feat(p1r): 实现 Account 安全事件 API

This commit is contained in:
zizi 2026-05-29 23:12:49 +08:00
parent fab1ed0e82
commit 0d37d37c76
12 changed files with 1033 additions and 6 deletions

View File

@ -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)) {

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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());
// 摘要只取业务描述不回传 IPUAdeviceInfo 原始 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;

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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");

View File

@ -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;
}
}

View File

@ -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());
}
}
}

View File

@ -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,