UserService.java
package com.albertsilva.dev.asjcatalog.service;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import com.albertsilva.dev.asjcatalog.domain.user.Role;
import com.albertsilva.dev.asjcatalog.domain.user.User;
import com.albertsilva.dev.asjcatalog.dto.user.request.UserCreateRequest;
import com.albertsilva.dev.asjcatalog.dto.user.request.UserUpdateRequest;
import com.albertsilva.dev.asjcatalog.dto.user.response.UserDetailsResponse;
import com.albertsilva.dev.asjcatalog.dto.user.response.UserResponse;
import com.albertsilva.dev.asjcatalog.mapper.user.UserMapper;
import com.albertsilva.dev.asjcatalog.projection.UserDetailsProjection;
import com.albertsilva.dev.asjcatalog.repository.RoleRepository;
import com.albertsilva.dev.asjcatalog.repository.UserRepository;
import com.albertsilva.dev.asjcatalog.service.exception.ResourceNotFoundException;
import jakarta.persistence.EntityNotFoundException;
/**
* Serviço de aplicação dos usuários ({@link User}) com dois papéis distintos:
*
* <ol>
* <li><b>Administração de usuários</b>: listagem, consulta, criação,
* atualização, ativação/desativação e exclusão, chamados por
* {@code UserController};</li>
* <li><b>Autenticação</b>: implementa {@link UserDetailsService}, e
* {@link #loadUserByUsername(String)} é chamado no login (grant
* {@code password}) por {@code CustomPasswordAuthenticationProvider}.</li>
* </ol>
*
* <p>
* Os fluxos da própria conta do usuário (registro, ativação, recuperação de
* senha, perfil e troca de senha) ficam em {@code AccountService}.
* </p>
*
* <p>
* <b>Dependências:</b> {@code UserRepository}, {@code RoleRepository},
* {@code UserMapper} e {@code PasswordEncoder}. Este service não chama
* validators: unicidade e formato do e-mail, força da senha, dados pessoais na
* senha e existência das roles ({@code @ValidRoles}) são validados antes, na
* camada web.
* </p>
*
* <p>
* <b>Regras aplicadas aqui:</b> a senha é codificada na criação e, se
* informada, na atualização; o usuário criado por este service nasce ativo;
* as roles são resolvidas por id e todas devem existir; o e-mail é gravado
* como recebido (sem {@code trim} nem conversão de caixa).
* </p>
*
* <p>
* <b>Transações:</b> os métodos administrativos são transacionais (leituras
* com {@code readOnly = true}); {@code loadUserByUsername} não declara
* transação. <b>Autorização:</b> não é feita aqui, e sim nos controllers
* ({@code @PreAuthorize}).
* </p>
*/
@Service
public class UserService implements UserDetailsService {
private static final Logger logger = LoggerFactory.getLogger(UserService.class);
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
/**
* Constrói o serviço de usuários com suas dependências principais.
*
* @param userRepository repositório de usuários
* @param roleRepository repositório de papéis
* @param userMapper responsável pela conversão entre DTOs e entidades
* @param passwordEncoder codificador de senhas
*/
public UserService(UserRepository userRepository, RoleRepository roleRepository, UserMapper userMapper,
PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.userMapper = userMapper;
this.passwordEncoder = passwordEncoder;
}
/**
* Lista usuários de forma paginada, com filtro opcional por primeiro nome.
*
* <p>
* O termo recebe {@code trim}; nulo, vazio ou só com espaços significa "sem
* filtro" e usa {@code findAll(pageable)}. Com filtro, usa
* {@code findByFirstNameContainingIgnoreCase} (apenas {@code firstName}; o
* sobrenome não participa).
* </p>
*
* <p>
* A conversão para {@link UserResponse} acessa as roles de cada usuário, que
* são carregadas sob demanda (padrão N+1 provável, dentro da transação de
* leitura).
* </p>
*
* @param firstName termo procurado no primeiro nome (opcional)
* @param pageable página, tamanho e ordenação
* @return página de usuários
*/
@Transactional(readOnly = true)
public Page<UserResponse> search(String firstName, Pageable pageable) {
String filter = StringUtils.hasText(firstName) ? firstName.trim() : null;
logger.debug("Buscando usuários | filtro={} | page={} | size={} | sort={}", filter, pageable.getPageNumber(),
pageable.getPageSize(), pageable.getSort());
Page<User> usersPage = (filter != null) ? userRepository.findByFirstNameContainingIgnoreCase(filter, pageable)
: userRepository.findAll(pageable);
logger.info("Busca concluída | totalElements={} | totalPages={}", usersPage.getTotalElements(),
usersPage.getTotalPages());
return userMapper.toResponsePage(usersPage);
}
/**
* Retorna os dados de um usuário, incluindo o indicador {@code active} e as
* roles.
*
* @param id identificador do usuário
* @return detalhes do usuário
* @throws ResourceNotFoundException ({@code error.user.notFound}) se o
* usuário não existir
*/
@Transactional(readOnly = true)
public UserDetailsResponse findById(Long id) {
return userMapper.toDetailsResponse(findEntityById(id));
}
/**
* Cria um usuário <b>ativo</b>, com as roles indicadas, a pedido de um
* administrador.
*
* <p>
* Sequência: resolve as roles por id ({@code findRolesByIdsOrThrow}); o
* mapper cria a entidade; a senha é codificada; {@code activate()}; o
* usuário é salvo.
* </p>
*
* <p>
* <b>Usuário sem roles:</b> {@code roleIds} nulo ou vazio é aceito e resulta
* em usuário sem roles (o validator {@code @ValidRoles} também aceita esse
* caso). Como {@code searchUserAndRolesByEmail} usa junção interna, esse
* usuário não é encontrado por {@link #loadUserByUsername(String)} e, na
* prática, não consegue autenticar.
* </p>
*
* <p>
* O e-mail é gravado como recebido. A unicidade é validada antes, por
* {@code @UniqueEmail}; o banco também impõe e-mail único.
* </p>
*
* @param request dados do novo usuário
* @return usuário criado
* @throws ResourceNotFoundException ({@code error.role.ids.notFound}) se
* alguma role não existir
*/
@Transactional
public UserResponse create(UserCreateRequest request) {
logger.debug("Criando novo usuário - email: {}", request.email());
User entity = userMapper.toEntity(request, findRolesByIdsOrThrow(request.roleIds()));
entity.setPassword(passwordEncoder.encode(request.password()));
entity.activate();
entity = userRepository.save(entity);
logger.info("Usuário criado com sucesso. id: {}", entity.getId());
return userMapper.toResponse(entity);
}
/**
* Atualiza nome, sobrenome e e-mail de um usuário e, se informados, suas
* roles e sua senha.
*
* <p>
* Usa {@code getReferenceById} (referência preguiçosa): a existência só é
* verificada quando o proxy é acessado, dentro do bloco {@code try}; se o
* usuário não existir, o {@code EntityNotFoundException} da JPA é convertido
* em {@link ResourceNotFoundException}.
* </p>
*
* <ul>
* <li>{@code UserMapper.updateEntity} copia nome, sobrenome e e-mail sem
* verificar nulos (o DTO exige valores não vazios); o e-mail não é
* normalizado</li>
* <li>{@code roleIds} nulo mantém as roles; um conjunto (mesmo vazio)
* substitui as atuais, portanto um conjunto vazio remove todas</li>
* <li>{@code password} nulo mantém a senha; caso contrário é codificada</li>
* <li>o indicador {@code active} não é alterado</li>
* </ul>
*
* <p>
* A unicidade do e-mail (excluindo o próprio usuário) e a regra de dados
* pessoais na senha são validadas antes, por {@code @UserUpdateValid}.
* </p>
*
* @param id identificador do usuário
* @param request dados da atualização
* @return usuário atualizado
* @throws ResourceNotFoundException se o usuário
* ({@code error.user.notFound}) ou alguma
* role ({@code error.role.ids.notFound})
* não existir
*/
@Transactional
public UserResponse update(Long id, UserUpdateRequest request) {
logger.debug("Atualizando usuário. id: {}", id);
try {
User entity = userRepository.getReferenceById(id);
userMapper.updateEntity(request, entity);
updateRolesIfPresent(request, entity);
updatePasswordIfPresent(request, entity);
entity = userRepository.save(entity);
logger.info("Usuário atualizado com sucesso. id: {}", id);
return userMapper.toResponse(entity);
} catch (EntityNotFoundException e) {
logger.warn("Falha ao atualizar. Usuário não encontrado. id: {}", id);
throw new ResourceNotFoundException("error.user.notFound");
}
}
/**
* Marca o usuário como ativo. Operação idempotente: se já estiver ativo,
* nada é alterado.
*
* <p>
* Não chama {@code save}: a alteração da entidade gerenciada é gravada no
* commit. Na autenticação, o indicador é o que {@code User#isEnabled()}
* devolve.
* </p>
*
* @param id identificador do usuário
* @throws ResourceNotFoundException se o usuário não existir
*/
@Transactional
public void activate(Long id) {
User entity = findEntityById(id);
if (entity.isActive() == true) {
logger.debug("Status já definido | id={} | active={}", id, true);
return;
}
entity.activate();
logger.info("Status alterado | id={} | active={}", id, true);
}
/**
* Marca o usuário como inativo. Operação idempotente: se já estiver
* inativo, nada é alterado.
*
* <p>
* Não chama {@code save}: a alteração da entidade gerenciada é gravada no
* commit. O usuário deixa de conseguir obter novos tokens (o provedor do
* grant {@code password} recusa contas com {@code isEnabled() == false}),
* mas o método não invalida tokens já emitidos nem oculta o usuário de
* {@link #search(String, Pageable)}.
* </p>
*
* @param id identificador do usuário
* @throws ResourceNotFoundException se o usuário não existir
*/
@Transactional
public void deactivate(Long id) {
User entity = findEntityById(id);
if (entity.isActive() == false) {
logger.debug("Status já definido | id={} | active={}", id, false);
return;
}
entity.deactivate();
logger.info("Status alterado | id={} | active={}", id, false);
}
/**
* Remove fisicamente um usuário.
*
* <p>
* Carrega o usuário com {@code findById} (404 se não existir) e chama
* {@code delete}. Como {@code User.tokens} tem {@code cascade = ALL}, os
* tokens do usuário são removidos junto, e os vínculos em
* {@code tb_user_role} também (lado dono). Não há tratamento de
* {@code DataIntegrityViolationException} neste método nem verificação de
* que o usuário removido seja o próprio solicitante.
* </p>
*
* @param id identificador do usuário
* @throws ResourceNotFoundException se o usuário não existir
*/
@Transactional
public void delete(Long id) {
logger.debug("Deletando usuário. id: {}", id);
User entity = findEntityById(id);
userRepository.delete(entity);
logger.info("Usuário deletado com sucesso. id: {}", id);
}
/**
* Carrega a entidade {@link User} pelo identificador ou lança exceção.
*
* <p>
* A anotação {@code @Transactional(readOnly = true)} neste método privado
* provavelmente não é aplicada pelo proxy do Spring (chamadas internas e
* métodos privados não passam pelo proxy); a execução ocorre na transação do
* método público que o chamou.
* </p>
*
* @param id identificador do usuário
* @return usuário encontrado
* @throws ResourceNotFoundException ({@code error.user.notFound}) se não
* existir
*/
@Transactional(readOnly = true)
private User findEntityById(Long id) {
logger.debug("Buscando usuário por id: {}", id);
return userRepository.findById(id).orElseThrow(() -> {
logger.warn("Usuário não encontrado. id: {}", id);
return new ResourceNotFoundException("error.user.notFound");
});
}
/**
* Resolve os ids de roles em entidades, exigindo que todas existam.
*
* <p>
* Conjunto nulo ou vazio devolve um conjunto vazio (sem erro). Caso
* contrário, busca as roles com {@code findAllById} e compara a quantidade
* encontrada com a de ids recebidos; se diferirem, lança
* {@link ResourceNotFoundException}.
* </p>
*
* @param roleIds ids das roles (pode ser nulo ou vazio)
* @return roles correspondentes (vazio se {@code roleIds} for nulo ou vazio)
* @throws ResourceNotFoundException ({@code error.role.ids.notFound}) se
* alguma role não existir
*/
private Set<Role> findRolesByIdsOrThrow(Set<Long> roleIds) {
if (roleIds == null || roleIds.isEmpty()) {
return Collections.emptySet();
}
Set<Role> roles = new HashSet<>(roleRepository.findAllById(roleIds));
if (roles.size() != roleIds.size()) {
throw new ResourceNotFoundException("error.role.ids.notFound");
}
return roles;
}
/**
* Carrega os dados de autenticação do usuário cujo e-mail é
* {@code username}, para o fluxo de login.
*
* <p>
* <b>Quem chama:</b> {@code CustomPasswordAuthenticationProvider}, no grant
* {@code password}; este é o único chamador em {@code src/main} (o
* {@code UserDetailsService} é injetado em {@code AuthorizationServerConfig}
* e repassado ao provider).
* </p>
*
* <p>
* <b>O que faz:</b> executa uma única consulta nativa,
* {@code searchUserAndRolesByEmail}, que devolve uma linha por role. Não
* declara transação e não usa carregamento tardio.
* </p>
*
* <p>
* <b>User parcial:</b> o objeto devolvido é uma instância nova de
* {@link User}, nunca persistida, com apenas id, e-mail, senha (hash),
* {@code active} e uma {@code Role} (id e authority) por linha.
* {@code firstName}, {@code lastName} e {@code tokens} não são preenchidos.
* No fluxo de login o provider usa o resultado para conferir a senha
* ({@code getPassword}), o status ({@code isEnabled} e demais
* {@code isAccount...}), as authorities (escopos) e o id
* ({@code ((User) userDetails).getId()}); não foi encontrada nenhuma leitura
* de nome ou sobrenome desse objeto.
* </p>
*
* <p>
* <b>Casos limite:</b> o e-mail é comparado exatamente como gravado (sem
* {@code trim} nem ignorar caixa); usuário inexistente ou sem nenhuma role
* gera lista vazia e {@link UsernameNotFoundException}, que o provider
* converte em {@code invalid_grant} ("Invalid credentials"); usuário inativo
* é carregado normalmente, e a recusa ocorre depois, no provider.
* </p>
*
* @param username e-mail do usuário
* @return detalhes do usuário para autenticação
* @throws UsernameNotFoundException se não houver linhas para o e-mail
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
List<UserDetailsProjection> result = userRepository.searchUserAndRolesByEmail(username);
if (result.isEmpty()) {
logger.warn("Usuário não encontrado para email: {}", username);
throw new UsernameNotFoundException("User not found with email: " + username);
}
User user = new User();
user.setId(result.get(0).getId());
user.setEmail(result.get(0).getUsername());
user.setPassword(result.get(0).getPassword());
user.setActive(result.get(0).getActive());
for (UserDetailsProjection projection : result) {
user.addRole(new Role(projection.getRoleId(), projection.getAuthority()));
}
return user;
}
/**
* Codifica e grava a nova senha somente se {@code request.password()} for
* diferente de nulo; caso contrário, mantém a senha atual.
*/
private void updatePasswordIfPresent(UserUpdateRequest request, User entity) {
if (request.password() != null) {
entity.setPassword(passwordEncoder.encode(request.password()));
}
}
/**
* Substitui as roles do usuário somente se {@code request.roleIds()} for
* diferente de nulo: resolve as roles (todas devem existir), limpa o conjunto
* atual e adiciona as novas. Um conjunto vazio remove todas as roles.
*
* @throws ResourceNotFoundException se alguma role não existir
*/
private void updateRolesIfPresent(UserUpdateRequest request, User entity) {
if (request.roleIds() == null) {
return;
}
Set<Role> roles = findRolesByIdsOrThrow(request.roleIds());
entity.getRoles().clear();
entity.getRoles().addAll(roles);
}
}