暂存提交

This commit is contained in:
2026-08-13 19:01:16 +08:00
parent 8ba04fc87d
commit 073cb6a577
50 changed files with 981 additions and 136 deletions
@@ -0,0 +1,17 @@
package com.baiyue.iam.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventEnvelope;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
@FeignClient(name = "event-bridge-service", contextId = "iamSecurityEventBridgeClient")
public interface SecurityEventBridgeClient {
@PostMapping("/internal/event-bridge/events")
ApiResponse<Acknowledgement> receive(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
record Acknowledgement(String eventId, String result, String receivedAt) { }
}
@@ -39,5 +39,6 @@ public final class AuthorizationDtos {
private String clientType;
private Set<String> roles;
private Set<String> permissions;
private Long permissionVersion;
}
}
@@ -62,4 +62,7 @@ public interface AuthorizationQueryMapper {
@Param("tenantId") long tenantId,
@Param("membershipId") long membershipId,
@Param("appCode") String appCode);
@Select("SELECT COALESCE(MAX(permission_version),0) FROM iam_security_subject_version WHERE auth_user_id=#{userId}")
long findPermissionVersion(@Param("userId") long userId);
}
@@ -1,22 +0,0 @@
package com.baiyue.iam.securityevent;
import jakarta.annotation.Resource;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.apache.rocketmq.spring.support.RocketMQHeaders;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
@ConditionalOnProperty(name="baiyue.security-event.publisher-enabled",havingValue="true")
public class SecurityEventOutboxRelay {
@Resource private SecurityEventRepository repository;
@Resource private RocketMQTemplate rocketMQ;
@Value("${baiyue.security-event.topic:baiyue-security-event-v1}") private String topic;
private final String owner=UUID.randomUUID().toString();
@Scheduled(fixedDelayString="${baiyue.security-event.poll-interval-ms:5000}")
public void relay(){for(String id:repository.pendingIds(50)){if(!repository.claim(id,owner))continue;var row=repository.find(id);try{rocketMQ.syncSend(topic+":"+row.eventType(),MessageBuilder.withPayload(row.payload()).setHeader(RocketMQHeaders.KEYS,row.eventId()).setHeader("eventId",row.eventId()).build(),3000);repository.sent(id,owner);}catch(RuntimeException ex){repository.failed(id,owner,row.attempts(),ex.getMessage());}}}
}
@@ -1,25 +0,0 @@
package com.baiyue.iam.securityevent;
import jakarta.annotation.Resource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class SecurityEventRepository {
@Resource private JdbcTemplate jdbc;
public long incrementPermissionVersion(long authUserId) {
jdbc.update("INSERT INTO iam_security_subject_version(auth_user_id,permission_version) VALUES (?,1) ON DUPLICATE KEY UPDATE permission_version=permission_version+1", authUserId);
return jdbc.queryForObject("SELECT permission_version FROM iam_security_subject_version WHERE auth_user_id=?", Long.class, authUserId);
}
public void append(String id,String type,String aggregateType,String aggregateId,String payload) {
jdbc.update("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json,status,attempt_count,next_retry_at) VALUES (?,?,?,?,CAST(? AS JSON),'PENDING',0,CURRENT_TIMESTAMP(3))", id,type,aggregateType,aggregateId,payload);
}
public List<String> pendingIds(int limit) { return jdbc.queryForList("SELECT event_id FROM iam_security_event_outbox WHERE (status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)) OR (status='SENDING' AND lease_until<=CURRENT_TIMESTAMP(3)) ORDER BY created_at LIMIT ?", String.class,limit); }
public boolean claim(String id,String owner) { return jdbc.update("UPDATE iam_security_event_outbox SET status='SENDING',lease_owner=?,lease_until=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL 60 SECOND),attempt_count=attempt_count+1 WHERE event_id=? AND ((status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)) OR (status='SENDING' AND lease_until<=CURRENT_TIMESTAMP(3)))",owner,id)==1; }
public Row find(String id) { return jdbc.queryForObject("SELECT event_id,event_type,payload_json,attempt_count FROM iam_security_event_outbox WHERE event_id=?",(rs,n)->new Row(rs.getString(1),rs.getString(2),rs.getString(3),rs.getInt(4)),id); }
public void sent(String id,String owner) { jdbc.update("UPDATE iam_security_event_outbox SET status='SENT',sent_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,lease_until=NULL,last_error=NULL WHERE event_id=? AND lease_owner=?",id,owner); }
public void failed(String id,String owner,int attempts,String error) { jdbc.update("UPDATE iam_security_event_outbox SET status=IF(attempt_count>=8,'DEAD','PENDING'),next_retry_at=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND),lease_owner=NULL,lease_until=NULL,last_error=? WHERE event_id=? AND lease_owner=?",Math.min(900,5*(1<<Math.min(attempts,7))),safe(error),id,owner); }
private String safe(String value) { if(value==null)return "投递失败"; return value.length()<=1000?value:value.substring(0,1000); }
public record Row(String eventId,String eventType,String payload,int attempts) { }
}
@@ -1,23 +0,0 @@
package com.baiyue.iam.securityevent;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
@Service
public class SecurityEventService {
public static final String USER_STATUS_CHANGED="IAM_USER_STATUS_CHANGED_V1";
public static final String ACCOUNT_STATUS_CHANGED="IAM_ACCOUNT_STATUS_CHANGED_V1";
public static final String PERMISSION_VERSION_CHANGED="IAM_PERMISSION_VERSION_CHANGED_V1";
@Resource private SecurityEventRepository repository;
@Resource private ObjectMapper json;
public void userStatus(long authUserId,long tenantId,long membershipId,String status){append(USER_STATUS_CHANGED,"USER",authUserId,Map.of("authUserId",authUserId,"tenantId",tenantId,"membershipId",membershipId,"status",status));}
public void accountStatus(long accountId,String status){append(ACCOUNT_STATUS_CHANGED,"ACCOUNT",accountId,Map.of("accountId",accountId,"status",status));}
public long permissionsChanged(long authUserId,long tenantId,long membershipId,String reason){long version=repository.incrementPermissionVersion(authUserId);append(PERMISSION_VERSION_CHANGED,"AUTHORIZATION",authUserId,Map.of("authUserId",authUserId,"tenantId",tenantId,"membershipId",membershipId,"permissionVersion",version,"reason",reason));return version;}
private void append(String type,String aggregateType,long aggregateId,Map<String,Object> fields){String id=UUID.randomUUID().toString();Map<String,Object> payload=new LinkedHashMap<>();payload.put("eventId",id);payload.put("eventType",type);payload.put("schemaVersion",1);payload.put("occurredAt",Instant.now().toString());payload.putAll(fields);try{repository.append(id,type,aggregateType,Long.toString(aggregateId),json.writeValueAsString(payload));}catch(JsonProcessingException ex){throw new IllegalStateException("安全事件序列化失败",ex);}}
}
@@ -46,7 +46,8 @@ public class AuthorizationSnapshotService {
Set<String> permissions = normalizedSet(mapper.findPermissions(
request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode));
return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(),
appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions));
appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions),
mapper.findPermissionVersion(request.getUserId()));
}
/**
@@ -40,6 +40,8 @@ public class MemberService {
private IamRoleMapper roleMapper;
@Resource
private AuthAccountClient authClient;
@Resource
private SecurityEventOutboxService securityEvents;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -159,6 +161,7 @@ public class MemberService {
membership.setStatus(request.status());
membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null);
user.setStatus(request.status()); repository.saveUser(user); repository.save(membership);
securityEvents.userStatus(user.getAuthUserId(), tenantId, "MERCHANT", request.status());
} catch (RuntimeException localFailure) {
try {
authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldStatus));
@@ -38,6 +38,8 @@ public class PlatformInternalUserService {
private IamRoleMapper roleMapper;
@Resource
private AuthAccountClient authClient;
@Resource
private SecurityEventOutboxService securityEvents;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -138,6 +140,7 @@ public class PlatformInternalUserService {
user.setStatus(request.status());
repository.saveUser(user);
repository.save(membership);
securityEvents.userStatus(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", request.status());
return response(membership, user, authAccount.username(), roleIds);
} catch (RuntimeException localFailure) {
compensateStatus(user.getAuthUserId(), authAccount.status(), localFailure);
@@ -167,6 +170,8 @@ public class PlatformInternalUserService {
repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds);
if (!new java.util.HashSet<>(currentRoleIds).equals(new java.util.HashSet<>(roleIds))) {
revokeSessions(user.getAuthUserId());
securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM",
"MEMBER_ROLES_CHANGED");
}
AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId());
return response(membership, user, authAccount.username(), roleIds);
@@ -43,6 +43,8 @@ public class PlatformUserManagementService {
private IamRoleMapper roleMapper;
@Resource
private AuthAccountClient authClient;
@Resource
private SecurityEventOutboxService securityEvents;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -185,6 +187,8 @@ public class PlatformUserManagementService {
user.setStatus(request.status());
members.saveUser(user);
members.save(membership);
securityEvents.userStatus(user.getAuthUserId(), membership.getTenantId(),
appCode(membership.getTenantId()), request.status());
} catch (RuntimeException localFailure) {
call(() -> authClient.status(internalToken, user.getAuthUserId(),
new AuthAccountClient.StatusRequest(oldAccount.status())));
@@ -33,6 +33,7 @@ public class RbacService {
@Resource private RbacRepository repository;
@Resource private MemberRepository memberRepository;
@Resource private AuthAccountClient authClient;
@Resource private SecurityEventOutboxService securityEvents;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -232,6 +233,7 @@ public class RbacService {
var user = memberRepository.findUser(membership.getUserId());
if (user == null || user.getAuthUserId() == null) throw sessionRevocationUnavailable();
revokeSessions(List.of(user.getAuthUserId()));
securityEvents.permissionChanged(user.getAuthUserId(), tenantId, appCode, "MEMBER_ROLES_CHANGED");
}
}
@@ -249,7 +251,10 @@ public class RbacService {
}
}
repository.replaceRolePermissions(tenantId, request.roleId(), request.permissionIds());
revokeAssignedSessions(tenantId, request.roleId());
List<Long> affected = repository.findAssignedAuthUserIds(tenantId, request.roleId());
revokeSessions(affected);
affected.forEach(userId -> securityEvents.permissionChanged(userId, tenantId, appCode,
"ROLE_PERMISSIONS_CHANGED"));
}
private void revokeAssignedSessions(long tenantId, Long roleId) {
@@ -0,0 +1,87 @@
package com.baiyue.iam.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventEnvelopeValidator;
import com.baiyue.iam.client.SecurityEventBridgeClient;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/** IAM 安全事件至少一次投递器,支持过期租约回收、退避重试和死信。 */
@Component
@Slf4j
public class SecurityEventOutboxRelay {
@Resource private JdbcTemplate jdbc;
@Resource private ObjectMapper objectMapper;
@Resource private SecurityEventBridgeClient client;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Value("${baiyue.security-event.max-attempts:12}") private int maxAttempts;
private final String workerId = UUID.randomUUID().toString();
@Scheduled(fixedDelayString = "${baiyue.security-event.fixed-delay-ms:5000}")
public void relay() {
recoverExpiredLeases();
for (String eventId : candidates()) deliver(eventId);
}
@Transactional
public void deliver(String eventId) {
LocalDateTime now = LocalDateTime.now();
int claimed = jdbc.update("UPDATE iam_security_event_outbox SET status='SENDING',lease_owner=?,"
+ "lease_until=?,updated_at=? WHERE event_id=? AND status='PENDING' AND next_retry_at<=?",
workerId, now.plusMinutes(2), now, eventId, now);
if (claimed != 1) return;
String json = jdbc.queryForObject("SELECT payload_json FROM iam_security_event_outbox "
+ "WHERE event_id=? AND status='SENDING' AND lease_owner=?", String.class, eventId, workerId);
try {
EventEnvelope envelope = objectMapper.readValue(json, EventEnvelope.class);
EventEnvelopeValidator.requireValid(envelope);
var response = client.receive(internalToken, envelope);
if (response == null || !response.successful() || response.data() == null
|| !eventId.equals(response.data().eventId())) {
throw new IllegalStateException("事件桥未确认 IAM 安全事件");
}
jdbc.update("UPDATE iam_security_event_outbox SET status='SENT',sent_at=?,lease_owner=NULL,"
+ "lease_until=NULL,last_error=NULL,updated_at=? WHERE event_id=? AND lease_owner=?",
LocalDateTime.now(), LocalDateTime.now(), eventId, workerId);
} catch (Exception exception) {
fail(eventId, exception.getMessage());
log.warn("IAM 安全事件投递失败,eventId={}, reason={}", eventId, exception.getMessage());
}
}
private List<String> candidates() {
return jdbc.queryForList("SELECT event_id FROM iam_security_event_outbox WHERE status='PENDING' "
+ "AND next_retry_at<=NOW(3) ORDER BY created_at,event_id LIMIT 50", String.class);
}
private void recoverExpiredLeases() {
jdbc.update("UPDATE iam_security_event_outbox SET status='PENDING',lease_owner=NULL,lease_until=NULL,"
+ "next_retry_at=NOW(3),updated_at=NOW(3) WHERE status='SENDING' AND lease_until<NOW(3)");
}
private void fail(String eventId, String message) {
Integer attempts = jdbc.queryForObject("SELECT attempt_count FROM iam_security_event_outbox WHERE event_id=?",
Integer.class, eventId);
int next = (attempts == null ? 0 : attempts) + 1;
String status = next >= maxAttempts ? "DEAD" : "PENDING";
long delay = Math.min(1800, 5L << Math.min(next, 8));
jdbc.update("UPDATE iam_security_event_outbox SET status=?,attempt_count=?,next_retry_at=?,"
+ "lease_owner=NULL,lease_until=NULL,last_error=?,updated_at=? WHERE event_id=? AND lease_owner=?",
status, next, LocalDateTime.now().plusSeconds(delay), truncate(message), LocalDateTime.now(), eventId, workerId);
}
private String truncate(String value) {
String text = value == null ? "unknown" : value.replaceAll("[\\r\\n\\t]+", " ");
return text.length() > 1000 ? text.substring(0, 1000) : text;
}
}
@@ -0,0 +1,67 @@
package com.baiyue.iam.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventIdentifiers;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.IamSecurityEventsV1;
import com.baiyue.common.event.RegionCode;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.time.Instant;
/** 在 IAM 状态事务内递增安全版本并写入可靠事件发送箱。 */
@Service
public class SecurityEventOutboxService {
@Resource private JdbcTemplate jdbc;
@Resource private ObjectMapper objectMapper;
public void userStatus(long authUserId, long tenantId, String appCode, String status) {
long version = bump(authUserId);
write(EventTypes.IAM_USER_STATUS_CHANGED, "USER", String.valueOf(authUserId), tenantId, version,
new IamSecurityEventsV1.UserStatusChanged(authUserId, tenantId, appCode, status, version, Instant.now()));
}
public void tenantStatus(long tenantId, String status) {
jdbc.update("INSERT INTO iam_security_tenant_version(tenant_id,status_version) VALUES (?,1) "
+ "ON DUPLICATE KEY UPDATE status_version=status_version+1", tenantId);
long version = jdbc.queryForObject(
"SELECT status_version FROM iam_security_tenant_version WHERE tenant_id=?", Long.class, tenantId);
write(EventTypes.IAM_TENANT_STATUS_CHANGED, "TENANT", String.valueOf(tenantId), tenantId, version,
new IamSecurityEventsV1.TenantStatusChanged(tenantId, status, Instant.now()));
}
public void permissionChanged(long authUserId, long tenantId, String appCode, String reason) {
long version = bump(authUserId);
write(EventTypes.IAM_PERMISSION_VERSION_CHANGED, "AUTHORIZATION", String.valueOf(authUserId), tenantId, version,
new IamSecurityEventsV1.PermissionVersionChanged(authUserId, tenantId, appCode, version, reason, Instant.now()));
}
private long bump(long authUserId) {
jdbc.update("INSERT INTO iam_security_subject_version(auth_user_id,permission_version) VALUES (?,1) "
+ "ON DUPLICATE KEY UPDATE permission_version=permission_version+1", authUserId);
return jdbc.queryForObject("SELECT permission_version FROM iam_security_subject_version WHERE auth_user_id=?",
Long.class, authUserId);
}
private void write(String type, String aggregateType, String aggregateId, long tenantId,
long aggregateVersion, Object value) {
JsonNode payload = objectMapper.valueToTree(value);
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload));
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), type, IamSecurityEventsV1.VERSION,
Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(tenantId), aggregateType,
aggregateId, aggregateVersion, null, EventIdentifiers.newUuid(), null, null, payload, hash).requireValid();
try {
jdbc.update("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) "
+ "VALUES (?,?,?,?,CAST(? AS JSON))", envelope.eventId(), type, aggregateType, aggregateId,
objectMapper.writeValueAsString(envelope));
} catch (Exception exception) {
throw new IllegalStateException("IAM 安全事件写入失败", exception);
}
}
}
@@ -66,6 +66,8 @@ public class TenantService {
private OverseasStorageFeeInitializationService overseasStorageFeeInitialization;
@Resource
private TenantAdministratorProvisioningService administratorProvisioning;
@Resource
private SecurityEventOutboxService securityEvents;
@Transactional
@BusinessAudit(event = "PLATFORM_CUSTOMER_OWNER_PASSWORD_RESET", resourceType = "TENANT",
@@ -380,6 +382,7 @@ public class TenantService {
}
tenant.setStatus(request.status());
repository.saveTenant(tenant);
securityEvents.tenantStatus(tenantId, request.status());
return response(tenant);
}
@@ -0,0 +1,25 @@
USE baiyue_iam;
-- 尾程订单权限属于平台端;列表权限使用 MENU,其余操作使用 BUTTON。
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5106000, 0, 'PLATFORM', 'MENU', 0, 'platform:lastleg:order:list', '尾程订单', '/lastleg/orders', 'GET', 60, 'ACTIVE', 1, 0),
(5106001, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:detail', '查看尾程订单详情', NULL, 'GET', 10, 'ACTIVE', 1, 0),
(5106002, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:assign', '分配尾程订单', NULL, 'POST', 20, 'ACTIVE', 1, 0),
(5106003, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:transfer', '转派尾程订单', NULL, 'POST', 30, 'ACTIVE', 1, 0),
(5106004, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:merge-pallet', '合并尾程托盘', NULL, 'POST', 40, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path),
http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, NOW(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0
AND permission.permission_code IN (
'platform:lastleg:order:list', 'platform:lastleg:order:detail',
'platform:lastleg:order:assign', 'platform:lastleg:order:transfer',
'platform:lastleg:order:merge-pallet');
@@ -2,20 +2,34 @@ USE baiyue_iam;
CREATE TABLE IF NOT EXISTS iam_security_subject_version (
auth_user_id BIGINT NOT NULL COMMENT '认证账号ID',
permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,角色或权限变化时单调递增',
permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,身份或授权变化时单调递增',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (auth_user_id)
) ENGINE=InnoDB COMMENT='安全主体权限版本';
CREATE TABLE IF NOT EXISTS iam_security_event_outbox (
event_id VARCHAR(64) NOT NULL COMMENT '全局事件ID,消费者幂等键', event_type VARCHAR(64) NOT NULL,
aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER、ACCOUNT、AUTHORIZATION', aggregate_id VARCHAR(64) NOT NULL,
payload_json JSON NOT NULL COMMENT '不含密码、令牌和权限全集的安全事件',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、SENDING、SENT、DEAD',
attempt_count INT NOT NULL DEFAULT 0, next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
lease_owner VARCHAR(128) NULL, lease_until DATETIME(3) NULL, last_error VARCHAR(1000) NULL,
sent_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CREATE TABLE IF NOT EXISTS iam_security_tenant_version (
tenant_id BIGINT NOT NULL COMMENT '客户ID',
status_version BIGINT NOT NULL DEFAULT 0 COMMENT '客户状态单调版本',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (event_id), KEY idx_security_outbox_publish (status, next_retry_at, lease_until, created_at),
KEY idx_security_outbox_aggregate (aggregate_type, aggregate_id, created_at)
PRIMARY KEY (tenant_id)
) ENGINE=InnoDB COMMENT='客户状态事件版本';
CREATE TABLE IF NOT EXISTS iam_security_event_outbox (
event_id VARCHAR(64) NOT NULL COMMENT '全局事件ID,消费者幂等键',
event_type VARCHAR(64) NOT NULL COMMENT '安全事件类型',
aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER、TENANT或AUTHORIZATION',
aggregate_id VARCHAR(64) NOT NULL COMMENT '聚合标识',
payload_json JSON NOT NULL COMMENT '标准跨区事件信封,不含密码、令牌和权限全集',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、SENDING、SENT、DEAD',
attempt_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
lease_owner VARCHAR(128) NULL,
lease_until DATETIME(3) NULL,
last_error VARCHAR(1000) NULL,
sent_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (event_id),
KEY idx_security_outbox_publish (status,next_retry_at,lease_until,created_at),
KEY idx_security_outbox_aggregate (aggregate_type,aggregate_id,created_at)
) ENGINE=InnoDB COMMENT='IAM可靠安全事件本地发送箱';
@@ -0,0 +1,43 @@
package com.baiyue.iam.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class SecurityEventSqlContractTest {
@Test
void securityOutboxHasVersionsAndReliableDeliveryColumns() throws Exception {
String sql = new ClassPathResource("db/security-event-outbox.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("iam_security_subject_version")
.contains("permission_version BIGINT NOT NULL DEFAULT 0")
.contains("iam_security_tenant_version")
.contains("status_version BIGINT NOT NULL DEFAULT 0")
.contains("iam_security_event_outbox")
.contains("payload_json JSON NOT NULL")
.contains("idx_security_outbox_publish")
.contains("PENDING", "SENDING", "SENT", "DEAD");
}
@Test
void unifiedMigrationIncludesSecurityEventSchemaAndAssertion() throws Exception {
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
Path migration = Files.exists(direct)
? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql").normalize();
String sql = Files.readString(migration, StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS iam_security_subject_version")
.contains("CREATE TABLE IF NOT EXISTS iam_security_tenant_version")
.contains("CREATE TABLE IF NOT EXISTS iam_security_event_outbox")
.contains("idx_security_outbox_publish", "idx_security_outbox_aggregate")
.contains("CALL assert_iam_security_event_schema();");
assertThat(sql.indexOf("CALL assert_iam_security_event_schema();"))
.isLessThan(sql.lastIndexOf("SELECT 'PASS' AS migration_status"));
}
}
@@ -33,6 +33,7 @@ class MemberServiceTest {
@MockBean private TenantRepository tenantRepository;
@MockBean private IamRoleMapper roleMapper;
@MockBean private AuthAccountClient auth;
@MockBean private SecurityEventOutboxService securityEvents;
@Resource private MemberService service;
private IamMembership member;
private IamUser user;
@@ -30,6 +30,7 @@ class PlatformInternalUserServiceTest {
@MockBean private MemberRepository repository;
@MockBean private IamRoleMapper roleMapper;
@MockBean private AuthAccountClient auth;
@MockBean private SecurityEventOutboxService securityEvents;
@Resource private PlatformInternalUserService service;
private IamMembership membership;
private IamUser user;
@@ -31,6 +31,7 @@ class PlatformUserManagementServiceTest {
@MockBean private MemberRepository members;
@MockBean private IamRoleMapper roles;
@MockBean private AuthAccountClient auth;
@MockBean private SecurityEventOutboxService securityEvents;
@Resource private PlatformUserManagementService service;
private IamRole platformRole;
private IamMembership membership;
@@ -39,6 +39,7 @@ class RbacServiceTest {
@MockBean private RbacRepository repository;
@MockBean private MemberRepository memberRepository;
@MockBean private AuthAccountClient authClient;
@MockBean private SecurityEventOutboxService securityEvents;
@Resource private RbacService service;
@Test
@@ -28,6 +28,7 @@ class TenantCustomerExportTest {
@MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization;
@MockBean private TenantAdministratorProvisioningService administratorProvisioning;
@MockBean private SecurityEventOutboxService securityEvents;
@Resource private TenantService service;
@Test
void returnsUnmaskedCustomerRowsAndAdvancingCursor() {
@@ -53,6 +53,7 @@ class TenantServiceTest {
@MockBean private MemberRepository members;
@MockBean private IamRoleMapper roles;
@MockBean private AuthAccountClient authClient;
@MockBean private SecurityEventOutboxService securityEvents;
@MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization;
@SpyBean private TenantAdministratorProvisioningService provisioning;