Files
agentic-coding-workflow/internal/user/repository.go
T

201 lines
7.8 KiB
Go

// 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
}