Files
MailUI4Agents/server/internal/handler/push.go
JianFeeeee a8d6811cd7 style(server): gofmt 注释规范 + init.sql 补 parent_session_id + 新增 ratelimit 判据
## 主体是 gofmt

`server/` 下 20 个文件的改动里,**142/147 行是注释与缩进** ——
Go 1.19+ 的 gofmt 把块注释里的 `//   x` 规范成 `//\tx`。
功能零变化。

## 一处实质:init.sql 补 parent_session_id

`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS parent_session_id
 TEXT REFERENCES sessions(session_id)`

远端已在 `db/migrate.go` 的 `sqliteAddColumns` 结构迁移通道里加了同一列;
init.sql 是**全新库**的建库脚本,缺这列会让新库少一个字段。

- `IF NOT EXISTS` ⇒ 与结构迁移通道幂等,两者并存无害
- 默认空 = 根节点;**不据历史数据猜父子**(猜错会让线索拓扑失真,比没有更糟)

## 新增 handler/ratelimit_test.go(162 行)

速率限制的判据。此前这一块没有独立判据。

## 验证(本机实跑,非推断)

    go build ./...                              通过
    go vet ./internal/{handler,repo,db,sse}/   通过
    go test -count=1(handler/repo/db/sse/blob)
      handler  41.3s  ok
      repo    101.9s  ok
      db       1.1s  ok
      sse      0.01s ok
      blob     0.08s ok

`-count=1` 是刻意的:第一遍跑出来全是 `(cached)`,那种绿灯不算数。
2026-10-05 09:26:59 +08:00

186 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"log"
"net/http"
"regexp"
"strings"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/push"
"github.com/agentmail/gateway/internal/repo"
"github.com/google/uuid"
)
/*
设备推送登记 —— /api/v1/me/devices/push-token
# 为什么这三个端点"没配推送也能用"
推送是自部署后端的**可选项**(用户 2026-09-15 明确要求)。所以:
- 登记/注销**不看服务端有没有配凭证**:照存。管理员之后把 `HMS_APP_ID/SECRET`
配上就立刻生效,客户端不必再登记一次(它那时可能已经不在前台了)。
- 响应里回 `enabled`:客户端据此知道「登记成功了,但服务端现在还没开推送」——
而不是把"没收到通知"当成登记失败去反复重试。
- 查不到 provider 凭证时**不返回错误状态码**:那是正常配置,不是故障。
# 为什么 provider 用字符串而不是枚举
加通道(web push、别的厂商)不该动端点形状、不该动 schema。合法性只做**形状**
校验(字符集与长度),不做白名单 —— 白名单会把「服务端还没实现的那个通道」
变成客户端的 400,而那恰恰是最不该拦的时候。
*/
var pushProviderRe = regexp.MustCompile(`^[a-z0-9_-]{1,32}$`)
// pushTokenRequest 是登记/注销共用的请求体。
type pushTokenRequest struct {
Provider string `json:"provider"`
Token string `json:"token"`
// DeviceName 仅供人辨认(如"我的手机"),服务端不做判据。
DeviceName string `json:"device_name"`
// SessionID 是客户端当前所在的会话:点通知要回到那条会话里的那封信。
// 允许为空(客户端还没进任何会话),此时通知只带 mail_id。
SessionID string `json:"session_id"`
}
// pushEnabled 是三个端点共用的响应片段。
func pushStatus() map[string]interface{} {
return map[string]interface{}{
"enabled": push.Enabled(),
"providers": push.Names(),
}
}
// RegisterPushToken 登记(或刷新)一台设备的推送地址。
//
// POST /api/v1/me/devices/push-token
func RegisterPushToken(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
var req pushTokenRequest
if !DecodeBody(w, r, &req) {
return
}
req.Provider = strings.TrimSpace(req.Provider)
req.Token = strings.TrimSpace(req.Token)
if !pushProviderRe.MatchString(req.Provider) {
Error(w, http.StatusBadRequest, "provider 非法(只允许小写字母、数字、下划线、连字符,最长 32)")
return
}
if req.Token == "" || len(req.Token) > 512 {
Error(w, http.StatusBadRequest, "token 非法(不能为空,最长 512 字符)")
return
}
/*
* session_id 非空时必须是**合法 UUID**(dsh 2026-09-17 报的真 bug)。
*
* 为什么这个校验值得加(那个 bug 无声地活了很久):
* 客户端一度把这个字段填成了**服务器地址**(`https://…/api/v1`),而这里只 TrimSpace、
* 允许任何字符串 ⇒ 不报错、不影响收信、不进日志。而**投递路径不看这个字段**
* (发通知用的是邮件自己的 `m.SessionID`,见 notify/mail.go;dispatch 只用 token 的
* Provider/Token),所以它错了也**没有任何机制会报警**。
* 可这个字段存在的唯一目的就是“点通知回到那条会话”—— 等哪天真按它路由,
* 人会被送到别的会话,而根因在一个看起来“存进去了”的字段上。
*
* 代价与取舍:老客户端(上报过错值的)下次上报会吃 **400**;按契约 400 归静默,
* 所以不打扰用户、只是那一次不上报(下次带对值就恢复)。用一次静默重试换掉
* “静默存错值”,值。
* 注意:这里**只校验非空值**——空串是契约允许的(“客户端还没进任何会话”)。
*/
if sid := strings.TrimSpace(req.SessionID); sid != "" {
if _, err := uuid.Parse(sid); err != nil {
Error(w, http.StatusBadRequest, "session_id 非法(必须是会话 UUID,或留空表示“还没有当前会话”)")
return
}
}
if err := repo.UpsertPushToken(r.Context(), req.Provider, req.Token, user.Username,
strings.TrimSpace(req.SessionID), strings.TrimSpace(req.DeviceName)); err != nil {
Error(w, http.StatusInternalServerError, "登记推送地址失败")
return
}
// 顺手清理长期没刷新的登记(90 天)。放在登记路径上是因为它天然低频
// (每台设备只在启动/token 轮换时来一次),不需要定时任务。
// 失败只记日志:清理失败不该让一次正常登记变成 500。
if _, err := repo.PruneStalePushTokens(r.Context()); err != nil {
log.Printf("[push] 清理过期推送登记失败: %v", err)
}
resp := pushStatus()
resp["ok"] = true
JSON(w, http.StatusOK, resp)
}
// DeletePushToken 注销一台设备的推送地址。
//
// DELETE /api/v1/me/devices/push-token
func DeletePushToken(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
var req pushTokenRequest
if !DecodeBody(w, r, &req) {
return
}
req.Provider = strings.TrimSpace(req.Provider)
req.Token = strings.TrimSpace(req.Token)
if !pushProviderRe.MatchString(req.Provider) || req.Token == "" {
Error(w, http.StatusBadRequest, "provider 或 token 非法")
return
}
deleted, err := repo.DeletePushToken(r.Context(), req.Provider, req.Token, user.Username)
if err != nil {
Error(w, http.StatusInternalServerError, "注销推送地址失败")
return
}
resp := pushStatus()
resp["ok"] = true
// deleted=false 不是错误(本来就没登记),但客户端可据此避免重复注销。
resp["deleted"] = deleted
JSON(w, http.StatusOK, resp)
}
// ListPushTokens 列出自己的推送登记。
//
// GET /api/v1/me/devices/push-token
func ListPushTokens(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
tokens, err := repo.ListPushTokensOf(r.Context(), user.Username)
if err != nil {
Error(w, http.StatusInternalServerError, "读取推送登记失败")
return
}
items := make([]map[string]interface{}, 0, len(tokens))
for _, t := range tokens {
items = append(items, map[string]interface{}{
"id": t.TokenID,
"provider": t.Provider,
// token 只回尾 6 位:客户端不需要全文(它自己刚发过来的),
// 而一个能读到全文的接口等于多一处泄漏面。
"token_tail": tailOf(t.Token, 6),
"device_name": t.DeviceName,
"session_id": t.SessionID,
})
}
resp := pushStatus()
resp["tokens"] = items
JSON(w, http.StatusOK, resp)
}
func tailOf(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[len(r)-n:])
}