Files
MailUI4Agents/gateway/internal/repo/users.go
JianFeeeee 07e6b789b2 feat: 配额下沉到会话 + 窄屏覆盖式布局 + 工作列表卡片视图
## 配额重构:废除 Agent 终身额度

原实现在 agents 上放一个 max_rounds/used_rounds 计数器,used_rounds 单调递增、
永不重置 —— 跑满就要管理员手工重置才能再干活。那是把一次性资源模型套在长期
在线的服务上,且并行任务互相抢额度。

改为:
- 唯一被强制的预算是【会话】的往返预算(sessions.max_rounds/used_rounds),
  写信时给、对话页里随时改 —— 配额的语义是「这件事值得多少个来回」,
  那是任务的属性而不是 Agent 的属性
- agents.default_rounds 只作为「派给这个 Agent 的新任务」的默认值(默认 20)
- agents.used_rounds 降级为纯统计
- 新建会话速率限制(1h/20 条)堵住用 .new 开一串新会话绕过预算;
  人类不受限(agentLimiterKey 返回空串即不计量)

## 窄屏适配(用户反馈「窄屏基本不可用」)

原先只有三栏并排:60(导航)+320(列表)+详情,375px 屏上详情被挤到 0。

第一版做成「一次只显示一栏」,用户纠正应当是新页面覆盖老页面并带动画,
于是重做为覆盖式:
- NarrowStack:底层列表始终挂载,详情绝对定位盖在上面。两个好处 ——
  列表滚动位置与选中态天然保留;退出动画有东西可播(直接卸载再渲染另一个
  组件的话,没有任何一帧能让旧页面往右滑出去)
- 因此必须区分「逻辑上是否打开」与「是否还在 DOM 里」:关闭时先播 200ms
  滑出,动画结束才卸载
- 入场用双层 requestAnimationFrame:必须让浏览器至少绘制一帧「在右侧之外」
  的状态,否则挂载与 translate-x-0 在同一帧内完成,transition 不触发
- 窄屏专属控件用 useIsNarrow() 条件渲染而非 md:hidden —— 后者只是视觉隐藏,
  宽屏用户按 Tab 会聚焦到看不见的返回按钮
- 底部导航 + 抽屉侧栏 + env(safe-area-inset-bottom)

## 工作列表卡片视图(Phase 7.1 最后一项)

中间栏可切列表/卡片。列表答「跟谁在聊」,卡片答「在聊什么、进展如何」:
主题 + 最新一封的发件人与摘要 + 往返预算徽标。

- 两种视图共用同一份数据与同一套动作;归档确认框也共用 —— 归档是破坏性操作,
  换个视图就换套确认 UI 只会让人对「自己点了什么」更没底
- 预算徽标在「不限」时不显示(对每张卡片都成立的「0/0」是纯噪声)
- 数据一次取回,不让卡片为每条会话再打一次库

## 修掉的缺陷

- GET /me/sessions 一直 500:ListSessionsFor 的 SELECT 加了预算两列却没加进
  Scan,列数不匹配。联系人栏一条数据都拉不到,而错误只是「Failed to list sessions」
- GET /sessions/{id} 忘了填充附件:前端会话视图走的是这个端点,于是 Agent
  回信里的附件在 UI 上完全不存在(另一个端点填了但没人调用)
- 插件曾完全没在加载:为了可测在 index.js 里 export 了辅助函数与一个 Map,
  而 opencode 把入口模块的每一个导出都当成插件工厂逐个检查,多导出一个 Map
  就 "Plugin export is not a function",插件静默失效、邮件全投不进去。
  逻辑挪到 lib/relay-dedup.js,并加断言钉住「入口只有 default 导出」
- 同一件事发两封邮件:模型带附件主动回信后,session.idle 又把它最后那段话
  自动转了一遍(生产实测 311 与 342 字节各一封)。explicitSends 记录本轮
  主动发信,自动转发据此让位;relay_key 幂等管不了这个 —— 那个键保证的是
  「同一条消息不转两次」
- SQLite 时间戳只有秒精度:同秒插入的多封邮件排序不确定(实测同秒插 5 封,
  顺序由随机 UUID 决定)。「会话里最早那封」(决定联系人身份)与「最后那封」
  (决定最新进展)都会取错。NOW() 升到微秒 + mails 的 INSERT 显式传它
  (改 schema 默认值只对新库生效,SQLite 没有 ALTER COLUMN)+ 所有
  ORDER BY created_at 补 mail_id 兜底
- fillAttachments 从逐封查询改成一次 IN(...):原来是 N+1,200 封的会话打开
  要打 200 次库
- repo 层 5 处 rows.Next() 循环补 rows.Err():没有它,读到一半连接断掉会
  静默返回部分结果,UI 上表现为「邮件凭空少了几封」
- go:embed 占位页改名 placeholder.html:叫 index.html 会被 Vite 产物覆盖并
  提交进去,而它引用的 assets/ 是被忽略的 —— 新克隆打开是白屏

## 回复/转发栏

- 两处都加抄送(可折叠);原邮件带抄送时多一个「回复全部」,回填用
  cc_list[].raw 而非重拼 name@path(后者会丢掉会话段)
- 会话视图每张卡片加转发入口:转发之前只存在于单封邮件视图,而人多数时间
  待在会话视图里,等于功能在 UI 上找不到
- ReplyBar 的错误从 console.error 改为显示出来:预算耗尽、地址不存在、
  速率限制都走这条路,之前点发送毫无反应

## 测试

- repo: 列顺序(三个 SQL 分支)、卡片字段、previewRunes 边界、时间戳亚秒精度、
  批量附件查询、速率限制(80 goroutine 断言恰好 20 条通过)
- web: 窄屏布局 16 条结构性断言(覆盖而非分栏、延迟卸载、双层 rAF、
  条件渲染而非 md:hidden)
- 插件: 自动转发去重 17 条(含「入口只有 default 导出」不变量)
- install.sh 把插件测试也纳入部署前门禁
2026-09-02 14:16:46 +08:00

513 lines
14 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 repo
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
const (
bcryptCost = 12
sessionTTL = 7 * 24 * time.Hour
userSelectCols = `user_id, username, display_name, password_hash, role, status, created_at, last_login, allowed_agents, allowed_paths`
)
var (
ErrUserNotFound = errors.New("user not found")
ErrBadCredentials = errors.New("invalid username or password")
ErrUserDisabled = errors.New("user disabled")
ErrNameTaken = errors.New("name already taken by an agent or user")
ErrSessionInvalid = errors.New("session invalid or expired")
ErrInvalidUsername = errors.New("username must be 2-64 chars of [a-z0-9._-]")
ErrAlreadySetup = errors.New("system already initialized")
)
// ---------- 命名空间校验 ----------
// 三维地址的 name 位由人类用户与 Agent 共用,因此必须全局唯一
func nameTaken(ctx context.Context, name string) (bool, error) {
var n int
err := db.DB.QueryRowContext(ctx, `
SELECT (SELECT COUNT(*) FROM users WHERE username = $1)
+ (SELECT COUNT(*) FROM agents WHERE agent_name = $1)
`, name).Scan(&n)
return n > 0, err
}
// AgentNameAvailable 供 Agent 注册前校验(不与人类用户重名)
func AgentNameAvailable(ctx context.Context, agentName string) (bool, error) {
var n int
err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM users WHERE username = $1`, agentName).Scan(&n)
return n == 0, err
}
func validUsername(name string) bool {
if len(name) < 2 || len(name) > 64 {
return false
}
for _, r := range name {
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-'
if !ok {
return false
}
}
// 保留字human 是兼容别名,不能被真实用户占用
return name != "human"
}
// ---------- User CRUD ----------
func scanUser(row *sql.Row) (*models.User, error) {
var u models.User
var agentsJSON, pathsJSON []byte
err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash,
&u.Role, &u.Status, &u.CreatedAt, &u.LastLogin, &agentsJSON, &pathsJSON)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, err
}
u.AllowedAgents = decodeStrList(agentsJSON)
u.AllowedPaths = decodeStrList(pathsJSON)
return &u, nil
}
func decodeStrList(raw []byte) []string {
out := []string{}
if len(raw) > 0 {
_ = json.Unmarshal(raw, &out)
}
if out == nil {
out = []string{}
}
return out
}
func CreateUser(ctx context.Context, username, password, displayName, role string,
allowedAgents, allowedPaths []string) (*models.User, error) {
username = strings.ToLower(strings.TrimSpace(username))
if !validUsername(username) {
return nil, ErrInvalidUsername
}
if role != "admin" {
role = "user"
}
if displayName == "" {
displayName = username
}
taken, err := nameTaken(ctx, username)
if err != nil {
return nil, err
}
if taken {
return nil, ErrNameTaken
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return nil, err
}
agentsJSON, _ := json.Marshal(normalizeList(allowedAgents))
pathsJSON, _ := json.Marshal(normalizeList(allowedPaths))
row := db.DB.QueryRowContext(ctx, `
INSERT INTO users (username, display_name, password_hash, role, allowed_agents, allowed_paths)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING `+userSelectCols,
username, displayName, string(hash), role, agentsJSON, pathsJSON)
return scanUser(row)
}
func GetUserByName(ctx context.Context, username string) (*models.User, error) {
return scanUser(db.DB.QueryRowContext(ctx,
`SELECT `+userSelectCols+` FROM users WHERE username = $1`,
strings.ToLower(strings.TrimSpace(username))))
}
func GetUserByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
return scanUser(db.DB.QueryRowContext(ctx,
`SELECT `+userSelectCols+` FROM users WHERE user_id = $1`, id))
}
func ListUsers(ctx context.Context) ([]models.User, error) {
rows, err := db.DB.QueryContext(ctx,
`SELECT `+userSelectCols+` FROM users ORDER BY created_at ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
users := []models.User{}
for rows.Next() {
var u models.User
var agentsJSON, pathsJSON []byte
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash,
&u.Role, &u.Status, &u.CreatedAt, &u.LastLogin, &agentsJSON, &pathsJSON); err != nil {
return nil, err
}
u.AllowedAgents = decodeStrList(agentsJSON)
u.AllowedPaths = decodeStrList(pathsJSON)
users = append(users, u)
}
return users, nil
}
// UserUpdate 描述一次用户更新nil 字段表示不改
type UserUpdate struct {
DisplayName *string
Role *string
Status *string
AllowedAgents *[]string
AllowedPaths *[]string
}
func UpdateUser(ctx context.Context, id uuid.UUID, up UserUpdate) (*models.User, error) {
var agentsJSON, pathsJSON *string
if up.AllowedAgents != nil {
b, _ := json.Marshal(normalizeList(*up.AllowedAgents))
s := string(b)
agentsJSON = &s
}
if up.AllowedPaths != nil {
b, _ := json.Marshal(normalizeList(*up.AllowedPaths))
s := string(b)
pathsJSON = &s
}
row := db.DB.QueryRowContext(ctx, `
UPDATE users SET
display_name = COALESCE($2, display_name),
role = COALESCE($3, role),
status = COALESCE($4, status),
allowed_agents = COALESCE($5`+db.JSONCast()+`, allowed_agents),
allowed_paths = COALESCE($6`+db.JSONCast()+`, allowed_paths)
WHERE user_id = $1
RETURNING `+userSelectCols,
id, up.DisplayName, up.Role, up.Status, agentsJSON, pathsJSON)
return scanUser(row)
}
// normalizeList 去空白、去空项、去重,保持顺序
func normalizeList(in []string) []string {
out := make([]string, 0, len(in))
seen := map[string]bool{}
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
func SetPassword(ctx context.Context, id uuid.UUID, newPassword string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost)
if err != nil {
return err
}
tag, err := db.DB.ExecContext(ctx,
`UPDATE users SET password_hash = $2 WHERE user_id = $1`, id, string(hash))
if err != nil {
return err
}
if n, _ := tag.RowsAffected(); n == 0 {
return ErrUserNotFound
}
// 改密后踢掉该用户所有会话
_, _ = db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE user_id = $1`, id)
return nil
}
func DisableUser(ctx context.Context, id uuid.UUID) error {
tag, err := db.DB.ExecContext(ctx,
`UPDATE users SET status = 'disabled' WHERE user_id = $1`, id)
if err != nil {
return err
}
if n, _ := tag.RowsAffected(); n == 0 {
return ErrUserNotFound
}
_, _ = db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE user_id = $1`, id)
return nil
}
func CountAdmins(ctx context.Context) (int, error) {
var n int
err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM users WHERE role = 'admin' AND status = 'active'`).Scan(&n)
return n, err
}
// EnsureAdminUser 首次启动时创建默认管理员(幂等)
func EnsureAdminUser(ctx context.Context, username, password string) (*models.User, bool, error) {
if n, err := CountAdmins(ctx); err != nil {
return nil, false, err
} else if n > 0 {
u, err := GetUserByName(ctx, username)
if err != nil && !errors.Is(err, ErrUserNotFound) {
return nil, false, err
}
return u, false, nil
}
u, err := CreateUser(ctx, username, password, "管理员", "admin", nil, nil)
if err != nil {
return nil, false, err
}
return u, true, nil
}
// ---------- 登录 / 会话令牌 ----------
func Authenticate(ctx context.Context, username, password string) (*models.User, error) {
u, err := GetUserByName(ctx, username)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
// 统一错误,避免暴露用户是否存在
return nil, ErrBadCredentials
}
return nil, err
}
if u.Status != "active" {
return nil, ErrUserDisabled
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
return nil, ErrBadCredentials
}
_, _ = db.DB.ExecContext(ctx, `UPDATE users SET last_login = NOW() WHERE user_id = $1`, u.ID)
return u, nil
}
func newToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func CreateUserSession(ctx context.Context, userID uuid.UUID, userAgent string) (string, time.Time, error) {
token, err := newToken()
if err != nil {
return "", time.Time{}, err
}
expires := time.Now().Add(sessionTTL)
if len(userAgent) > 256 {
userAgent = userAgent[:256]
}
_, err = db.DB.ExecContext(ctx, `
INSERT INTO user_sessions (token, user_id, expires_at, user_agent)
VALUES ($1, $2, $3, $4)`, token, userID, expires, userAgent)
if err != nil {
return "", time.Time{}, err
}
// 顺手清理过期令牌
_, _ = db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE expires_at < NOW()`)
return token, expires, nil
}
// ResolveUserSession 校验令牌并滑动续期
func ResolveUserSession(ctx context.Context, token string) (*models.User, error) {
if token == "" {
return nil, ErrSessionInvalid
}
var userID uuid.UUID
err := db.DB.QueryRowContext(ctx, `
SELECT user_id FROM user_sessions
WHERE token = $1 AND expires_at > NOW()`, token).Scan(&userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrSessionInvalid
}
return nil, err
}
u, err := GetUserByID(ctx, userID)
if err != nil {
return nil, err
}
if u.Status != "active" {
return nil, ErrUserDisabled
}
_, _ = db.DB.ExecContext(ctx,
`UPDATE user_sessions SET expires_at = $2 WHERE token = $1`,
token, time.Now().Add(sessionTTL))
return u, nil
}
func DeleteUserSession(ctx context.Context, token string) error {
_, err := db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE token = $1`, token)
return err
}
// ---------- 人类用户候选(供地址补全) ----------
func ListActiveUsernames(ctx context.Context) ([]string, error) {
rows, err := db.DB.QueryContext(ctx,
`SELECT username FROM users WHERE status = 'active' ORDER BY username`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var s string
if err := rows.Scan(&s); err == nil {
out = append(out, s)
}
}
return out, rows.Err()
}
// ---------- 会话归属 ----------
func SetSessionOwner(ctx context.Context, sessionID, userID uuid.UUID) error {
_, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET owner_user_id = $2 WHERE session_id = $1 AND owner_user_id IS NULL`,
sessionID, userID)
return err
}
// SessionOwnerUsername 返回会话归属人类用户名;无归属时返回空串
func SessionOwnerUsername(ctx context.Context, sessionID uuid.UUID) (string, error) {
var name *string
err := db.DB.QueryRowContext(ctx, `
SELECT u.username
FROM sessions s LEFT JOIN users u ON u.user_id = s.owner_user_id
WHERE s.session_id = $1`, sessionID).Scan(&name)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("session %s not found", sessionID)
}
return "", err
}
if name == nil {
return "", nil
}
return *name, nil
}
// UserCanAccessSession 判断用户能否访问该会话owner、或在邮件收发/抄送中出现,或 admin
func UserCanAccessSession(ctx context.Context, u *models.User, sessionID uuid.UUID) (bool, error) {
if u.IsAdmin() {
return true, nil
}
var n int
err := db.DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM sessions s
WHERE s.session_id = $1
AND (s.owner_user_id = $2
OR EXISTS (
SELECT 1 FROM mails m
WHERE m.session_id = s.session_id
AND (m.from_name = $3 OR m.to_name = $3
OR `+db.CCHas("m.cc_list", 3)+`)
))
`, sessionID, u.ID, u.Username).Scan(&n)
return n > 0, err
}
// FirstAdminUsername 返回最早创建的可用管理员用户名(用于无归属会话的兜底决策人)
func FirstAdminUsername(ctx context.Context) (string, error) {
var name string
err := db.DB.QueryRowContext(ctx, `
SELECT username FROM users
WHERE role = 'admin' AND status = 'active'
ORDER BY created_at ASC LIMIT 1`).Scan(&name)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return "", err
}
return name, nil
}
// RandomPassword 生成一个随机初始密码(首次启动无 ADMIN_PASSWORD 时使用)
func RandomPassword(n int) string {
const charset = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "ChangeMe" + fmt.Sprint(time.Now().Unix())
}
for i := range b {
b[i] = charset[int(b[i])%len(charset)]
}
return string(b)
}
// ---------- Setup首次初始化管理员 ----------
// NeedsSetup 返回系统是否尚未初始化(没有任何用户)
func NeedsSetup(ctx context.Context) (bool, error) {
var n int
err := db.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
return n == 0, err
}
// SetupFirstAdmin 在系统尚无任何用户时创建首个管理员。
// 已初始化时返回 ErrAlreadySetup避免被用作后门。
func SetupFirstAdmin(ctx context.Context, username, password, displayName string) (*models.User, error) {
empty, err := NeedsSetup(ctx)
if err != nil {
return nil, err
}
if !empty {
return nil, ErrAlreadySetup
}
if displayName == "" {
displayName = username
}
return CreateUser(ctx, username, password, displayName, "admin", nil, nil)
}
// ---------- 可选目录候选(供权限设置界面) ----------
// AllWorkspaceNames 汇总所有 Agent 注册过的工作区名,供管理员挑选可访问目录
func AllWorkspaceNames(ctx context.Context) ([]string, error) {
rows, err := db.DB.QueryContext(ctx, `
SELECT DISTINCT ws->>'name' AS name
FROM agents, jsonb_array_elements(workspaces) AS ws
WHERE COALESCE(ws->>'name', '') <> ''
ORDER BY name`)
if err != nil {
return []string{}, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var s string
if err := rows.Scan(&s); err == nil {
out = append(out, s)
}
}
return out, rows.Err()
}
// IsHumanUser 判断某个三维地址 name 位是否为人类用户
func IsHumanUser(ctx context.Context, name string) (bool, error) {
var n int
err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM users WHERE username = $1`, name).Scan(&n)
return n > 0, err
}