// Package user 内的 service.go 实装 Service 接口:把 auth 工具(密码哈希、 // session token)和 Repository(持久层)粘合为一组面向 transport 层的应用 // 服务方法。 // // 设计取舍: // - 不区分 "邮箱不存在" 与 "密码错误":两条路径都返回同一个 CodeUnauthorized // 错误,避免登录入口被用作邮箱存在性探测。 // - ResolveSession 把 "未授权" 与 "未找到" 都翻译成 (uuid.Nil, false, nil), // 符合 transport/http/middleware.SessionResolver 接口约定(token 无效不算 // 系统错误,由中间件统一返回 401)。 // - Bootstrap 仅在 DB 中尚无任何用户时落地新管理员;已有用户直接返回 // (nil, nil),使重复执行 bootstrap 流程不会覆盖现网管理员。 // - Login 把 ip 透传给 audit recorder(IP 解析在 audit 包内完成)。 // - now 字段以 func() time.Time 而非直接调用 time.Now 持有,方便后续测试 // 冻结时间(首版 5 个测试还用不到,但接口先留)。 package user import ( "context" "time" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" ) // sessionTTL 是单条会话的有效期。7 天与多数 Web 控制台默认值一致;上层未 // 暴露续期能力时,到期后客户端需重新登录。 const sessionTTL = 7 * 24 * time.Hour // service 是 Service 接口的默认实现。字段保持小写以禁止外部直接构造, // 调用方必须通过 NewService 注入 Repository。 type service struct { repo Repository audit audit.Recorder now func() time.Time } // NewService 用给定的 Repository 和 audit.Recorder 构造 Service 实现。 // recorder 可为 nil(审计是 best-effort,nil 时直接跳过写审计)。 // 返回 Service 接口而非 *service,以便日后扩展实现(如增加缓存层装饰器) // 时不破坏调用方代码。 func NewService(repo Repository, recorder audit.Recorder) Service { return &service{repo: repo, audit: recorder, now: time.Now} } // Login 校验邮箱与密码,成功后签发新的会话 token。 // 任何与凭据相关的失败(用户不存在、密码不匹配)都统一返回 CodeUnauthorized, // 不向调用方泄露 "邮箱是否存在" 这一侧信道;只有底层故障(哈希解析、 // token 生成、写库失败等)会返回带详细 cause 的非授权类错误。 func (s *service) Login(ctx context.Context, email, password, ip string) (string, *User, error) { u, err := s.repo.GetUserByEmail(ctx, email) if err != nil { // 不区分 "邮箱不存在" 与 "密码错误",统一报 unauthorized。 return "", nil, errs.New(errs.CodeUnauthorized, "邮箱或密码错误") } ok, err := VerifyPassword(password, u.PasswordHash) if err != nil { return "", nil, errs.Wrap(err, errs.CodeInternal, "verify password") } if !ok { return "", nil, errs.New(errs.CodeUnauthorized, "邮箱或密码错误") } tok, hash, err := NewSessionToken() if err != nil { return "", nil, errs.Wrap(err, errs.CodeInternal, "new token") } sess := &Session{ ID: uuid.New(), UserID: u.ID, TokenHash: hash, ExpiresAt: s.now().Add(sessionTTL), } if err := s.repo.CreateSession(ctx, sess); err != nil { return "", nil, err } // 审计是 best-effort:_ = 吞掉错误,审计落库失败不能导致登录失败。 // 用 context.WithoutCancel 派生:HTTP handler 在响应返回后 r.Context() 会 // 被取消,但审计写入必须完成(漏行比写错更危险)。timeout 由调用方在 // ctx 上不再控制,audit recorder 自身需保证不无限阻塞。 if s.audit != nil { uid := u.ID _ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{ UserID: &uid, Action: "user.login", TargetType: "user", TargetID: u.ID.String(), IP: ip, }) } return tok, u, nil } // Logout 删除给定 token 对应的会话。pgRepo.DeleteSessionByTokenHash 对不存在 // 的行不返错,因此本方法天然幂等:客户端重复登出或登出未知 token 都不会 // 报错。 // 先 ResolveSession 拿 uid 是为了给 audit Entry 填充 UserID;ResolveSession // 失败(如 token 已过期/无效)时依然继续删除会话(保持幂等),只是 uid 为 // uuid.Nil,此时跳过写审计。 func (s *service) Logout(ctx context.Context, token string) error { uid, _, _ := s.ResolveSession(ctx, token) if err := s.repo.DeleteSessionByTokenHash(ctx, HashSessionToken(token)); err != nil { return err } // 审计是 best-effort:_ = 吞掉错误;uid == uuid.Nil 表示 token 无效,跳过。 // 用 context.WithoutCancel 派生:HTTP handler 在响应返回后 r.Context() 会 // 被取消,但审计写入必须完成(漏行比写错更危险)。timeout 由调用方在 // ctx 上不再控制,audit recorder 自身需保证不无限阻塞。 if s.audit != nil && uid != uuid.Nil { u := uid _ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{ UserID: &u, Action: "user.logout", TargetType: "user", TargetID: uid.String(), }) } return nil } // ResolveSession 把 bearer token 翻译为已认证用户 ID。空 token 直接视作匿名 // 请求;存在但未知/过期的 token 也归一为 (uuid.Nil, false, nil),由调用方 // (通常是 Auth 中间件)按 401 处理。只有真正的底层故障才会返回非 nil // error,从而在系统异常时不掩盖问题。 func (s *service) ResolveSession(ctx context.Context, token string) (uuid.UUID, bool, error) { if token == "" { return uuid.Nil, false, nil } sess, err := s.repo.GetSessionByTokenHash(ctx, HashSessionToken(token)) if err != nil { ae, ok := errs.As(err) if ok && (ae.Code == errs.CodeUnauthorized || ae.Code == errs.CodeNotFound) { return uuid.Nil, false, nil } return uuid.Nil, false, err } // touch 是 best-effort:失败也不影响本次解析结果,留 _ 显式忽略。 _ = s.repo.TouchSession(ctx, sess.TokenHash) return sess.UserID, true, nil } // Get 按主键加载用户。错误透传 Repository 层(NotFound 已在那里翻译完成)。 func (s *service) Get(ctx context.Context, id uuid.UUID) (*User, error) { return s.repo.GetUserByID(ctx, id) } // Bootstrap 在系统首次启动时创建初始管理员账号。已有任意用户时返回 // (nil, nil) 表示 "已无需 bootstrap",调用方据此判断是否要继续打印初始 // 凭据。密码长度由 HashPassword 校验;若传入太短的密码,错误码归一为 // CodeInvalidInput。 func (s *service) Bootstrap(ctx context.Context, email, password string) (*User, error) { count, err := s.repo.CountUsers(ctx) if err != nil { return nil, err } if count > 0 { // 已有用户,bootstrap 仅在空 DB 时生效。 return nil, nil } hash, err := HashPassword(password) if err != nil { return nil, errs.Wrap(err, errs.CodeInvalidInput, "bootstrap password") } u := &User{ ID: uuid.New(), Email: email, PasswordHash: hash, DisplayName: "Admin", IsAdmin: true, } return s.repo.CreateUser(ctx, u) }