// Package mysql 实现 user-service 的 MySQL 持久化边界。 package mysql import ( "context" "database/sql" "errors" "strings" mysqldriver "github.com/go-sql-driver/mysql" "hyapp/pkg/xerr" authdomain "hyapp/services/user-service/internal/domain/auth" userdomain "hyapp/services/user-service/internal/domain/user" ) // SetPassword 为已有用户首次写入密码身份。 func (r *Repository) SetPassword(ctx context.Context, account authdomain.PasswordAccount) error { // password_accounts 以 user_id 唯一,v1 只允许首次设置密码。 _, err := r.db.ExecContext(ctx, ` INSERT INTO password_accounts (user_id, password_hash, hash_alg, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?) `, account.UserID, account.PasswordHash, account.HashAlg, account.CreatedAtMs, account.UpdatedAtMs) if err != nil { // 唯一键冲突映射为 PASSWORD_ALREADY_SET。 return mapAuthDuplicateError(err) } return nil } // FindPasswordByDisplayUserID 通过当前 active display_user_id 查找用户密码身份。 func (r *Repository) FindPasswordByDisplayUserID(ctx context.Context, displayUserID string, nowMs int64) (authdomain.PasswordAccount, error) { // 密码登录前先按输入短号触发靓号懒过期。 if err := r.expirePrettyByDisplayUserID(ctx, displayUserID, nowMs, "lazy_password_login"); err != nil { return authdomain.PasswordAccount{}, err } var account authdomain.PasswordAccount err := r.db.QueryRowContext(ctx, ` SELECT pa.user_id, u.current_display_user_id, pa.password_hash, pa.hash_alg, pa.created_at_ms, pa.updated_at_ms FROM users u INNER JOIN password_accounts pa ON pa.user_id = u.user_id WHERE u.current_display_user_id = ? `, displayUserID).Scan(&account.UserID, &account.DisplayUserID, &account.PasswordHash, &account.HashAlg, &account.CreatedAtMs, &account.UpdatedAtMs) if err == sql.ErrNoRows { // 短号不存在或未设置密码都返回 not found,由 service 统一映射 AUTH_FAILED。 return authdomain.PasswordAccount{}, xerr.New(xerr.NotFound, "password account not found") } if err != nil { return authdomain.PasswordAccount{}, err } return account, nil } // CreateSession 创建新的 refresh session。 func (r *Repository) CreateSession(ctx context.Context, session authdomain.Session) error { // refresh token 只保存 hash,原文不会写入数据库。 _, err := r.db.ExecContext(ctx, ` INSERT INTO auth_sessions (session_id, user_id, refresh_token_hash, device_id, expires_at_ms, revoked_at_ms, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, NULL, ?, ?) `, session.SessionID, session.UserID, session.RefreshTokenHash, session.DeviceID, session.ExpiresAtMs, session.CreatedAtMs, session.UpdatedAtMs) if err != nil { // session_id 或 refresh hash 冲突统一映射成 auth 领域冲突。 return mapAuthDuplicateError(err) } return nil } // FindActiveSessionByRefreshHash 查找未过期且未吊销的 session。 func (r *Repository) FindActiveSessionByRefreshHash(ctx context.Context, refreshTokenHash string, nowMs int64) (authdomain.Session, error) { // 查到 session 后再判断 revoked/expired,给调用方稳定 reason。 session, err := r.findSessionByRefreshHash(ctx, refreshTokenHash) if err != nil { return authdomain.Session{}, err } if session.RevokedAtMs > 0 { // 已吊销 session 不能继续 refresh。 return authdomain.Session{}, xerr.New(xerr.SessionRevoked, "session revoked") } if session.ExpiresAtMs <= nowMs { // 过期 session 与吊销 session 使用不同 reason。 return authdomain.Session{}, xerr.New(xerr.SessionExpired, "session expired") } return session, nil } // FindActiveSessionByID 查找未过期且未吊销的 session。 func (r *Repository) FindActiveSessionByID(ctx context.Context, sessionID string, nowMs int64) (authdomain.Session, error) { // onboarding 完成后只需要重签 access token,不能要求客户端再提交 refresh token 原文。 session, err := r.findSessionByID(ctx, sessionID) if err != nil { return authdomain.Session{}, err } if session.RevokedAtMs > 0 { // 已吊销 session 不能继续签发任何 access token。 return authdomain.Session{}, xerr.New(xerr.SessionRevoked, "session revoked") } if session.ExpiresAtMs <= nowMs { // session 过期后只能重新登录,不能用完成注册接口续命。 return authdomain.Session{}, xerr.New(xerr.SessionExpired, "session expired") } return session, nil } // ReplaceSession 原子吊销旧 session 并创建新 session。 func (r *Repository) ReplaceSession(ctx context.Context, oldSessionID string, newSession authdomain.Session, revokedAtMs int64) error { // refresh 轮换必须原子吊销旧 session 并插入新 session。 tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() var oldRevokedAt sql.NullInt64 err = tx.QueryRowContext(ctx, ` SELECT revoked_at_ms FROM auth_sessions WHERE session_id = ? FOR UPDATE `, oldSessionID).Scan(&oldRevokedAt) if err == sql.ErrNoRows || oldRevokedAt.Valid { // 旧 session 不存在或已经吊销时,不能再次轮换。 return xerr.New(xerr.SessionRevoked, "session revoked") } if err != nil { return err } _, err = tx.ExecContext(ctx, ` UPDATE auth_sessions SET revoked_at_ms = ?, updated_at_ms = ? WHERE session_id = ? `, revokedAtMs, revokedAtMs, oldSessionID) if err != nil { return err } if err := insertSession(ctx, tx, newSession); err != nil { // 插入新 session 失败会回滚旧 session 吊销。 return err } return tx.Commit() } // RevokeSession 按 session_id 或 refresh token hash 吊销 session。 func (r *Repository) RevokeSession(ctx context.Context, sessionID string, refreshTokenHash string, revokedAtMs int64) (bool, error) { if sessionID == "" && refreshTokenHash != "" { // 只传 refresh token 时先反查 session_id。 session, err := r.findSessionByRefreshHash(ctx, refreshTokenHash) if err != nil { return false, err } sessionID = session.SessionID } result, err := r.db.ExecContext(ctx, ` UPDATE auth_sessions SET revoked_at_ms = ?, updated_at_ms = ? WHERE session_id = ? AND revoked_at_ms IS NULL `, revokedAtMs, revokedAtMs, sessionID) if err != nil { return false, err } affected, err := result.RowsAffected() if err != nil { return false, err } // affected=false 表示没有可吊销 session,调用方按幂等结果处理。 return affected > 0, nil } // FindThirdPartyIdentity 查找 provider + subject 绑定。 func (r *Repository) FindThirdPartyIdentity(ctx context.Context, provider string, providerSubject string) (authdomain.ThirdPartyIdentity, error) { var identity authdomain.ThirdPartyIdentity err := r.db.QueryRowContext(ctx, ` SELECT user_id, provider, provider_subject, COALESCE(provider_union_id, ''), created_at_ms, updated_at_ms FROM third_party_identities WHERE provider = ? AND provider_subject = ? `, provider, providerSubject).Scan(&identity.UserID, &identity.Provider, &identity.ProviderSubject, &identity.ProviderUnionID, &identity.CreatedAtMs, &identity.UpdatedAtMs) if err == sql.ErrNoRows { // 未绑定三方身份时由 service 转入注册路径。 return authdomain.ThirdPartyIdentity{}, xerr.New(xerr.NotFound, "third-party identity not found") } if err != nil { return authdomain.ThirdPartyIdentity{}, err } return identity, nil } // CreateThirdPartyUser 原子创建用户、默认短号、三方身份和首个 session。 func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session) error { // 三方首次登录注册必须同时写用户、默认短号、三方绑定和首个 session。 tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if err := insertUserIdentity(ctx, tx, user, identity); err != nil { // 默认短号冲突由 service 重试。 return mapAuthDuplicateError(err) } _, err = tx.ExecContext(ctx, ` INSERT INTO third_party_identities (user_id, provider, provider_subject, provider_union_id, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?) `, thirdParty.UserID, thirdParty.Provider, thirdParty.ProviderSubject, nullableString(thirdParty.ProviderUnionID), thirdParty.CreatedAtMs, thirdParty.UpdatedAtMs) if err != nil { return mapAuthDuplicateError(err) } if err := insertSession(ctx, tx, session); err != nil { // session 插入失败会回滚用户和三方绑定。 return err } return tx.Commit() } // RecordLoginAudit 写登录审计;调用方不会让审计失败影响主链路。 func (r *Repository) RecordLoginAudit(ctx context.Context, audit authdomain.LoginAudit) error { // 审计表不能包含密码、三方 credential 或 refresh token 原文。 _, err := r.db.ExecContext(ctx, ` INSERT INTO login_audit (request_id, user_id, login_type, provider, result, failure_code, client_ip, user_agent, created_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, audit.RequestID, nullableUserID(audit.UserID), audit.LoginType, nullableString(audit.Provider), audit.Result, nullableString(audit.FailureCode), nullableString(audit.ClientIP), nullableString(audit.UserAgent), audit.CreatedAtMs) return err } func (r *Repository) findSessionByRefreshHash(ctx context.Context, refreshTokenHash string) (authdomain.Session, error) { // refresh token hash 是唯一索引,最多命中一条 session。 var session authdomain.Session var revokedAt sql.NullInt64 err := r.db.QueryRowContext(ctx, ` SELECT session_id, user_id, refresh_token_hash, device_id, expires_at_ms, revoked_at_ms, created_at_ms, updated_at_ms FROM auth_sessions WHERE refresh_token_hash = ? `, refreshTokenHash).Scan(&session.SessionID, &session.UserID, &session.RefreshTokenHash, &session.DeviceID, &session.ExpiresAtMs, &revokedAt, &session.CreatedAtMs, &session.UpdatedAtMs) if err == sql.ErrNoRows { // 找不到 hash 按 revoked 返回,避免暴露 token 是否存在。 return authdomain.Session{}, xerr.New(xerr.SessionRevoked, "session revoked") } if err != nil { return authdomain.Session{}, err } if revokedAt.Valid { // sql.NullInt64 转换成领域零值语义。 session.RevokedAtMs = revokedAt.Int64 } return session, nil } func (r *Repository) findSessionByID(ctx context.Context, sessionID string) (authdomain.Session, error) { // session_id 来自已校验 access token 的 sid claim,仍必须回查服务端 session 状态。 var session authdomain.Session var revokedAt sql.NullInt64 err := r.db.QueryRowContext(ctx, ` SELECT session_id, user_id, refresh_token_hash, device_id, expires_at_ms, revoked_at_ms, created_at_ms, updated_at_ms FROM auth_sessions WHERE session_id = ? `, sessionID).Scan(&session.SessionID, &session.UserID, &session.RefreshTokenHash, &session.DeviceID, &session.ExpiresAtMs, &revokedAt, &session.CreatedAtMs, &session.UpdatedAtMs) if err == sql.ErrNoRows { // 不暴露 session_id 是否存在,统一按 revoked 处理。 return authdomain.Session{}, xerr.New(xerr.SessionRevoked, "session revoked") } if err != nil { return authdomain.Session{}, err } if revokedAt.Valid { session.RevokedAtMs = revokedAt.Int64 } return session, nil } func insertSession(ctx context.Context, tx *sql.Tx, session authdomain.Session) error { // insertSession 只在已有事务中使用,保证与 session 替换或三方注册同提交。 _, err := tx.ExecContext(ctx, ` INSERT INTO auth_sessions (session_id, user_id, refresh_token_hash, device_id, expires_at_ms, revoked_at_ms, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, NULL, ?, ?) `, session.SessionID, session.UserID, session.RefreshTokenHash, session.DeviceID, session.ExpiresAtMs, session.CreatedAtMs, session.UpdatedAtMs) if err != nil { return mapAuthDuplicateError(err) } return nil } func mapAuthDuplicateError(err error) error { var mysqlErr *mysqldriver.MySQLError if !errors.As(err, &mysqlErr) || mysqlErr.Number != 1062 { // 非 MySQL 重复键错误保持原样返回。 return err } // 根据约束名或表名把重复键错误映射成稳定领域 reason。 message := mysqlErr.Message switch { case strings.Contains(message, "password_accounts"): return xerr.New(xerr.PasswordAlreadySet, "password already set") case strings.Contains(message, "third_party") || strings.Contains(message, "uk_third_party_provider_subject"): return xerr.New(xerr.Conflict, "third-party identity already exists") case strings.Contains(message, "display") || strings.Contains(message, "uk_users_current_display_user_id"): return xerr.New(xerr.DisplayUserIDExists, "display_user_id already exists") default: return xerr.New(xerr.Conflict, "duplicate key") } } func nullableUserID(userID int64) any { if userID <= 0 { // 审计中未知用户写 NULL。 return nil } return userID }