diff --git a/client/harmony/entry/src/main/ets/api/PushService.ets b/client/harmony/entry/src/main/ets/api/PushService.ets index 3fb1dc3..c86ef9a 100644 --- a/client/harmony/entry/src/main/ets/api/PushService.ets +++ b/client/harmony/entry/src/main/ets/api/PushService.ets @@ -41,10 +41,16 @@ const MARKER_KEY: string = 'reported_marker'; const SETTING_KEY: string = 'push_enabled'; /* - * ★ 2026-09-15:接入系统通知是**可配置项**(用户强调多次)。 - * 默认 **关** —— 自部署的用户没有服务端推送凭证时,App 不取 token、不请求权限、不上报、不打网关。 - * 开了以后才走 getToken → requestEnableNotification → 上报这一整条。 - * 这条开关存在 PushService 里(而不是设置页 @State),是因为 onCreate(ability 阶段)要读它。 + * ★ 2026-09-15:接入系统通知是**可配置项**(用户强调多次 —— "用户可以自由部署 + * 自己的 agentmail")。默认 **开**(见 PushContract.DEFAULT_PUSH_ENABLED)。 + * + * ★ 2026-09-26 订正:这段原先写的是"默认关",与常量、`isEnabled`、`reportToken` + * 三处的实际行为**矛盾**(都是默认开)。按用户当初的原话,正确落点是 + * **服务端没配通道时优雅降级**(回 `enabled:false` 即跳过,不报错、不阻塞), + * 而不是让客户端默认关掉——那样自部署用户即使配好了通道也收不到,除非 + * 他自己去设置页翻开关。 + * + * 开关读在 PushService 里(而不是设置页 @State),是因为 onCreate(ability 阶段)要读它。 */ /** 点击跳转的目标(由通知 data 解析而来;只有两样东西要路由) */ @@ -151,23 +157,85 @@ export class PushService { * 取 Push Kit 的 token。**取不到就返回空串**(没装 HMS Core、没登录华为账号、没权限……都是正常情况)。 */ private async getToken(): Promise { + /* + * ★ 2026-09-26:这里原来**只有抛异常那一条路径有日志**,而 + * 「Push Kit 成功返回但值是空的」那条**什么都不留**就直接 return。 + * 真机上两种都表现为"服务端收不到 POST",从日志上完全分不开 —— + * 而这正是我这版包要解决的:分不开就只能再发一版。 + * + * 现象(用户实测,关开关各两次): + * 服务端 GET /me/devices/push-token 有(三次), + * POST /me/devices/push-token 零条,push_tokens 表空 + * ⇒ 断点必然在 `if (token.length === 0) return;` 这一行。 + */ try { const token: string = await pushService.getToken(); - return token === undefined || token === null ? '' : token; + if (token === undefined || token === null) { + hilog.info(DOMAIN, TAG, + 'Push Kit 返回了空值(未取到 token):不是异常路径,所以原先没有日志;' + + '常见于设备未登录华为账号 / 无 HMS Core / 应用未开通推送'); + return ''; + } + hilog.info(DOMAIN, TAG, 'Push Kit 取到 token:%{public}s 字符(尾 %{public}s)', + String(token.length), token.slice(-6)); + return token; } catch (e) { const err = e as BusinessError; - hilog.info(DOMAIN, TAG, 'push token 取不到(静默,属正常):%{public}s', err.message); + hilog.info(DOMAIN, TAG, 'push token 取不到(静默,属正常):code=%{public}s %{public}s', + String(err.code), err.message); return ''; } } /** 申请通知权限(用户拒绝也不影响主链) */ + /* + * 申请通知权限。 + * + * ★ 2026-09-26 三处订正(都是"看不出发生了什么"造成的): + * + * ① 这个 API 返回 `Promise`,**不是 `AuthResult``** —— 我第一版照 + * 其他平台的印象写成了 `res.authResults`,而本机 SDK 的声明里根本没有这个类型 + * (`@ohos.notificationManager.d.ts:633`)。判据是"读本机 SDK 声明", + * 不是"记得某个平台的 API 长什么样"。 + * + * ② **用户拒绝过之后,这个 API 再也不会弹窗了**(同一份声明 L560-563: + * "the application cannot use this API to open the dialog box again", + * 只能改用 openNotificationSettingsWithResult)。所以"拨两次开关都没反应" + * 有一种完全合理、且**不会报错**的解释:第一次拒绝后这里就静默失败了。 + * → 因此先查状态(isNotificationEnabledSync),只在没开时才申请; + * 已经是开的就别去撞那个已经废掉的弹窗路径。 + * + * ③ 无论走哪条路都记一行 —— 这一整条链(权限→token→上报)每一步都可能 + * 静默失败,而服务端只能看到"没有 POST"。日志是唯一能定性的一端。 + */ async requestEnableNotification(): Promise { + // 先查现状:已经开了就别再申请(也解释"为什么没弹窗") + let already: boolean = false; try { - await notificationManager.requestEnableNotification(this.context); + already = notificationManager.isNotificationEnabledSync(); } catch (e) { const err = e as BusinessError; - hilog.info(DOMAIN, TAG, '通知权限未开(静默):%{public}s', err.message); + hilog.info(DOMAIN, TAG, '读通知权限状态失败 code=%{public}s %{public}s', + String(err.code), err.message); + } + if (already) { + hilog.info(DOMAIN, TAG, '通知权限:已开启(无需申请)'); + return; + } + hilog.info(DOMAIN, TAG, '通知权限:未开启,申请中…'); + try { + await notificationManager.requestEnableNotification(this.context); + hilog.info(DOMAIN, TAG, '通知权限:申请返回(无异常即视为成功)'); + } catch (e) { + const err = e as BusinessError; + /* + * 1600004 = Notification disabled(用户拒绝过)。 + * 这不是异常情况,是**用户的选择**,所以文案要说清"该怎么办", + * 而不是让排查的人以为是代码坏了。 + */ + hilog.info(DOMAIN, TAG, + '通知权限:申请失败 code=%{public}s %{public}s(1600004 = 用户曾拒绝,需去系统设置里手动开启)', + String(err.code), err.message); } } @@ -214,6 +282,7 @@ export class PushService { * 这条 gate 在**最前**:getToken/requestEnableNotification 都在它后面。 */ if (!PushService.isEnabled(this.context)) { + hilog.info(DOMAIN, TAG, '上报跳过:系统通知开关是关的(不会取 token、不打网关)'); return; } // ★ 先申请通知权限再取 token:部分设备上 Push Kit getToken 会因通知权限未开而 @@ -221,9 +290,26 @@ export class PushService { await this.requestEnableNotification(); const token: string = await this.getToken(); if (token.length === 0) { - return; // 没有 token 就没什么可报的(这不是错误) + // 没有 token 就没什么可报的(这不是错误)。但**必须留痕** —— + // 上一版这里静默 return,导致"服务端零 POST"有两种可能分不开 + // (取不到 token / 时机不对),白等一轮。见 getToken 的注释。 + hilog.info(DOMAIN, TAG, '上报跳过:getToken 没给出 token(原因见上面几行)'); + return; } + /* + * ★ 成功路径补一行日志(2026-09-26)。 + * + * 之前**只有失败路径有日志**(且是 hilog.info),于是真机上出现"收不到通知" + * 时,从服务端看只有"设备没来登记",从设备看又没有任何记录 —— + * 分不清是「getToken 取不到」「上报被拒」「服务端 enabled:false」 + * 还是「上报成功但推送没到」。 + * + * 这一行让最后一种也有痕迹。注意它**不打印 token 本身**(那是凭据), + * 只打印尾 6 位与服务端回的 enabled/providers。 + */ const accountKey: string = this.account.getActiveId(); + hilog.info(DOMAIN, TAG, '准备上报 token:尾 %{public}s,账号 %{public}s', + token.slice(-6), accountKey || '(无)'); const lastMarker: string = await this.readMarker(); /* * ★ 换账号必须重报:服务端里同一 token 换账号是**转移**, @@ -265,8 +351,14 @@ export class PushService { return; } try { - await api.post('/me/devices/push-token', body); + const resp: PushRegisterResponse = await api.post('/me/devices/push-token', body); await this.writeMarker(reportMarker(accountKey, token)); + /* + * enabled:false = 服务端没配推送通道(正常态,自部署的默认)。 + * 这一行是**区分"服务端没配"与"推送链路有别的问题"**的唯一服务端视角证据。 + */ + hilog.info(DOMAIN, TAG, '上报完成:enabled=%{public}s providers=%{public}s', + String(resp?.enabled), JSON.stringify(resp?.providers ?? [])); } catch (e) { const err = e as BusinessError; /* diff --git a/server/internal/repo/repo.go b/server/internal/repo/repo.go index d549b0d..d0e7755 100644 --- a/server/internal/repo/repo.go +++ b/server/internal/repo/repo.go @@ -620,6 +620,47 @@ func checkWorkspace(workspace string) error { } // unreadFor 返回"$n 这个读者看这封邮件是未读"的谓词;`m` 必须是 mails 的别名。 +/* +workspaceScope 把「收窄到某个工作区」这个谓词加到一条 SQL 上,并返回追加后的实参。 + +# 为什么要抽出来(不是"洁癖",是已经发生过的事故) + +收件箱列表、未读计数、标记已读这三个查询**必须是同一个口径**: +列表里看不到的信却被"全部标已读"标掉,就是静默丢信(session_scope_test.go 记过 +这个形状:read_inbox 按会话收窄那次修的就是它)。 + +而这三处原本各自维护同样的三行: + + args := []any{agentName} + if strings.TrimSpace(workspace) != "" { + args = append(args, workspace) + q += fmt.Sprintf(` AND s.workspace = $%d`, len(args)) + } + +★ 三行手抄的代价不是"多几行",而是**加参数时要改三处、漏一处不会编译报错**。 +本次给三个函数加 workspace 参数就是手抄了三遍(2026-09-26)。 +同类事故在本仓有先例:quota.go 的 `★★★ 判据自检` 记的"占位符编号错位导致 +静默少行" —— 根因完全一样(同一个模板抄多处,靠人肉保持一致)。 + +# 与 `FindOrCreateDefaultSession` 里那套的区别(**故意不同**) + +那里要的是「工作区为空时从邮件反推」(历史会话没有 workspace 的兼容), +语义更宽,是另一件事 —— 所以没共用这个。要合并前先确认那是不是想要的行为。 + +# 空值怎么办 + +空 = **不过滤**(人类侧跨工作区,WebUI 按 session_workspace 分组显示)。 +"Agent 侧必须带 workspace"这条约束在 Handler 层(接口契约),不在这里 +(数据层不变量)—— 混在一起会让人以为 repo 层该拒绝空值。 +*/ +func workspaceScope(q string, args []any, workspace string) (string, []any) { + if strings.TrimSpace(workspace) == "" { + return q, args + } + args = append(args, workspace) + return q + fmt.Sprintf(` AND s.workspace = $%d`, len(args)), args +} + func unreadFor(arg string) string { return `(m.status <> 'archived' AND NOT EXISTS ( SELECT 1 FROM mail_reads r WHERE r.mail_id = m.mail_id AND r.reader_name = ` + arg + `))` @@ -746,10 +787,7 @@ func ListInboxScoped(ctx context.Context, agentName, status, workspace string, l // 用 EXISTS 而不是再 JOIN 一次 sessions:s 已经在上面 JOIN 过了, // 这里直接把条件写进 WHERE 即可(同一条 s)。 args := []any{agentName} - if strings.TrimSpace(workspace) != "" { - args = append(args, workspace) - q += fmt.Sprintf(` AND s.workspace = $%d`, len(args)) - } + q, args = workspaceScope(q, args, workspace) if sessionID != uuid.Nil { // 会话收窄:只列这条线索里的邮件(见上面「会话维度」的说明) args = append(args, sessionID) @@ -843,10 +881,7 @@ func CountUnreadScoped(ctx context.Context, agentName, workspace string, session AND ` + unreadFor("$1") + ` AND s.status <> 'archived'` args := []any{agentName} - if strings.TrimSpace(workspace) != "" { - args = append(args, workspace) - q += fmt.Sprintf(` AND s.workspace = $%d`, len(args)) - } + q, args = workspaceScope(q, args, workspace) if sessionID != uuid.Nil { args = append(args, sessionID) q += fmt.Sprintf(` AND m.session_id = $%d`, len(args)) @@ -1327,13 +1362,6 @@ func aliasOwner(ctx context.Context, alias string) (*uuid.UUID, error) { return &id, nil } -func SessionMailCount(ctx context.Context, sessionID uuid.UUID) (int, error) { - var count int - err := db.DB.QueryRowContext(ctx, - `SELECT COUNT(*) FROM mails WHERE session_id = $1`, sessionID).Scan(&count) - return count, err -} - // ---------- Contacts / Archive ---------- // Contact 是「联系人」= 一条 name@path.session 三维地址