feat(线索树)★★: 卡片视图接线 + C 层 admin 全量邮件
## ① 卡片视图(WorkCard)
上一轮只接了列表视图 —— 但用户原话是「形成/展示为树结构」,
只在一个视图里成立不算:**切一下视图,线索树就没了**。
缩进比列表更紧(8px/级、上限 3 级):卡片本来就有三块内容
(发件人行 / 主题 / 摘要 + 预算条),400px 侧栏里每级 12px
挤掉的是**摘要本身** —— 摘要没了,卡片就只剩一个标题。
判据加一格专门盯「两个视图都接了」,否则这格缺口会一直没人看。
## ② C 层:admin 全量邮件(scope=all)
GET /api/v1/me/mail/inbox?scope=all admin 才有效
GET /api/v1/me/mail/inbox 默认,仍是自己收件箱
三条边界,两个变异都转红:
| | 行为 | 变异后 |
|---|---|---|
| 非 admin 要 scope=all | **403** | 静默降级 200 ⇒ 转红 |
| 默认(无 scope) | 自己的,一封不多 | 身份隐式全给 ⇒ 转红 |
| 非法 scope | 400 | — |
**静默降级是最危险的那个**:调用方会以为拿到了全量(实际没有)——
「看起来能用的错答案」,比报错难查得多。
**默认不给 admin 全量**:全看必须显式要求,不能靠身份隐式获得。
### 为什么单独写 ListAllMails,没给 ListInboxScoped 加参数
`ListInboxScoped` 的第一个参数 `agentName` 兼任两职:
① SQL 里的 reader 过滤
② `readStateFor("$1")` 算 status(已读/未读是**按读者**记的)
两者都必须有值 —— `requireReader` 就是为此存在。
若把「全量」做成「reader 传空」,那个非法状态看起来就合法了;
一旦放进去,**status 会静默变成未读** —— 一个没人会注意到、
却让「已读/未读」全面失真的坑。
同理,admin 全量视图里 `status=unread` **明确报错**而不是返回全部 ——
后者会让前端把整箱染成"未读"。admin 不是任何一封信的读者。
### scanMailRows 提取(repo.go +16/-0,纯新增)
第二份手写扫描副本的第一个分叉点必然是「admin 视图少算一个派生字段」,
而那在前端表现为某个徽标不见了,极难归因。⇒ 两处共用一份,
`ListInboxScoped` 行为一行未改。
## ★ 这不触碰 Agent 侧的收窄
`ListInboxScoped` 里 `to_name = reader OR cc` 那条是 **AgentAuth** 用的,
是 15e4fe9 / 095213b 修出来的越权防护。`scope=all` 是**人类登录态**下
admin 的显式全量视图,两条通道互不相干 —— 语义不同,不要混谈。
## 判据自己错了一次
`TestAdminScopeAllSeesEverything` 红在 500,报
`Scan: invalid UUID length: 7` —— 看着像 SQL/扫描代码坏了,
其实是判据自己的数据不对(`mail_id` 是 UUID,我塞了 `"m-admin"`)。
**判据数据错了会伪装成被测代码坏了。**
14 包全绿;Electron 带改动 fail 9 / 基线 fail 12(减少 3 红、无新红,
剩下的是其他会话 harmony 设备判据的红)。
This commit is contained in:
@ -236,8 +236,36 @@ func MeGetInbox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
mails, err := repo.ListInbox(r.Context(), user.Username, status, "", limit)
|
||||
// scope=all:admin 全量视图(用户 2026-10-04 定的分层可见性)。
|
||||
//
|
||||
// ★ 为什么是「同一个 URL 加参数」而不是新端点:
|
||||
// 可见性收窄集中在一处判断,少一个端点就少一处将来忘了收窄的地方。
|
||||
//
|
||||
// ★ 普通用户显式请求 scope=all 必须 **403**,不能静默降级回自己的收件箱 ——
|
||||
// 静默降级会让调用方以为拿到了全量(实际没有),
|
||||
// 那是「看起来能用的错答案」,比报错难查得多。
|
||||
scope := r.URL.Query().Get("scope")
|
||||
mails, err := func() ([]models.Mail, error) {
|
||||
if scope == "" || scope == "me" {
|
||||
return repo.ListInbox(r.Context(), user.Username, status, "", limit)
|
||||
}
|
||||
if scope != "all" {
|
||||
Error(w, http.StatusBadRequest, "scope 只能是 me 或 all")
|
||||
return nil, errAlreadyAnswered
|
||||
}
|
||||
if !user.IsAdmin() {
|
||||
// ★ fail-closed:非 admin 要全量 ⇒ 拒绝,**不回退**到自己的收件箱。
|
||||
// 静默降级会让调用方以为拿到了全量(实际没有)——
|
||||
// 那是「看起来能用的错答案」,比报错难查得多。
|
||||
Error(w, http.StatusForbidden, "scope=all 需要 admin 权限")
|
||||
return nil, errAlreadyAnswered
|
||||
}
|
||||
return repo.ListAllMails(r.Context(), status, limit)
|
||||
}()
|
||||
if err != nil {
|
||||
if errors.Is(err, errAlreadyAnswered) {
|
||||
return // 4xx 已写,不再覆写成 500
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to list inbox")
|
||||
return
|
||||
}
|
||||
@ -255,6 +283,9 @@ func MeGetInbox(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// errAlreadyAnswered 表示「响应已经写完了,别再覆写成 500」。
|
||||
var errAlreadyAnswered = errors.New("already answered")
|
||||
|
||||
// GET /api/v1/me/mail/sent
|
||||
func MeGetSent(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
|
||||
Reference in New Issue
Block a user