// Package user 内的 repository.go 是 sqlc 生成的查询包(usersqlc)之上的薄 // 包装层。它的唯一职责是把 "驱动层类型"(pgtype.UUID / pgtype.Timestamptz / // int32 等)和 "领域类型"(uuid.UUID / time.Time / int / *time.Time)做双向 // 转换,并把底层的 pgx.ErrNoRows 翻译为 errs.AppError,使 service 层可以面 // 向干净的领域接口编程,无需直接依赖驱动细节。 // // 设计取舍: // - 之所以保留独立的 Repository interface,是为了给后续的 service 层提供 // 可替换的 mock 注入点(E6 单测无需起 Postgres)。 // - 所有 :one 查询都集中在此处把 pgx.ErrNoRows 翻译成对应语义的 // errs.Code(用户查找用 NotFound、会话查找用 Unauthorized),避免 // transport 层逐处判断。 // - pgtype 转换工具刻意保留为包内私有,仅在本文件使用;如果其它子包 // 也需要类似映射,应当各自维护或上抽到 infra/db。 package user import ( "context" "errors" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" usersqlc "github.com/yan1h/agent-coding-workflow/internal/user/sqlc" ) // Repository 抽象 user 模块对持久层的全部依赖。所有方法都接受 context 并 // 返回领域类型,既便于 service 层 mock,也方便日后切换驱动实现。 type Repository interface { CreateUser(ctx context.Context, u *User) (*User, error) GetUserByEmail(ctx context.Context, email string) (*User, error) GetUserByID(ctx context.Context, id uuid.UUID) (*User, error) CountUsers(ctx context.Context) (int, error) ListAdmins(ctx context.Context) ([]*User, error) CreateSession(ctx context.Context, s *Session) error GetSessionByTokenHash(ctx context.Context, hash []byte) (*Session, error) TouchSession(ctx context.Context, hash []byte) error DeleteSessionByTokenHash(ctx context.Context, hash []byte) error } // pgRepo 是 Repository 的 Postgres 实现,内部持有一个 sqlc 生成的 Queries。 // 字段刻意保持小写避免外泄;外部只能通过 NewPostgresRepository 构造。 type pgRepo struct { q *usersqlc.Queries } // NewPostgresRepository 用现有的 pgxpool 构造 Repository 实现。pool 必须由 // 调用方负责 Close;本仓库不接管其生命周期。 func NewPostgresRepository(pool *pgxpool.Pool) Repository { return &pgRepo{q: usersqlc.New(pool)} } // toPgUUID 把领域 uuid.UUID 转为 sqlc 期望的 pgtype.UUID。 // 始终标记 Valid=true:调用方传 uuid.Nil 即视为显式写入零值。 func toPgUUID(u uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: u, Valid: true} } // fromPgUUID 把 pgtype.UUID 转回领域 uuid.UUID。Valid=false 时返回 uuid.Nil, // 让上层用零值判定 "无值",无需关心驱动层的可空封装。 func fromPgUUID(p pgtype.UUID) uuid.UUID { if !p.Valid { return uuid.Nil } return uuid.UUID(p.Bytes) } // rowToUser 把 sqlc 生成的 User 行转为领域 User。所有时间字段直接取 .Time // 即可:用户表的 created_at/updated_at 在 schema 上是 NOT NULL DEFAULT now(), // 永远会 Valid。 func rowToUser(row usersqlc.User) *User { return &User{ ID: fromPgUUID(row.ID), Email: row.Email, PasswordHash: row.PasswordHash, DisplayName: row.DisplayName, IsAdmin: row.IsAdmin, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, } } // rowToSession 把 sqlc 生成的 UserSession 行转为领域 Session。LastSeenAt 在 // schema 上可空,因此映射为 *time.Time:未被 touch 过的会话该字段为 nil。 func rowToSession(row usersqlc.UserSession) *Session { var lastSeen *time.Time if row.LastSeenAt.Valid { t := row.LastSeenAt.Time lastSeen = &t } return &Session{ ID: fromPgUUID(row.ID), UserID: fromPgUUID(row.UserID), TokenHash: row.TokenHash, ExpiresAt: row.ExpiresAt.Time, LastSeenAt: lastSeen, CreatedAt: row.CreatedAt.Time, } } // CreateUser 写入一条新用户记录,并把 RETURNING 子句拿到的完整行映射回 // 领域 User(包含 DB 填充的 created_at/updated_at)。 func (r *pgRepo) CreateUser(ctx context.Context, u *User) (*User, error) { row, err := r.q.CreateUser(ctx, usersqlc.CreateUserParams{ ID: toPgUUID(u.ID), Email: u.Email, PasswordHash: u.PasswordHash, DisplayName: u.DisplayName, IsAdmin: u.IsAdmin, }) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "create user") } return rowToUser(row), nil } // GetUserByEmail 按邮箱精确查询用户。pgx.ErrNoRows 翻译为 NotFound,方便 // 上层(例如登录流程)分类响应;其它错误一律 Internal。 func (r *pgRepo) GetUserByEmail(ctx context.Context, email string) (*User, error) { row, err := r.q.GetUserByEmail(ctx, email) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "用户不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "get user by email") } return rowToUser(row), nil } // GetUserByID 按主键查询用户。错误处理与 GetUserByEmail 对称。 func (r *pgRepo) GetUserByID(ctx context.Context, id uuid.UUID) (*User, error) { row, err := r.q.GetUserByID(ctx, toPgUUID(id)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "用户不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "get user by id") } return rowToUser(row), nil } // CountUsers 返回用户总数。sqlc 生成的签名是 int32;上层通常用 int 做条件 // 判断(例如 Bootstrap 决定是否首启),这里直接转 int 简化调用。 func (r *pgRepo) CountUsers(ctx context.Context) (int, error) { n, err := r.q.CountUsers(ctx) if err != nil { return 0, errs.Wrap(err, errs.CodeInternal, "count users") } return int(n), nil } // CreateSession 写入一条新会话。ExpiresAt 必填,因此始终标 Valid=true; // last_seen_at 由 schema 默认 NULL,无需在此显式传。 func (r *pgRepo) CreateSession(ctx context.Context, s *Session) error { if err := r.q.CreateSession(ctx, usersqlc.CreateSessionParams{ ID: toPgUUID(s.ID), UserID: toPgUUID(s.UserID), TokenHash: s.TokenHash, ExpiresAt: pgtype.Timestamptz{Time: s.ExpiresAt, Valid: true}, }); err != nil { return errs.Wrap(err, errs.CodeInternal, "create session") } return nil } // GetSessionByTokenHash 按 sha256 摘要反查会话。该方法主要给鉴权中间件 // 使用,因此 ErrNoRows 翻译为 Unauthorized 而非 NotFound:从语义看 "找不到 // 会话" 等价于 "未认证",不应该把内部存在性细节透给客户端。 func (r *pgRepo) GetSessionByTokenHash(ctx context.Context, hash []byte) (*Session, error) { row, err := r.q.GetSessionByTokenHash(ctx, hash) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeUnauthorized, "session 无效") } return nil, errs.Wrap(err, errs.CodeInternal, "get session by token hash") } return rowToSession(row), nil } // TouchSession 把会话的 last_seen_at 更新为 now()。即便目标行不存在也不会 // 返回 ErrNoRows(:exec 不读结果集),因此无需特殊分支。 func (r *pgRepo) TouchSession(ctx context.Context, hash []byte) error { if err := r.q.TouchSession(ctx, hash); err != nil { return errs.Wrap(err, errs.CodeInternal, "touch session") } return nil } // DeleteSessionByTokenHash 删除指定哈希的会话。删除不存在的行不算错误, // 调用方可以放心当作 "登出幂等" 使用。 func (r *pgRepo) DeleteSessionByTokenHash(ctx context.Context, hash []byte) error { if err := r.q.DeleteSessionByTokenHash(ctx, hash); err != nil { return errs.Wrap(err, errs.CodeInternal, "delete session by token hash") } return nil } // ListAdmins 返回所有 is_admin=true 的用户,供 jobs runner 等内部模块使用。 func (r *pgRepo) ListAdmins(ctx context.Context) ([]*User, error) { rows, err := r.q.ListAdmins(ctx) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list admins") } out := make([]*User, 0, len(rows)) for _, row := range rows { out = append(out, rowToUser(row)) } return out, nil }