diff --git a/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentityProjectionService.java b/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentityProjectionService.java new file mode 100644 index 0000000..69f2865 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentityProjectionService.java @@ -0,0 +1,85 @@ +package com.yau.digitalrmb.identity.application; + +import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity; +import com.yau.digitalrmb.identity.infrastructure.persistence.entity.UserEntity; +import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.PlatformUserSnapshotMapper; +import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.UserMapper; +import com.yau.digitalrmb.platformintegration.domain.PlatformActor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +@Service +public class PlatformIdentityProjectionService { + private static final String EXTERNAL_SSO_ONLY_PASSWORD = "{noop}EXTERNAL_SSO_ONLY"; + + private final UserMapper userMapper; + private final PlatformUserSnapshotMapper snapshotMapper; + private final JdbcTemplate jdbcTemplate; + + public PlatformIdentityProjectionService(UserMapper userMapper, + PlatformUserSnapshotMapper snapshotMapper, + JdbcTemplate jdbcTemplate) { + this.userMapper = userMapper; + this.snapshotMapper = snapshotMapper; + this.jdbcTemplate = jdbcTemplate; + } + + @Transactional + public void project(PlatformActor actor) { + projectUser(actor); + projectSnapshot(actor); + projectRole(actor); + } + + private void projectUser(PlatformActor actor) { + UserEntity user = userMapper.selectById(actor.platformUserId()); + if (user == null) { + user = new UserEntity(); + user.setId(actor.platformUserId()); + user.setUsername(actor.account()); + user.setPasswordHash(EXTERNAL_SSO_ONLY_PASSWORD); + user.setEnabled(true); + userMapper.insert(user); + return; + } + user.setUsername(actor.account()); + user.setEnabled(true); + userMapper.updateById(user); + } + + private void projectSnapshot(PlatformActor actor) { + PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(actor.platformUserId()); + boolean newSnapshot = snapshot == null; + if (newSnapshot) { + snapshot = new PlatformUserSnapshotEntity(); + snapshot.setPlatformUserId(actor.platformUserId()); + } + snapshot.setAccount(actor.account()); + snapshot.setDisplayName(actor.displayName()); + snapshot.setRoleKey(actor.role().name()); + snapshot.setSourceUpdatedAt(LocalDateTime.ofInstant(actor.tokenSigningTime(), ZoneOffset.UTC)); + snapshot.setSyncedAt(LocalDateTime.now(ZoneOffset.UTC)); + if (newSnapshot) { + snapshotMapper.insert(snapshot); + } else { + snapshotMapper.updateById(snapshot); + } + } + + private void projectRole(PlatformActor actor) { + jdbcTemplate.update("DELETE FROM sys_user_role WHERE user_id = ?", actor.platformUserId()); + jdbcTemplate.update("INSERT INTO sys_user_role (user_id, role_id) VALUES (?, ?)", + actor.platformUserId(), roleId(actor)); + } + + private long roleId(PlatformActor actor) { + return switch (actor.role()) { + case TEACHER -> 1001L; + case STUDENT -> 1002L; + }; + } +} diff --git a/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/PlatformUserSnapshotEntity.java b/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/PlatformUserSnapshotEntity.java new file mode 100644 index 0000000..abd8913 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/PlatformUserSnapshotEntity.java @@ -0,0 +1,29 @@ +package com.yau.digitalrmb.identity.infrastructure.persistence.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("platform_user_snapshot") +public class PlatformUserSnapshotEntity { + @TableId(value = "platform_user_id", type = IdType.INPUT) + private Long platformUserId; + + private String account; + + private String displayName; + + private String roleKey; + + private LocalDateTime sourceUpdatedAt; + + @TableField("synced_at") + private LocalDateTime syncedAt; +} diff --git a/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/mapper/PlatformUserSnapshotMapper.java b/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/mapper/PlatformUserSnapshotMapper.java new file mode 100644 index 0000000..f61c46e --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/mapper/PlatformUserSnapshotMapper.java @@ -0,0 +1,9 @@ +package com.yau.digitalrmb.identity.infrastructure.persistence.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PlatformUserSnapshotMapper extends BaseMapper { +} diff --git a/src/main/resources/db/migration/V2__create_platform_identity_and_auth_tables.sql b/src/main/resources/db/migration/V2__create_platform_identity_and_auth_tables.sql new file mode 100644 index 0000000..582cc88 --- /dev/null +++ b/src/main/resources/db/migration/V2__create_platform_identity_and_auth_tables.sql @@ -0,0 +1,28 @@ +CREATE TABLE platform_user_snapshot ( + platform_user_id BIGINT PRIMARY KEY, + account VARCHAR(64) NOT NULL, + display_name VARCHAR(64) NOT NULL, + role_key VARCHAR(16) NOT NULL, + source_updated_at TIMESTAMP NOT NULL, + synced_at TIMESTAMP NOT NULL +); + +CREATE TABLE auth_login_exchange_code ( + code_hash CHAR(64) PRIMARY KEY, + platform_user_id BIGINT NOT NULL, + expires_at TIMESTAMP NOT NULL, + consumed_at TIMESTAMP NULL +); + +CREATE TABLE auth_refresh_token ( + token_hash CHAR(64) PRIMARY KEY, + platform_user_id BIGINT NOT NULL, + expires_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP NULL +); + +INSERT INTO sys_role (id, name, created_at, updated_at, created_by, updated_by, deleted) +VALUES (1001, 'TEACHER', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'SYSTEM', 'SYSTEM', FALSE); + +INSERT INTO sys_role (id, name, created_at, updated_at, created_by, updated_by, deleted) +VALUES (1002, 'STUDENT', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'SYSTEM', 'SYSTEM', FALSE); diff --git a/src/test/java/com/yau/digitalrmb/identity/PlatformIdentityProjectionServiceTest.java b/src/test/java/com/yau/digitalrmb/identity/PlatformIdentityProjectionServiceTest.java new file mode 100644 index 0000000..034ba88 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/identity/PlatformIdentityProjectionServiceTest.java @@ -0,0 +1,43 @@ +package com.yau.digitalrmb.identity; + +import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService; +import com.yau.digitalrmb.platformintegration.domain.PlatformActor; +import com.yau.digitalrmb.platformintegration.domain.PlatformRole; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import javax.sql.DataSource; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +class PlatformIdentityProjectionServiceTest { + @Autowired + private PlatformIdentityProjectionService projectionService; + + @Autowired + private DataSource dataSource; + + @Test + void projectionIsIdempotentAndOwnsExactlyOneRole() { + PlatformActor teacher = new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER, + Instant.parse("2026-01-01T00:00:00Z")); + + projectionService.project(teacher); + projectionService.project(teacher); + + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + Integer associationCount = jdbc.queryForObject("SELECT COUNT(*) FROM sys_user_role WHERE user_id = 101", Integer.class); + String role = jdbc.queryForObject("SELECT role_key FROM platform_user_snapshot WHERE platform_user_id = 101", String.class); + String userName = jdbc.queryForObject("SELECT username FROM sys_user WHERE id = 101", String.class); + + assertThat(associationCount).isEqualTo(1); + assertThat(role).isEqualTo("TEACHER"); + assertThat(userName).isEqualTo("t001"); + } +}