feat(p1r): 实现 Account 安全事件 API
This commit is contained in:
parent
fab1ed0e82
commit
0d37d37c76
@ -16,9 +16,11 @@ import cn.iocoder.muse.module.member.convert.account.AccountConvert;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountCommandDO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.MemberNewApiBindingDO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountProfileDO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.MemberSecurityEventDO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.user.MemberUserDO;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.account.AccountProfileMapper;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.account.MemberNewApiBindingMapper;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.account.MemberSecurityEventMapper;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.user.MemberUserMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
@ -77,6 +79,8 @@ public class AccountProfileServiceImpl implements AccountProfileService {
|
||||
private AccountEntitlementService entitlementService;
|
||||
@Resource
|
||||
private MemberNewApiBindingMapper newApiBindingMapper;
|
||||
@Resource
|
||||
private MemberSecurityEventMapper securityEventMapper;
|
||||
|
||||
@Override
|
||||
public AppCurrentUserSummaryRespVO getCurrentUserSummary(Long accountUserId) {
|
||||
@ -173,9 +177,12 @@ public class AccountProfileServiceImpl implements AccountProfileService {
|
||||
entitlementService.getAdminEntitlementSummaries(userIds);
|
||||
Map<Long, MemberNewApiBindingDO> bindingMap = newApiBindingMapper.selectListByAccountUserIds(userIds).stream()
|
||||
.collect(Collectors.toMap(MemberNewApiBindingDO::getAccountUserId, Function.identity(), (first, second) -> first));
|
||||
Map<Long, List<MemberSecurityEventDO>> securityEventsMap =
|
||||
securityEventMapper.selectListByAccountUserIds(userIds).stream()
|
||||
.collect(Collectors.groupingBy(MemberSecurityEventDO::getAccountUserId));
|
||||
List<AdminAccountUserSummaryRespVO> list = userPage.getList().stream()
|
||||
.map(user -> toAdminSummary(user, profileMap.get(user.getId()), entitlementSummaryMap.get(user.getId()),
|
||||
bindingMap.get(user.getId())))
|
||||
bindingMap.get(user.getId()), securityEventsMap.get(user.getId())))
|
||||
.toList();
|
||||
return AccountPageResult.of(list, userPage.getTotal(), pageParam);
|
||||
}
|
||||
@ -274,7 +281,8 @@ public class AccountProfileServiceImpl implements AccountProfileService {
|
||||
|
||||
private AdminAccountUserSummaryRespVO toAdminSummary(MemberUserDO user, AccountProfileDO profile,
|
||||
AccountEntitlementService.AdminEntitlementSummary entitlementSummary,
|
||||
MemberNewApiBindingDO binding) {
|
||||
MemberNewApiBindingDO binding,
|
||||
List<MemberSecurityEventDO> securityEvents) {
|
||||
AdminAccountUserSummaryRespVO summary = new AdminAccountUserSummaryRespVO();
|
||||
summary.setUserId(AccountConvert.INSTANCE.stringId(user.getId()));
|
||||
summary.setNickname(resolveNickname(user, profile));
|
||||
@ -283,7 +291,7 @@ public class AccountProfileServiceImpl implements AccountProfileService {
|
||||
summary.setEntitlementSource(entitlementSummary == null ? "unassigned" : entitlementSummary.getEntitlementSource());
|
||||
summary.setQuotaStatus(entitlementSummary == null ? "normal" : entitlementSummary.getQuotaStatus());
|
||||
summary.setNewApiBindingStatus(toOpenApiBindingStatus(binding));
|
||||
summary.setRiskFlags(Collections.emptyList());
|
||||
summary.setRiskFlags(toRiskFlags(securityEvents));
|
||||
summary.setCreatedAt(user.getCreateTime());
|
||||
summary.setLastActiveAt(user.getLoginDate());
|
||||
return summary;
|
||||
@ -304,6 +312,25 @@ public class AccountProfileServiceImpl implements AccountProfileService {
|
||||
return status;
|
||||
}
|
||||
|
||||
private List<String> toRiskFlags(List<MemberSecurityEventDO> securityEvents) {
|
||||
if (securityEvents == null || securityEvents.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<String> flags = new java.util.ArrayList<>();
|
||||
// 管理端摘要只暴露风险标签,不暴露 IP/UA/deviceInfo;实际安全事件详情仍由本人 app 接口查询。
|
||||
boolean hasCritical = securityEvents.stream()
|
||||
.anyMatch(event -> "critical".equals(event.getSeverity()));
|
||||
boolean hasUnacknowledged = securityEvents.stream()
|
||||
.anyMatch(event -> !Boolean.TRUE.equals(event.getAcknowledged()));
|
||||
if (hasCritical) {
|
||||
flags.add("critical_security_event");
|
||||
}
|
||||
if (hasUnacknowledged) {
|
||||
flags.add("unacknowledged_security_event");
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
private LambdaQueryWrapperX<MemberUserDO> buildUserPageQuery(String keyword, String status, String entitlementSource) {
|
||||
LambdaQueryWrapperX<MemberUserDO> query = new LambdaQueryWrapperX<>();
|
||||
if (StrUtil.isNotBlank(keyword)) {
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
package cn.iocoder.muse.module.member.application.account;
|
||||
|
||||
import cn.iocoder.muse.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.muse.module.member.controller.account.vo.AccountPageResult;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventAcknowledgeReqVO;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventAcknowledgeResultRespVO;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventDetailRespVO;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventSummaryRespVO;
|
||||
|
||||
/**
|
||||
* Account 安全事件应用服务。
|
||||
*/
|
||||
public interface AccountSecurityService {
|
||||
|
||||
/**
|
||||
* 查询当前登录用户自己的安全事件列表。
|
||||
*/
|
||||
AccountPageResult<SecurityEventSummaryRespVO> appListSecurityEvents(Long accountUserId, PageParam pageParam,
|
||||
String severity);
|
||||
|
||||
/**
|
||||
* 查询当前登录用户自己的安全事件详情。
|
||||
*/
|
||||
SecurityEventDetailRespVO appGetSecurityEvent(Long accountUserId, String eventId);
|
||||
|
||||
/**
|
||||
* 追加确认当前登录用户自己的安全事件。
|
||||
*/
|
||||
SecurityEventAcknowledgeResultRespVO appAcknowledgeSecurityEvent(Long accountUserId, String eventId,
|
||||
SecurityEventAcknowledgeReqVO reqVO);
|
||||
}
|
||||
@ -0,0 +1,233 @@
|
||||
package cn.iocoder.muse.module.member.application.account;
|
||||
|
||||
import cn.iocoder.muse.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.muse.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.muse.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.muse.module.member.controller.account.vo.AccountPageResult;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventAcknowledgeReqVO;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventAcknowledgeResultRespVO;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventDetailRespVO;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventSummaryRespVO;
|
||||
import cn.iocoder.muse.module.member.convert.account.AccountConvert;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountCommandDO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountSecurityEventAckDO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.MemberSecurityEventDO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.user.MemberUserDO;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.account.AccountSecurityEventAckMapper;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.account.MemberSecurityEventMapper;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.user.MemberUserMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static cn.iocoder.muse.framework.common.exception.enums.GlobalErrorCodeConstants.NOT_FOUND;
|
||||
import static cn.iocoder.muse.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
|
||||
import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_USER_NOT_EXISTS;
|
||||
|
||||
/**
|
||||
* Account 安全事件应用服务实现。
|
||||
*/
|
||||
@Service
|
||||
public class AccountSecurityServiceImpl implements AccountSecurityService {
|
||||
|
||||
private static final String OPERATION_ACK_SECURITY_EVENT = "appAcknowledgeSecurityEvent";
|
||||
private static final String TARGET_TYPE_SECURITY_EVENT = "securityEvent";
|
||||
private static final Set<String> SEVERITIES = Set.of("info", "warning", "critical");
|
||||
private static final Set<String> ACK_ACTIONS =
|
||||
Set.of("acknowledged", "password_changed", "session_revoked", "false_positive");
|
||||
private static final Map<String, String> ACTION_RISK_SUMMARIES = Map.of(
|
||||
"acknowledged", "用户已知晓该安全事件,风险仍需持续观察",
|
||||
"password_changed", "用户已修改密码,凭证泄露风险已降低",
|
||||
"session_revoked", "相关 session 撤销待人工处理,未接入 session 管理服务",
|
||||
"false_positive", "用户标记为误报,风险需由后续审计复核");
|
||||
private static final Map<String, List<String>> ACTION_NEXT_STEPS = Map.of(
|
||||
"acknowledged", List.of("持续观察账户安全事件"),
|
||||
"password_changed", List.of("使用新密码重新登录", "检查近期登录设备"),
|
||||
"session_revoked", List.of("手动退出其他设备后重新登录", "必要时修改密码"),
|
||||
"false_positive", List.of("等待管理员复核误报标记"));
|
||||
|
||||
@Resource
|
||||
private MemberUserMapper memberUserMapper;
|
||||
@Resource
|
||||
private MemberSecurityEventMapper securityEventMapper;
|
||||
@Resource
|
||||
private AccountSecurityEventAckMapper securityEventAckMapper;
|
||||
@Resource
|
||||
private AccountCommandService commandService;
|
||||
@Resource
|
||||
private AccountAuditService auditService;
|
||||
|
||||
@Override
|
||||
public AccountPageResult<SecurityEventSummaryRespVO> appListSecurityEvents(Long accountUserId, PageParam pageParam,
|
||||
String severity) {
|
||||
requireUser(accountUserId);
|
||||
String normalizedSeverity = normalizeSeverity(severity);
|
||||
PageResult<MemberSecurityEventDO> page =
|
||||
securityEventMapper.selectPageByAccountUserIdAndSeverity(pageParam, accountUserId, normalizedSeverity);
|
||||
List<MemberSecurityEventDO> events = page.getList() == null ? Collections.emptyList() : page.getList();
|
||||
List<Long> eventIds = events.stream().map(MemberSecurityEventDO::getId).toList();
|
||||
Map<Long, AccountSecurityEventAckDO> latestAckMap =
|
||||
securityEventAckMapper.selectLatestByEventIdsAndAccountUserId(eventIds, accountUserId).stream()
|
||||
.collect(Collectors.toMap(AccountSecurityEventAckDO::getEventId, Function.identity(),
|
||||
(first, second) -> first));
|
||||
List<SecurityEventSummaryRespVO> list = events.stream()
|
||||
.map(event -> AccountConvert.INSTANCE.convertSecurityEventSummary(event, latestAckMap.get(event.getId())))
|
||||
.toList();
|
||||
return AccountPageResult.of(list, page.getTotal(), pageParam);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SecurityEventDetailRespVO appGetSecurityEvent(Long accountUserId, String eventId) {
|
||||
requireUser(accountUserId);
|
||||
Long parsedEventId = parseEventId(eventId);
|
||||
MemberSecurityEventDO event = securityEventMapper.selectByIdAndAccountUserId(parsedEventId, accountUserId);
|
||||
if (event == null) {
|
||||
throw notFound("安全事件不存在");
|
||||
}
|
||||
AccountSecurityEventAckDO ack = securityEventAckMapper.selectLatestByEventIdAndAccountUserId(parsedEventId,
|
||||
accountUserId);
|
||||
return AccountConvert.INSTANCE.convertSecurityEventDetail(event, ack);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public SecurityEventAcknowledgeResultRespVO appAcknowledgeSecurityEvent(Long accountUserId, String eventId,
|
||||
SecurityEventAcknowledgeReqVO reqVO) {
|
||||
requireUser(accountUserId);
|
||||
Long parsedEventId = parseEventId(eventId);
|
||||
MemberSecurityEventDO event = securityEventMapper.selectByIdAndAccountUserId(parsedEventId, accountUserId);
|
||||
if (event == null) {
|
||||
throw notFound("安全事件不存在");
|
||||
}
|
||||
String action = normalizeAction(reqVO == null ? null : reqVO.getAction());
|
||||
String requestHash = commandService.buildRequestHash(Map.of(
|
||||
"eventId", parsedEventId,
|
||||
"action", action,
|
||||
"note", reqVO == null || reqVO.getNote() == null ? "" : reqVO.getNote()));
|
||||
AccountCommandService.CommandEnvelope envelope = AccountCommandService.CommandEnvelope.builder()
|
||||
.commandId(reqVO == null ? null : reqVO.getCommandId())
|
||||
.operationId(OPERATION_ACK_SECURITY_EVENT)
|
||||
.actorUserId(accountUserId)
|
||||
.ownerUserId(accountUserId)
|
||||
.targetType(TARGET_TYPE_SECURITY_EVENT)
|
||||
.targetId(parsedEventId)
|
||||
.requestHash(requestHash)
|
||||
.build();
|
||||
AccountCommandDO replayCommand = commandService.reserveCommand(envelope);
|
||||
if (replayCommand != null) {
|
||||
return JsonUtils.parseObject(replayCommand.getResultSnapshot(), SecurityEventAcknowledgeResultRespVO.class);
|
||||
}
|
||||
|
||||
AccountSecurityEventAckDO previousAck =
|
||||
securityEventAckMapper.selectLatestByEventIdAndAccountUserId(parsedEventId, accountUserId);
|
||||
LocalDateTime acknowledgedAt = LocalDateTime.now();
|
||||
SecurityEventAcknowledgeResultRespVO result = buildAckResult(parsedEventId, action, acknowledgedAt);
|
||||
String riskSummaryJson = riskSummaryJson(result, previousAck);
|
||||
AccountSecurityEventAckDO ack = AccountSecurityEventAckDO.builder()
|
||||
.eventId(parsedEventId)
|
||||
.accountUserId(accountUserId)
|
||||
.actorUserId(accountUserId)
|
||||
.action(action)
|
||||
.note(reqVO == null ? null : reqVO.getNote())
|
||||
.commandId(reqVO == null ? null : reqVO.getCommandId())
|
||||
.requestHash(requestHash)
|
||||
.riskSummary(riskSummaryJson)
|
||||
.build();
|
||||
// acknowledge 是用户处理轨迹,必须每个首次成功 command 追加新行,不能覆盖或删除历史确认动作。
|
||||
ack.setCreateTime(acknowledgedAt);
|
||||
securityEventAckMapper.insert(ack);
|
||||
// 基础事件 acknowledged 只作为快速读状态使用;真实处理历史以 muse_account_security_event_ack 追加表为准。
|
||||
securityEventMapper.markAcknowledgedIfNeeded(parsedEventId, accountUserId, acknowledgedAt);
|
||||
String resultSnapshot = JsonUtils.toJsonString(result);
|
||||
commandService.recordSucceeded(envelope, resultSnapshot);
|
||||
auditService.record(AccountAuditService.AuditCreateReq.builder()
|
||||
.operationId(OPERATION_ACK_SECURITY_EVENT)
|
||||
.actorUserId(accountUserId)
|
||||
.accountUserId(accountUserId)
|
||||
.side("app")
|
||||
.targetType(TARGET_TYPE_SECURITY_EVENT)
|
||||
.targetId(parsedEventId)
|
||||
.commandId(reqVO == null ? null : reqVO.getCommandId())
|
||||
.requestHash(requestHash)
|
||||
.beforeSnapshot(AccountConvert.INSTANCE.sanitizedSecurityEventSnapshot(event, previousAck))
|
||||
.afterSnapshot(JsonUtils.toJsonString(result))
|
||||
.riskSummary(riskSummaryJson)
|
||||
.status("succeeded")
|
||||
.build());
|
||||
return result;
|
||||
}
|
||||
|
||||
private MemberUserDO requireUser(Long accountUserId) {
|
||||
MemberUserDO user = accountUserId == null ? null : memberUserMapper.selectById(accountUserId);
|
||||
if (user == null) {
|
||||
throw new ServiceException(ACCOUNT_USER_NOT_EXISTS);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private Long parseEventId(String eventId) {
|
||||
if (!StringUtils.hasText(eventId)) {
|
||||
throw notFound("安全事件不存在");
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(eventId);
|
||||
} catch (NumberFormatException ex) {
|
||||
throw notFound("安全事件不存在");
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeSeverity(String severity) {
|
||||
if (!StringUtils.hasText(severity)) {
|
||||
return null;
|
||||
}
|
||||
String normalized = severity.trim();
|
||||
if (!SEVERITIES.contains(normalized)) {
|
||||
throw invalidParamException("severity 参数不支持: {}", severity);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String normalizeAction(String action) {
|
||||
if (!StringUtils.hasText(action) || !ACK_ACTIONS.contains(action.trim())) {
|
||||
throw invalidParamException("action 参数不支持: {}", action);
|
||||
}
|
||||
return action.trim();
|
||||
}
|
||||
|
||||
private SecurityEventAcknowledgeResultRespVO buildAckResult(Long eventId, String action,
|
||||
LocalDateTime acknowledgedAt) {
|
||||
SecurityEventAcknowledgeResultRespVO result = new SecurityEventAcknowledgeResultRespVO();
|
||||
result.setEventId(AccountConvert.INSTANCE.stringId(eventId));
|
||||
result.setAcknowledged(true);
|
||||
result.setAction(action);
|
||||
result.setAcknowledgedAt(acknowledgedAt);
|
||||
result.setRiskSummary(ACTION_RISK_SUMMARIES.get(action));
|
||||
result.setNextSteps(ACTION_NEXT_STEPS.getOrDefault(action, Collections.emptyList()));
|
||||
return result;
|
||||
}
|
||||
|
||||
private String riskSummaryJson(SecurityEventAcknowledgeResultRespVO result, AccountSecurityEventAckDO previousAck) {
|
||||
ObjectNode node = JsonUtils.getObjectMapper().createObjectNode();
|
||||
node.put("summary", result.getRiskSummary());
|
||||
node.put("action", result.getAction());
|
||||
node.put("acknowledged", Boolean.TRUE.equals(result.getAcknowledged()));
|
||||
node.put("previousAction", previousAck == null ? null : previousAck.getAction());
|
||||
node.set("nextSteps", JsonUtils.getObjectMapper().valueToTree(result.getNextSteps()));
|
||||
return JsonUtils.toJsonString(node);
|
||||
}
|
||||
|
||||
private ServiceException notFound(String message) {
|
||||
return new ServiceException(NOT_FOUND.getCode(), message);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
package cn.iocoder.muse.module.member.controller.app.account;
|
||||
|
||||
import cn.iocoder.muse.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.muse.module.member.application.account.AccountSecurityService;
|
||||
import cn.iocoder.muse.module.member.controller.account.vo.AccountPageParam;
|
||||
import cn.iocoder.muse.module.member.controller.account.vo.AccountPageResult;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventAcknowledgeReqVO;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventAcknowledgeResultRespVO;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventDetailRespVO;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventSummaryRespVO;
|
||||
import cn.iocoder.muse.module.member.domain.account.AccountApiVersionGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import static cn.iocoder.muse.framework.common.pojo.CommonResult.success;
|
||||
import static cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
|
||||
|
||||
/**
|
||||
* 用户 APP - Muse Account 安全事件 API。
|
||||
*/
|
||||
@Tag(name = "用户 APP - Muse Account 安全事件")
|
||||
@RestController
|
||||
@RequestMapping("/muse")
|
||||
@Validated
|
||||
public class AppAccountSecurityController {
|
||||
|
||||
@Resource
|
||||
private AccountSecurityService securityService;
|
||||
|
||||
@GetMapping(value = "/account/security-events", headers = "X-API-Version")
|
||||
@Operation(summary = "获取当前用户安全事件摘要")
|
||||
public CommonResult<AccountPageResult<SecurityEventSummaryRespVO>> listSecurityEvents(
|
||||
@RequestHeader(value = "X-API-Version", required = false) String apiVersion,
|
||||
@Valid AccountPageParam pageParam,
|
||||
@RequestParam(required = false) String severity) {
|
||||
AccountApiVersionGuard.requireVersion(apiVersion);
|
||||
return success(securityService.appListSecurityEvents(getLoginUserId(), pageParam, severity));
|
||||
}
|
||||
|
||||
@GetMapping(value = "/account/security-events", headers = "!X-API-Version")
|
||||
@Operation(summary = "获取当前用户安全事件摘要")
|
||||
public CommonResult<AccountPageResult<SecurityEventSummaryRespVO>> listSecurityEventsWithoutVersion() {
|
||||
AccountApiVersionGuard.requireVersion(null);
|
||||
return success(null);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/account/security-events/{eventId}", headers = "X-API-Version")
|
||||
@Operation(summary = "获取当前用户安全事件详情")
|
||||
public CommonResult<SecurityEventDetailRespVO> getSecurityEvent(
|
||||
@RequestHeader(value = "X-API-Version", required = false) String apiVersion,
|
||||
@PathVariable String eventId) {
|
||||
AccountApiVersionGuard.requireVersion(apiVersion);
|
||||
return success(securityService.appGetSecurityEvent(getLoginUserId(), eventId));
|
||||
}
|
||||
|
||||
@GetMapping(value = "/account/security-events/{eventId}", headers = "!X-API-Version")
|
||||
@Operation(summary = "获取当前用户安全事件详情")
|
||||
public CommonResult<SecurityEventDetailRespVO> getSecurityEventWithoutVersion(@PathVariable String eventId) {
|
||||
AccountApiVersionGuard.requireVersion(null);
|
||||
return success(null);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/account/security-events/{eventId}/acknowledge", headers = "X-API-Version")
|
||||
@Operation(summary = "确认当前用户安全事件")
|
||||
public CommonResult<SecurityEventAcknowledgeResultRespVO> acknowledgeSecurityEvent(
|
||||
@RequestHeader(value = "X-API-Version", required = false) String apiVersion,
|
||||
@PathVariable String eventId,
|
||||
@RequestBody @Valid SecurityEventAcknowledgeReqVO reqVO) {
|
||||
AccountApiVersionGuard.requireVersion(apiVersion);
|
||||
return success(securityService.appAcknowledgeSecurityEvent(getLoginUserId(), eventId, reqVO));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/account/security-events/{eventId}/acknowledge", headers = "!X-API-Version")
|
||||
@Operation(summary = "确认当前用户安全事件")
|
||||
public CommonResult<SecurityEventAcknowledgeResultRespVO> acknowledgeSecurityEventWithoutVersion(
|
||||
@PathVariable String eventId,
|
||||
@RequestBody @Valid SecurityEventAcknowledgeReqVO reqVO) {
|
||||
AccountApiVersionGuard.requireVersion(null);
|
||||
return success(null);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
package cn.iocoder.muse.module.member.controller.app.account.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Schema(description = "用户 APP - Account 安全事件确认 Request VO")
|
||||
@ -8,10 +11,15 @@ import lombok.Data;
|
||||
public class SecurityEventAcknowledgeReqVO {
|
||||
|
||||
@Schema(description = "幂等键")
|
||||
@NotBlank(message = "commandId 不能为空")
|
||||
private String commandId;
|
||||
@Schema(description = "确认动作")
|
||||
@NotBlank(message = "action 不能为空")
|
||||
@Pattern(regexp = "acknowledged|password_changed|session_revoked|false_positive",
|
||||
message = "action 仅支持 acknowledged、password_changed、session_revoked、false_positive")
|
||||
private String action;
|
||||
@Schema(description = "用户备注")
|
||||
@Size(max = 500, message = "note 最多 500 个字符")
|
||||
private String note;
|
||||
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import cn.iocoder.muse.module.member.controller.app.account.vo.AppPublishRecordR
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.AppPurchaseRecordRespVO;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.AppProfileRespVO;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventDetailRespVO;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventSummaryRespVO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountProfileDO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountRecordProjectionDO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountSecurityEventAckDO;
|
||||
@ -23,6 +24,7 @@ import java.time.format.DateTimeParseException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Account DTO 装配器。
|
||||
@ -41,6 +43,8 @@ public interface AccountConvert {
|
||||
Set<String> REVIEW_STATUSES = Set.of("draft", "submitted", "under_review", "needs_supplement", "approved", "listed", "delisted", "appealed");
|
||||
Set<String> MARKET_STATUSES = Set.of("not_listed", "listed", "delisted", "recalled");
|
||||
Set<String> ACTION_POLICIES = Set.of("allowed", "blocked");
|
||||
Pattern IPV4_TEXT_PATTERN = Pattern.compile(
|
||||
"\\b((25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1?\\d?\\d)\\b");
|
||||
|
||||
default AppProfileRespVO convertProfile(AccountProfileDO profile, String email, String phone,
|
||||
Boolean emailVerified, Boolean phoneVerified, String status,
|
||||
@ -153,6 +157,23 @@ public interface AccountConvert {
|
||||
return vo;
|
||||
}
|
||||
|
||||
default SecurityEventSummaryRespVO convertSecurityEventSummary(MemberSecurityEventDO event,
|
||||
AccountSecurityEventAckDO ack) {
|
||||
if (event == null) {
|
||||
return null;
|
||||
}
|
||||
JsonNode deviceNode = parseSnapshot(event.getDeviceInfo());
|
||||
SecurityEventSummaryRespVO vo = new SecurityEventSummaryRespVO();
|
||||
vo.setEventId(stringId(event.getId()));
|
||||
vo.setEventType(event.getEventType());
|
||||
vo.setSeverity(event.getSeverity());
|
||||
vo.setOccurredAt(event.getCreateTime());
|
||||
// 摘要只取业务描述,不回传 IP、UA、deviceInfo 原始 JSON。
|
||||
vo.setDescription(maskSensitiveText(text(deviceNode, "description")));
|
||||
vo.setAcknowledged(ack != null || Boolean.TRUE.equals(event.getAcknowledged()));
|
||||
return vo;
|
||||
}
|
||||
|
||||
default SecurityEventDetailRespVO convertSecurityEventDetail(MemberSecurityEventDO event,
|
||||
AccountSecurityEventAckDO ack) {
|
||||
if (event == null) {
|
||||
@ -166,9 +187,9 @@ public interface AccountConvert {
|
||||
vo.setOccurredAt(event.getCreateTime());
|
||||
vo.setSourceIp(maskIp(event.getIpAddress()));
|
||||
vo.setDeviceInfo(convertDeviceInfo(deviceNode));
|
||||
vo.setAffectedScope(text(deviceNode, "affectedScope"));
|
||||
vo.setDescription(text(deviceNode, "description"));
|
||||
vo.setSuggestedActions(Collections.emptyList());
|
||||
vo.setAffectedScope(maskSensitiveText(text(deviceNode, "affectedScope")));
|
||||
vo.setDescription(maskSensitiveText(text(deviceNode, "description")));
|
||||
vo.setSuggestedActions(stringList(deviceNode, "suggestedActions"));
|
||||
if (ack != null) {
|
||||
vo.setAcknowledgedAt(ack.getCreateTime());
|
||||
vo.setAcknowledgedAction(ack.getAction());
|
||||
@ -178,6 +199,11 @@ public interface AccountConvert {
|
||||
return vo;
|
||||
}
|
||||
|
||||
default String sanitizedSecurityEventSnapshot(MemberSecurityEventDO event, AccountSecurityEventAckDO ack) {
|
||||
SecurityEventDetailRespVO detail = convertSecurityEventDetail(event, ack);
|
||||
return detail == null ? null : JsonUtils.toJsonString(detail);
|
||||
}
|
||||
|
||||
default SecurityEventDetailRespVO.DeviceInfoVO convertDeviceInfo(JsonNode deviceNode) {
|
||||
SecurityEventDetailRespVO.DeviceInfoVO vo = new SecurityEventDetailRespVO.DeviceInfoVO();
|
||||
vo.setUserAgent(maskUserAgent(text(deviceNode, "userAgent")));
|
||||
@ -238,6 +264,14 @@ public interface AccountConvert {
|
||||
return userAgent.substring(0, blankIndex) + " ***";
|
||||
}
|
||||
|
||||
default String maskSensitiveText(String text) {
|
||||
if (text == null || text.isBlank()) {
|
||||
return text;
|
||||
}
|
||||
// deviceInfo JSON 的 description/affectedScope 属于外部事实文本,可能嵌入 IP;输出前统一做 IPv4 摘要化。
|
||||
return IPV4_TEXT_PATTERN.matcher(text).replaceAll(match -> maskIp(match.group()));
|
||||
}
|
||||
|
||||
default JsonNode parseSnapshot(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return null;
|
||||
|
||||
@ -5,6 +5,7 @@ import cn.iocoder.muse.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountSecurityEventAckDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@ -38,6 +39,18 @@ public interface AccountSecurityEventAckMapper extends BaseMapperX<AccountSecuri
|
||||
.orderByDesc(AccountSecurityEventAckDO::getId));
|
||||
}
|
||||
|
||||
default List<AccountSecurityEventAckDO> selectLatestByEventIdsAndAccountUserId(List<Long> eventIds,
|
||||
Long accountUserId) {
|
||||
if (eventIds == null || eventIds.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return selectList(new LambdaQueryWrapperX<AccountSecurityEventAckDO>()
|
||||
.in(AccountSecurityEventAckDO::getEventId, eventIds)
|
||||
.eq(AccountSecurityEventAckDO::getAccountUserId, accountUserId)
|
||||
.orderByDesc(AccountSecurityEventAckDO::getCreateTime)
|
||||
.orderByDesc(AccountSecurityEventAckDO::getId));
|
||||
}
|
||||
|
||||
default AccountSecurityEventAckDO selectByCommandId(String commandId) {
|
||||
return selectOne(AccountSecurityEventAckDO::getCommandId, commandId);
|
||||
}
|
||||
|
||||
@ -2,9 +2,14 @@ package cn.iocoder.muse.module.member.dal.mysql.account;
|
||||
|
||||
import cn.iocoder.muse.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.muse.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.muse.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.muse.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.MemberSecurityEventDO;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@ -13,6 +18,16 @@ import java.util.List;
|
||||
@Mapper
|
||||
public interface MemberSecurityEventMapper extends BaseMapperX<MemberSecurityEventDO> {
|
||||
|
||||
default PageResult<MemberSecurityEventDO> selectPageByAccountUserIdAndSeverity(PageParam pageParam,
|
||||
Long accountUserId,
|
||||
String severity) {
|
||||
return selectPage(pageParam, new LambdaQueryWrapperX<MemberSecurityEventDO>()
|
||||
.eq(MemberSecurityEventDO::getAccountUserId, accountUserId)
|
||||
.eqIfPresent(MemberSecurityEventDO::getSeverity, severity)
|
||||
.orderByDesc(MemberSecurityEventDO::getCreateTime)
|
||||
.orderByDesc(MemberSecurityEventDO::getId));
|
||||
}
|
||||
|
||||
default List<MemberSecurityEventDO> selectListByAccountUserId(Long accountUserId) {
|
||||
return selectList(new LambdaQueryWrapperX<MemberSecurityEventDO>()
|
||||
.eq(MemberSecurityEventDO::getAccountUserId, accountUserId)
|
||||
@ -23,4 +38,26 @@ public interface MemberSecurityEventMapper extends BaseMapperX<MemberSecurityEve
|
||||
return selectOne(MemberSecurityEventDO::getId, id, MemberSecurityEventDO::getAccountUserId, accountUserId);
|
||||
}
|
||||
|
||||
default List<MemberSecurityEventDO> selectListByAccountUserIds(List<Long> accountUserIds) {
|
||||
if (accountUserIds == null || accountUserIds.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return selectList(new LambdaQueryWrapperX<MemberSecurityEventDO>()
|
||||
.in(MemberSecurityEventDO::getAccountUserId, accountUserIds)
|
||||
.orderByDesc(MemberSecurityEventDO::getCreateTime)
|
||||
.orderByDesc(MemberSecurityEventDO::getId));
|
||||
}
|
||||
|
||||
default int markAcknowledgedIfNeeded(Long id, Long accountUserId, LocalDateTime acknowledgedAt) {
|
||||
LambdaUpdateWrapper<MemberSecurityEventDO> updateWrapper = new LambdaUpdateWrapper<MemberSecurityEventDO>()
|
||||
.set(MemberSecurityEventDO::getAcknowledged, true)
|
||||
.set(MemberSecurityEventDO::getAcknowledgedAt, acknowledgedAt)
|
||||
.eq(MemberSecurityEventDO::getId, id)
|
||||
.eq(MemberSecurityEventDO::getAccountUserId, accountUserId)
|
||||
.and(wrapper -> wrapper.eq(MemberSecurityEventDO::getAcknowledged, false)
|
||||
.or()
|
||||
.isNull(MemberSecurityEventDO::getAcknowledged));
|
||||
return update(null, updateWrapper);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -14,9 +14,11 @@ import cn.iocoder.muse.module.member.controller.app.account.vo.AppProfileRespVO;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.AppProfileUpdateReqVO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountCommandDO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountProfileDO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.MemberSecurityEventDO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.MemberNewApiBindingDO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.user.MemberUserDO;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.account.AccountProfileMapper;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.account.MemberSecurityEventMapper;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.account.MemberNewApiBindingMapper;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.user.MemberUserMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
|
||||
@ -61,6 +63,8 @@ class AccountProfileServiceTest extends BaseMockitoUnitTest {
|
||||
private AccountEntitlementService entitlementService;
|
||||
@Mock
|
||||
private MemberNewApiBindingMapper newApiBindingMapper;
|
||||
@Mock
|
||||
private MemberSecurityEventMapper securityEventMapper;
|
||||
|
||||
@BeforeAll
|
||||
static void initMybatisPlusTableInfo() {
|
||||
@ -349,6 +353,26 @@ class AccountProfileServiceTest extends BaseMockitoUnitTest {
|
||||
assertEquals("bound", page.getList().get(0).getNewApiBindingStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_listAdminUserSummaries_fillSecurityRiskFlagsFromEvents() {
|
||||
PageParam pageParam = pageParam();
|
||||
when(memberUserMapper.selectPage(any(PageParam.class), any(Wrapper.class)))
|
||||
.thenReturn(new PageResult<>(List.of(memberUser()), 1L));
|
||||
when(profileMapper.selectListByAccountUserIds(List.of(1001L))).thenReturn(List.of(profile()));
|
||||
when(entitlementService.getAdminEntitlementSummaries(List.of(1001L))).thenReturn(Map.of());
|
||||
when(newApiBindingMapper.selectListByAccountUserIds(List.of(1001L))).thenReturn(List.of());
|
||||
when(securityEventMapper.selectListByAccountUserIds(List.of(1001L))).thenReturn(List.of(
|
||||
securityEvent(1001L, "critical", false),
|
||||
securityEvent(1001L, "warning", false),
|
||||
securityEvent(1001L, "critical", true)));
|
||||
|
||||
AccountPageResult<AdminAccountUserSummaryRespVO> page =
|
||||
profileService.listAdminUserSummaries(pageParam, null, null, null);
|
||||
|
||||
assertEquals(List.of("critical_security_event", "unacknowledged_security_event"),
|
||||
page.getList().get(0).getRiskFlags());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_listAdminUserSummaries_filterByEntitlementSourceBeforePagination() {
|
||||
PageParam pageParam = pageParam();
|
||||
@ -489,6 +513,16 @@ class AccountProfileServiceTest extends BaseMockitoUnitTest {
|
||||
.build();
|
||||
}
|
||||
|
||||
private static MemberSecurityEventDO securityEvent(Long accountUserId, String severity, Boolean acknowledged) {
|
||||
return MemberSecurityEventDO.builder()
|
||||
.id("critical".equals(severity) ? 8801L : 8802L)
|
||||
.accountUserId(accountUserId)
|
||||
.eventType("login_anomaly")
|
||||
.severity(severity)
|
||||
.acknowledged(acknowledged)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static AppProfileUpdateReqVO updateReq() {
|
||||
AppProfileUpdateReqVO reqVO = new AppProfileUpdateReqVO();
|
||||
reqVO.setCommandId("cmd-profile-1");
|
||||
|
||||
@ -0,0 +1,284 @@
|
||||
package cn.iocoder.muse.module.member.application.account;
|
||||
|
||||
import cn.iocoder.muse.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.muse.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.muse.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.muse.module.member.controller.account.vo.AccountPageResult;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventAcknowledgeReqVO;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventAcknowledgeResultRespVO;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventDetailRespVO;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventSummaryRespVO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountCommandDO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountSecurityEventAckDO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.MemberSecurityEventDO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.user.MemberUserDO;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.account.AccountSecurityEventAckMapper;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.account.MemberSecurityEventMapper;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.user.MemberUserMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.muse.framework.common.exception.enums.GlobalErrorCodeConstants.NOT_FOUND;
|
||||
import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_USER_NOT_EXISTS;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* AccountSecurityService 单元测试。
|
||||
*/
|
||||
class AccountSecurityServiceTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private AccountSecurityServiceImpl securityService;
|
||||
@Mock
|
||||
private MemberUserMapper memberUserMapper;
|
||||
@Mock
|
||||
private MemberSecurityEventMapper securityEventMapper;
|
||||
@Mock
|
||||
private AccountSecurityEventAckMapper securityEventAckMapper;
|
||||
@Mock
|
||||
private AccountCommandService commandService;
|
||||
@Mock
|
||||
private AccountAuditService auditService;
|
||||
|
||||
@Test
|
||||
void should_listSecurityEventsForOwner_withLatestAckTrail() {
|
||||
PageParam pageParam = pageParam();
|
||||
MemberSecurityEventDO event = securityEvent(88L, 1001L, "login_anomaly", "critical");
|
||||
AccountSecurityEventAckDO ack = ack(501L, 88L, 1001L, "password_changed");
|
||||
when(memberUserMapper.selectById(1001L)).thenReturn(memberUser(1001L));
|
||||
when(securityEventMapper.selectPageByAccountUserIdAndSeverity(pageParam, 1001L, "critical"))
|
||||
.thenReturn(new PageResult<>(List.of(event), 1L));
|
||||
when(securityEventAckMapper.selectLatestByEventIdsAndAccountUserId(List.of(88L), 1001L))
|
||||
.thenReturn(List.of(ack));
|
||||
|
||||
AccountPageResult<SecurityEventSummaryRespVO> page =
|
||||
securityService.appListSecurityEvents(1001L, pageParam, "critical");
|
||||
|
||||
assertEquals(1L, page.getTotal());
|
||||
SecurityEventSummaryRespVO summary = page.getList().get(0);
|
||||
assertEquals("88", summary.getEventId());
|
||||
assertEquals("login_anomaly", summary.getEventType());
|
||||
assertEquals("critical", summary.getSeverity());
|
||||
assertEquals("异常登录来自 203.0.113.***", summary.getDescription());
|
||||
assertTrue(summary.getAcknowledged());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_getSecurityEventDetailForOwner_withMaskedSensitiveValues() {
|
||||
MemberSecurityEventDO event = securityEvent(88L, 1001L, "login_anomaly", "critical");
|
||||
AccountSecurityEventAckDO ack = ack(501L, 88L, 1001L, "password_changed");
|
||||
when(memberUserMapper.selectById(1001L)).thenReturn(memberUser(1001L));
|
||||
when(securityEventMapper.selectByIdAndAccountUserId(88L, 1001L)).thenReturn(event);
|
||||
when(securityEventAckMapper.selectLatestByEventIdAndAccountUserId(88L, 1001L)).thenReturn(ack);
|
||||
|
||||
SecurityEventDetailRespVO detail = securityService.appGetSecurityEvent(1001L, "88");
|
||||
|
||||
assertEquals("88", detail.getEventId());
|
||||
assertEquals("203.0.113.***", detail.getSourceIp());
|
||||
assertEquals("Mozilla/5.0 ***", detail.getDeviceInfo().getUserAgent());
|
||||
assertEquals("macOS", detail.getDeviceInfo().getPlatform());
|
||||
assertEquals("CN Shanghai", detail.getDeviceInfo().getLocation());
|
||||
assertEquals("账户登录会话", detail.getAffectedScope());
|
||||
assertEquals("异常登录来自 203.0.113.***", detail.getDescription());
|
||||
assertEquals(List.of("修改密码", "重新登录"), detail.getSuggestedActions());
|
||||
assertEquals("password_changed", detail.getAcknowledgedAction());
|
||||
assertEquals(ack.getCreateTime(), detail.getAcknowledgedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_throwNotFound_when_detailDoesNotBelongToLoginUser() {
|
||||
when(memberUserMapper.selectById(1001L)).thenReturn(memberUser(1001L));
|
||||
when(securityEventMapper.selectByIdAndAccountUserId(88L, 1001L)).thenReturn(null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> securityService.appGetSecurityEvent(1001L, "88"));
|
||||
|
||||
assertEquals(NOT_FOUND.getCode(), exception.getCode());
|
||||
verify(securityEventAckMapper, never()).selectLatestByEventIdAndAccountUserId(anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_throwUserNotExists_when_listSecurityEventsForMissingUser() {
|
||||
when(memberUserMapper.selectById(1001L)).thenReturn(null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> securityService.appListSecurityEvents(1001L, pageParam(), null));
|
||||
|
||||
assertEquals(ACCOUNT_USER_NOT_EXISTS.getCode(), exception.getCode());
|
||||
verifyNoInteractions(securityEventMapper, securityEventAckMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_appendAckAndRecordCommandAudit_when_acknowledgeFirstTime() {
|
||||
SecurityEventAcknowledgeReqVO reqVO = ackReq("cmd-sec-1", "password_changed");
|
||||
MemberSecurityEventDO event = securityEvent(88L, 1001L, "login_anomaly", "critical");
|
||||
when(memberUserMapper.selectById(1001L)).thenReturn(memberUser(1001L));
|
||||
when(securityEventMapper.selectByIdAndAccountUserId(88L, 1001L)).thenReturn(event);
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-sec-1");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
when(commandService.recordSucceeded(any(), anyString())).thenReturn(new AccountCommandDO());
|
||||
|
||||
SecurityEventAcknowledgeResultRespVO result =
|
||||
securityService.appAcknowledgeSecurityEvent(1001L, "88", reqVO);
|
||||
|
||||
assertEquals("88", result.getEventId());
|
||||
assertTrue(result.getAcknowledged());
|
||||
assertEquals("password_changed", result.getAction());
|
||||
assertNotNull(result.getAcknowledgedAt());
|
||||
assertTrue(result.getRiskSummary().contains("已修改密码"));
|
||||
verify(commandService).reserveCommand(argThat(envelope -> "cmd-sec-1".equals(envelope.getCommandId())
|
||||
&& "appAcknowledgeSecurityEvent".equals(envelope.getOperationId())
|
||||
&& Long.valueOf(1001L).equals(envelope.getActorUserId())
|
||||
&& Long.valueOf(1001L).equals(envelope.getOwnerUserId())
|
||||
&& "securityEvent".equals(envelope.getTargetType())
|
||||
&& Long.valueOf(88L).equals(envelope.getTargetId())
|
||||
&& "hash-sec-1".equals(envelope.getRequestHash())));
|
||||
verify(securityEventAckMapper).insert(argThat((AccountSecurityEventAckDO inserted) ->
|
||||
Long.valueOf(88L).equals(inserted.getEventId())
|
||||
&& Long.valueOf(1001L).equals(inserted.getAccountUserId())
|
||||
&& Long.valueOf(1001L).equals(inserted.getActorUserId())
|
||||
&& "password_changed".equals(inserted.getAction())
|
||||
&& "已处理".equals(inserted.getNote())
|
||||
&& "cmd-sec-1".equals(inserted.getCommandId())
|
||||
&& "hash-sec-1".equals(inserted.getRequestHash())
|
||||
&& JsonUtils.parseTree(inserted.getRiskSummary()).path("summary").asText().contains("已修改密码")));
|
||||
verify(securityEventMapper).markAcknowledgedIfNeeded(eq(88L), eq(1001L), any(LocalDateTime.class));
|
||||
verify(commandService).recordSucceeded(any(), argThat(snapshot ->
|
||||
JsonUtils.parseTree(snapshot).path("eventId").asText().equals("88")
|
||||
&& JsonUtils.parseTree(snapshot).path("action").asText().equals("password_changed")));
|
||||
verify(auditService).record(argThat(req -> "appAcknowledgeSecurityEvent".equals(req.getOperationId())
|
||||
&& Long.valueOf(1001L).equals(req.getActorUserId())
|
||||
&& Long.valueOf(1001L).equals(req.getAccountUserId())
|
||||
&& "app".equals(req.getSide())
|
||||
&& "securityEvent".equals(req.getTargetType())
|
||||
&& Long.valueOf(88L).equals(req.getTargetId())
|
||||
&& "cmd-sec-1".equals(req.getCommandId())
|
||||
&& "succeeded".equals(req.getStatus())
|
||||
&& !req.getBeforeSnapshot().contains("203.0.113.44")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_replayAcknowledgement_withoutAppendingAckAgain_when_commandIdempotentHit() {
|
||||
SecurityEventAcknowledgeReqVO reqVO = ackReq("cmd-sec-1", "acknowledged");
|
||||
when(memberUserMapper.selectById(1001L)).thenReturn(memberUser(1001L));
|
||||
when(securityEventMapper.selectByIdAndAccountUserId(88L, 1001L))
|
||||
.thenReturn(securityEvent(88L, 1001L, "login_anomaly", "warning"));
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-sec-1");
|
||||
when(commandService.reserveCommand(any())).thenReturn(AccountCommandDO.builder()
|
||||
.resultSnapshot("""
|
||||
{"eventId":"88","acknowledged":true,"action":"acknowledged","acknowledgedAt":"2026-05-29T10:15:00","riskSummary":"已知晓","nextSteps":["持续观察"]}
|
||||
""")
|
||||
.build());
|
||||
|
||||
SecurityEventAcknowledgeResultRespVO replay =
|
||||
securityService.appAcknowledgeSecurityEvent(1001L, "88", reqVO);
|
||||
|
||||
assertEquals("88", replay.getEventId());
|
||||
assertEquals("acknowledged", replay.getAction());
|
||||
assertEquals("已知晓", replay.getRiskSummary());
|
||||
verify(securityEventAckMapper, never()).insert(any(AccountSecurityEventAckDO.class));
|
||||
verify(securityEventMapper, never()).markAcknowledgedIfNeeded(anyLong(), anyLong(), any());
|
||||
verify(commandService, never()).recordSucceeded(any(), anyString());
|
||||
verify(auditService, never()).record(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_appendAnotherAck_when_sameEventAcknowledgedAgainWithDifferentCommand() {
|
||||
SecurityEventAcknowledgeReqVO reqVO = ackReq("cmd-sec-2", "false_positive");
|
||||
when(memberUserMapper.selectById(1001L)).thenReturn(memberUser(1001L));
|
||||
when(securityEventMapper.selectByIdAndAccountUserId(88L, 1001L))
|
||||
.thenReturn(securityEvent(88L, 1001L, "login_anomaly", "warning"));
|
||||
when(securityEventAckMapper.selectLatestByEventIdAndAccountUserId(88L, 1001L))
|
||||
.thenReturn(ack(501L, 88L, 1001L, "acknowledged"));
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-sec-2");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
when(commandService.recordSucceeded(any(), anyString())).thenReturn(new AccountCommandDO());
|
||||
|
||||
SecurityEventAcknowledgeResultRespVO result =
|
||||
securityService.appAcknowledgeSecurityEvent(1001L, "88", reqVO);
|
||||
|
||||
assertEquals("false_positive", result.getAction());
|
||||
verify(securityEventAckMapper).insert(argThat((AccountSecurityEventAckDO inserted) ->
|
||||
"false_positive".equals(inserted.getAction()) && "cmd-sec-2".equals(inserted.getCommandId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_returnPendingNextSteps_when_sessionRevokedButSessionOwnerUnavailable() {
|
||||
SecurityEventAcknowledgeReqVO reqVO = ackReq("cmd-sec-session", "session_revoked");
|
||||
when(memberUserMapper.selectById(1001L)).thenReturn(memberUser(1001L));
|
||||
when(securityEventMapper.selectByIdAndAccountUserId(88L, 1001L))
|
||||
.thenReturn(securityEvent(88L, 1001L, "device_change", "critical"));
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-session");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
when(commandService.recordSucceeded(any(), anyString())).thenReturn(new AccountCommandDO());
|
||||
|
||||
SecurityEventAcknowledgeResultRespVO result =
|
||||
securityService.appAcknowledgeSecurityEvent(1001L, "88", reqVO);
|
||||
|
||||
assertTrue(result.getAcknowledged());
|
||||
assertEquals("session_revoked", result.getAction());
|
||||
assertTrue(result.getRiskSummary().contains("未接入 session 管理服务"));
|
||||
assertTrue(result.getNextSteps().contains("手动退出其他设备后重新登录"));
|
||||
verify(securityEventAckMapper).insert(argThat((AccountSecurityEventAckDO inserted) ->
|
||||
JsonUtils.parseTree(inserted.getRiskSummary()).path("summary").asText()
|
||||
.contains("未接入 session 管理服务")));
|
||||
}
|
||||
|
||||
private static PageParam pageParam() {
|
||||
PageParam pageParam = new PageParam();
|
||||
pageParam.setPageNo(1);
|
||||
pageParam.setPageSize(20);
|
||||
return pageParam;
|
||||
}
|
||||
|
||||
private static MemberUserDO memberUser(Long userId) {
|
||||
return MemberUserDO.builder().id(userId).nickname("Muse").build();
|
||||
}
|
||||
|
||||
private static MemberSecurityEventDO securityEvent(Long id, Long accountUserId, String eventType, String severity) {
|
||||
MemberSecurityEventDO event = MemberSecurityEventDO.builder()
|
||||
.id(id)
|
||||
.accountUserId(accountUserId)
|
||||
.eventType(eventType)
|
||||
.severity(severity)
|
||||
.ipAddress("203.0.113.44")
|
||||
.acknowledged(false)
|
||||
.deviceInfo("""
|
||||
{"userAgent":"Mozilla/5.0 AppleWebKit/537.36","platform":"macOS","location":"CN Shanghai","affectedScope":"账户登录会话","description":"异常登录来自 203.0.113.44","suggestedActions":["修改密码","重新登录"]}
|
||||
""")
|
||||
.build();
|
||||
event.setCreateTime(LocalDateTime.of(2026, 5, 29, 10, 0));
|
||||
return event;
|
||||
}
|
||||
|
||||
private static AccountSecurityEventAckDO ack(Long id, Long eventId, Long accountUserId, String action) {
|
||||
AccountSecurityEventAckDO ack = AccountSecurityEventAckDO.builder()
|
||||
.id(id)
|
||||
.eventId(eventId)
|
||||
.accountUserId(accountUserId)
|
||||
.actorUserId(accountUserId)
|
||||
.action(action)
|
||||
.commandId("cmd-old")
|
||||
.requestHash("hash-old")
|
||||
.riskSummary("{\"summary\":\"旧确认\"}")
|
||||
.build();
|
||||
ack.setCreateTime(LocalDateTime.of(2026, 5, 29, 10, 10));
|
||||
return ack;
|
||||
}
|
||||
|
||||
private static SecurityEventAcknowledgeReqVO ackReq(String commandId, String action) {
|
||||
SecurityEventAcknowledgeReqVO reqVO = new SecurityEventAcknowledgeReqVO();
|
||||
reqVO.setCommandId(commandId);
|
||||
reqVO.setAction(action);
|
||||
reqVO.setNote("已处理");
|
||||
return reqVO;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,215 @@
|
||||
package cn.iocoder.muse.module.member.controller.app.account;
|
||||
|
||||
import cn.iocoder.muse.framework.common.enums.UserTypeEnum;
|
||||
import cn.iocoder.muse.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.muse.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.muse.framework.security.core.LoginUser;
|
||||
import cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.muse.module.member.application.account.AccountSecurityService;
|
||||
import cn.iocoder.muse.module.member.controller.account.vo.AccountPageResult;
|
||||
import cn.iocoder.muse.module.member.controller.app.AppMuseAccountContractController;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventAcknowledgeResultRespVO;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventDetailRespVO;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventSummaryRespVO;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_API_VERSION_UNSUPPORTED;
|
||||
import static org.hamcrest.Matchers.hasKey;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* AppAccountSecurityController 单元测试。
|
||||
*/
|
||||
class AppAccountSecurityControllerTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private AppAccountSecurityController controller;
|
||||
@Mock
|
||||
private AccountSecurityService securityService;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller)
|
||||
.setControllerAdvice(new TestExceptionAdvice())
|
||||
.build();
|
||||
SecurityFrameworkUtils.setLoginUser(loginUser(1001L), new MockHttpServletRequest());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectMissingApiVersion_beforeCatchAll_when_securityRoutesExist() throws Exception {
|
||||
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller, new AppMuseAccountContractController())
|
||||
.setControllerAdvice(new TestExceptionAdvice())
|
||||
.build();
|
||||
|
||||
routeMockMvc.perform(get("/muse/account/security-events"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(ACCOUNT_API_VERSION_UNSUPPORTED.getCode()))
|
||||
.andExpect(jsonPath("$").value(hasKey("data")));
|
||||
verifyNoInteractions(securityService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectMissingApiVersion_beforeCatchAll_when_securityDetailAndAckRoutesExist() throws Exception {
|
||||
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller, new AppMuseAccountContractController())
|
||||
.setControllerAdvice(new TestExceptionAdvice())
|
||||
.build();
|
||||
|
||||
routeMockMvc.perform(get("/muse/account/security-events/88"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(ACCOUNT_API_VERSION_UNSUPPORTED.getCode()));
|
||||
routeMockMvc.perform(post("/muse/account/security-events/88/acknowledge")
|
||||
.contentType("application/json")
|
||||
.content("{\"commandId\":\"cmd-sec-1\",\"action\":\"acknowledged\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(ACCOUNT_API_VERSION_UNSUPPORTED.getCode()));
|
||||
verifyNoInteractions(securityService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_useLoginUserAsOwner_when_listSecurityEvents() throws Exception {
|
||||
SecurityEventSummaryRespVO summary = new SecurityEventSummaryRespVO();
|
||||
summary.setEventId("88");
|
||||
summary.setEventType("login_anomaly");
|
||||
summary.setSeverity("critical");
|
||||
summary.setAcknowledged(true);
|
||||
when(securityService.appListSecurityEvents(eq(1001L), any(), eq("critical")))
|
||||
.thenReturn(new AccountPageResult<>(1L, 2, 20, List.of(summary)));
|
||||
|
||||
mockMvc.perform(get("/muse/account/security-events")
|
||||
.header("X-API-Version", "1")
|
||||
.param("pageNo", "2")
|
||||
.param("pageSize", "20")
|
||||
.param("userId", "2002")
|
||||
.param("severity", "critical"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.pageNo").value(2))
|
||||
.andExpect(jsonPath("$.data.list[0].eventId").value("88"))
|
||||
.andExpect(jsonPath("$.data.list[0].acknowledged").value(true));
|
||||
|
||||
verify(securityService).appListSecurityEvents(eq(1001L), argThat(page ->
|
||||
Integer.valueOf(2).equals(page.getPageNo()) && Integer.valueOf(20).equals(page.getPageSize())),
|
||||
eq("critical"));
|
||||
verify(securityService, never()).appListSecurityEvents(eq(2002L), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_getSecurityEventForLoginUserOnly_andNotExposeRawSensitiveFields() throws Exception {
|
||||
SecurityEventDetailRespVO detail = new SecurityEventDetailRespVO();
|
||||
detail.setEventId("88");
|
||||
detail.setEventType("login_anomaly");
|
||||
detail.setSeverity("critical");
|
||||
detail.setSourceIp("203.0.113.***");
|
||||
detail.setDescription("异常登录");
|
||||
detail.setSuggestedActions(List.of("修改密码"));
|
||||
SecurityEventDetailRespVO.DeviceInfoVO deviceInfo = new SecurityEventDetailRespVO.DeviceInfoVO();
|
||||
deviceInfo.setUserAgent("Mozilla/5.0 ***");
|
||||
deviceInfo.setPlatform("macOS");
|
||||
deviceInfo.setLocation("CN Shanghai");
|
||||
detail.setDeviceInfo(deviceInfo);
|
||||
when(securityService.appGetSecurityEvent(1001L, "88")).thenReturn(detail);
|
||||
|
||||
mockMvc.perform(get("/muse/account/security-events/88").header("X-API-Version", "1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.eventId").value("88"))
|
||||
.andExpect(jsonPath("$.data.sourceIp").value("203.0.113.***"))
|
||||
.andExpect(jsonPath("$.data.deviceInfo.userAgent").value("Mozilla/5.0 ***"))
|
||||
.andExpect(jsonPath("$.data", not(hasKey("ipAddress"))));
|
||||
|
||||
verify(securityService).appGetSecurityEvent(1001L, "88");
|
||||
verify(securityService, never()).appGetSecurityEvent(2002L, "88");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_acknowledgeSecurityEventForLoginUserOnly() throws Exception {
|
||||
SecurityEventAcknowledgeResultRespVO result = new SecurityEventAcknowledgeResultRespVO();
|
||||
result.setEventId("88");
|
||||
result.setAcknowledged(true);
|
||||
result.setAction("session_revoked");
|
||||
result.setAcknowledgedAt(LocalDateTime.of(2026, 5, 29, 10, 15));
|
||||
result.setRiskSummary("相关 session 撤销待人工处理,未接入 session 管理服务");
|
||||
result.setNextSteps(List.of("手动退出其他设备后重新登录"));
|
||||
when(securityService.appAcknowledgeSecurityEvent(eq(1001L), eq("88"), any())).thenReturn(result);
|
||||
|
||||
mockMvc.perform(post("/muse/account/security-events/88/acknowledge")
|
||||
.header("X-API-Version", "1")
|
||||
.contentType("application/json")
|
||||
.content("{\"commandId\":\"cmd-sec-1\",\"action\":\"session_revoked\",\"note\":\"已处理\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.eventId").value("88"))
|
||||
.andExpect(jsonPath("$.data.action").value("session_revoked"))
|
||||
.andExpect(jsonPath("$.data.riskSummary").value("相关 session 撤销待人工处理,未接入 session 管理服务"))
|
||||
.andExpect(jsonPath("$.data", not(hasKey("status"))));
|
||||
|
||||
verify(securityService).appAcknowledgeSecurityEvent(eq(1001L), eq("88"), argThat(req ->
|
||||
"cmd-sec-1".equals(req.getCommandId())
|
||||
&& "session_revoked".equals(req.getAction())
|
||||
&& "已处理".equals(req.getNote())));
|
||||
verify(securityService, never()).appAcknowledgeSecurityEvent(eq(2002L), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectMissingCommandId_when_acknowledgeSecurityEvent() throws Exception {
|
||||
mockMvc.perform(post("/muse/account/security-events/88/acknowledge")
|
||||
.header("X-API-Version", "1")
|
||||
.contentType("application/json")
|
||||
.content("{\"action\":\"acknowledged\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(400));
|
||||
verifyNoInteractions(securityService);
|
||||
}
|
||||
|
||||
private static LoginUser loginUser(Long userId) {
|
||||
return new LoginUser().setId(userId).setUserType(UserTypeEnum.MEMBER.getValue());
|
||||
}
|
||||
|
||||
@RestControllerAdvice
|
||||
static class TestExceptionAdvice {
|
||||
|
||||
@ExceptionHandler(ServiceException.class)
|
||||
CommonResult<?> serviceException(ServiceException exception) {
|
||||
return CommonResult.error(exception.getCode(), exception.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
CommonResult<?> methodArgumentNotValidException(MethodArgumentNotValidException exception) {
|
||||
return CommonResult.error(400, exception.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(BindException.class)
|
||||
CommonResult<?> bindException(BindException exception) {
|
||||
return CommonResult.error(400, exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -70,6 +70,25 @@ class AccountSecurityEventAckMapperTest {
|
||||
assertFalse(sqlSegment.contains("limit 1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_selectLatestBatchByEventIdsAndAccountUserIdWithOwnerAndStableOrder() {
|
||||
AccountSecurityEventAckMapper mapper = mock(AccountSecurityEventAckMapper.class, CALLS_REAL_METHODS);
|
||||
List<AccountSecurityEventAckDO> expected = List.of(AccountSecurityEventAckDO.builder().id(3L).build());
|
||||
doReturn(expected).when(mapper).selectList(any());
|
||||
|
||||
List<AccountSecurityEventAckDO> actual = mapper.selectLatestByEventIdsAndAccountUserId(List.of(88L, 89L),
|
||||
1001L);
|
||||
|
||||
assertSame(expected, actual);
|
||||
String sqlSegment = captureSelectListSql(mapper);
|
||||
assertTrue(sqlSegment.contains("event_id"));
|
||||
assertTrue(sqlSegment.contains("in"));
|
||||
assertTrue(sqlSegment.contains("account_user_id"));
|
||||
assertTrue(sqlSegment.contains("order by"));
|
||||
assertTrue(sqlSegment.contains("create_time desc"));
|
||||
assertTrue(sqlSegment.contains("id desc"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_notExposeEventOnlySelectOneApi() {
|
||||
assertThrows(NoSuchMethodException.class,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user