refactor(repo): workspace 谓词抽成共享构造器 + 删一个死函数
用户 2026-09-26:「审查一下服务端,我觉得现在还是有大量不符合设计的地方与冗余代码」。
# 先说审查结论:**"大量冗余"核不出来**
| 检查项 | 读数 |
| --- | --- |
| 99 个 handler | **全部注册,零死路由** |
| 死函数 | 2 个(本次删 1,另 1 个被测试用、保留) |
| 注释占比 | 23%(这个仓每个非显然决定都记"为什么",是有意的) |
| 测试 | 13644 行 = 源的 41% |
# 但找到一处真问题:`workspace` 谓词手抄了三遍
同一件事在三处各写一遍:
args := []any{agentName}
if strings.TrimSpace(workspace) != "" {
args = append(args, workspace)
q += fmt.Sprintf(` AND s.workspace = $%d`, len(args))
}
★ 代价不是"多几行",是**加参数要改三处、漏一处不会编译报错**。
本次给三个函数加 workspace 参数(`ListInboxScoped`/`CountUnreadScoped`/
`MarkAllInboxReadForSession`)就是手抄了三遍。
同仓有同类先例:`quota.go` 里那条 `★★★ 判据自检` 记的
「占位符编号错位导致静默少行」—— 根因完全一样(同一个模板抄多处,
靠人肉保持一致)。
⇒ 抽 `workspaceScope(q, args, workspace) (string, []any)`,三处各变成一行。
# 为什么"必需"这条不在 repo 层
`checkWorkspace` **允许空**:空 = 不过滤 = 人类侧(一个人跨工作区,WebUI
按 session_workspace 分组显示)。"Agent 侧必须带"是**接口契约**,放在 Handler。
抽出来的函数注释里把这层分工写死了,免得后来者以为 repo 层该拒绝空值。
# 与 `FindOrCreateDefaultSession` 里那套**故意不共用**
那里要的是「工作区为空时从 mails 反推」(历史会话兼容),语义更宽。
合并前要先确认那是不是想要的行为 —— 现在保持分开。
# 删 `SessionMailCount`
全仓零调用(连测试都没有)。`GetSessionMailByID` 也只被两个测试用,
但它是那两个测试的被测对象,**不删**(测试专用包装与死代码不是一回事)。
# 验证
· 变异:把 `workspaceScope` 改成永远不过滤 ⇒
`TestInboxListIsScopedByWorkspace` + `TestMarkAllReadIsScopedByWorkspace` 判红
· 12 个包通过;`internal/repo` 唯一的 FAIL
(`TestReplacePlatformSessionsKeepsOtherWorkspaces`)**改动前就红** ——
已用 `git stash` 式回退验证,它是 `DEBTS.json` 里记的 platform_sessions
PK 缺陷那条判据,与本次无关。
# 顺带记一笔(对我自己的)
本机 `go` 是 1.24.4 而 `go.mod` 要求 1.25.0,**`go build` 会去下载 toolchain
并因离线失败**(exit=1)。我前面几轮用 `go build ./... | head -5 && echo "编译 ok"`
判断,把 `head` 的 exit 0 当成了编译成功 —— **那是假的**。本轮才发现,
改用本地已有的 `toolchain@v0.0.1-go1.26.7` 才拿到可信结果。
⇒ 判据里凡用 `cmd | head && echo ok` 的形状,退出码被管道最后一道吞掉,
之后一律用 `cmd >/dev/null 2>&1; echo $?` 或显式检查 `${PIPESTATUS[0]}`。
This commit is contained in:
@ -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<string> {
|
||||
/*
|
||||
* ★ 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<void>`,**不是 `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<void> {
|
||||
// 先查现状:已经开了就别再申请(也解释"为什么没弹窗")
|
||||
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<PushRegisterResponse>('/me/devices/push-token', body);
|
||||
const resp: PushRegisterResponse = await api.post<PushRegisterResponse>('/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;
|
||||
/*
|
||||
|
||||
@ -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 三维地址
|
||||
|
||||
Reference in New Issue
Block a user