diff --git a/internal/user/repository.go b/internal/user/repository.go new file mode 100644 index 0000000..86a22bd --- /dev/null +++ b/internal/user/repository.go @@ -0,0 +1,200 @@ +// 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) + + 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 +}