From c6f2a24293b9e4d1984de4f0b2cfd06ecd1e5e1a Mon Sep 17 00:00:00 2001 From: "jiazheng.zhao" <2624533894@qq.com> Date: Wed, 5 Aug 2026 08:58:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=8F=91=E8=A1=8C=E8=B4=A7=E5=B8=81?= =?UTF-8?q?=E7=9A=84=E5=8A=9F=E8=83=BD=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 18 +- .../yau/digitalrmb/DigitalRmbApplication.java | 3 + .../application/LocalSsoAccountService.java | 65 +++- .../digitalrmb/identity/domain/UserRole.java | 30 ++ .../entity/PlatformUserSnapshotEntity.java | 7 +- .../persistence/entity/RoleEntity.java | 13 - .../persistence/entity/UserEntity.java | 56 ++- .../persistence/mapper/RoleMapper.java | 9 - .../persistence/mapper/UserMapper.java | 13 + .../CurrencyGenerationRequestService.java | 167 +++++++++ .../CurrencyGenerationVerificationResult.java | 60 ++++ ...CurrencyGenerationVerificationService.java | 125 +++++++ .../application/CurrencyRequestResult.java | 64 ++++ ...itutionApplicationVerificationService.java | 21 +- .../InstitutionIdentifierStepService.java | 39 ++- .../application/InstitutionKeyPairResult.java | 4 +- .../application/InstitutionKeyService.java | 8 + .../application/InstitutionKeySubject.java | 19 +- .../InstitutionTrainingErrorRecorder.java | 19 +- ...suedInstitutionIdentifierQueryService.java | 28 ++ .../IssuedInstitutionIdentifierResult.java | 18 + .../domain/CurrencyGenerationRequest.java | 145 ++++++++ .../CurrencyGenerationRequestRepository.java | 12 + .../CurrencyGenerationVerification.java | 153 ++++++++ ...rencyGenerationVerificationRepository.java | 12 + .../InstitutionIdentifierRepository.java | 13 +- .../CurrencyGenerationRequestEntity.java | 30 ++ .../CurrencyGenerationRequestMapper.java | 8 + ...cyGenerationRequestOperationLogEntity.java | 24 ++ ...cyGenerationRequestOperationLogMapper.java | 9 + .../CurrencyGenerationVerificationEntity.java | 37 ++ .../CurrencyGenerationVerificationMapper.java | 8 + ...erationVerificationOperationLogEntity.java | 24 ++ ...erationVerificationOperationLogMapper.java | 9 + ...nstitutionIdentifierApplicationEntity.java | 3 + .../InstitutionSm2KeyAuditEntity.java | 2 +- .../InstitutionSm2KeyEntity.java | 2 +- ...isCurrencyGenerationRequestRepository.java | 116 ++++++ ...rencyGenerationVerificationRepository.java | 109 ++++++ ...ybatisInstitutionIdentifierRepository.java | 37 +- .../ConfirmInstitutionIdentifierRequest.java | 21 +- .../dto/CurrencyRequestStepRequest.java | 17 + .../dto/CurrencyVerificationStepRequest.java | 16 + .../dto/InstitutionIdentifierStepRequest.java | 23 +- .../dto/PrepareCurrencyRequest.java | 33 ++ .../ReceiveCurrencyVerificationRequest.java | 13 + .../VerifyInstitutionApplicationRequest.java | 21 +- .../CurrencyGenerationRequestController.java | 99 ++++++ ...rencyGenerationVerificationController.java | 85 +++++ .../rest/InstitutionIdentifierController.java | 105 +++--- .../application/CasTicketValidator.java | 96 ----- .../application/PlatformTokenVerifier.java | 55 ++- .../application/VerifiedPlatformToken.java | 19 +- .../JdbcPlatformIdentityRepository.java | 99 ------ .../PlatformReadOnlyDataSourceConfig.java | 36 -- .../CasAuthenticationController.java | 65 ---- .../interfaces/PlatformSsoController.java | 9 +- .../security/application/CurrentUser.java | 2 +- .../application/CurrentUserService.java | 39 +-- .../security/application/JwtTokenService.java | 50 ++- .../LocalAccountAuthenticationService.java | 57 ++- .../application/RefreshTokenService.java | 8 +- .../security/config/SecurityConfig.java | 1 + .../security/context/AuthContextHolder.java | 116 ++++++ .../digitalrmb/security/context/JwtUser.java | 79 +++++ .../security/interfaces/AuthController.java | 20 +- .../interfaces/CurrentUserResponse.java | 2 +- .../security/interfaces/LoginRequest.java | 32 +- .../security/interfaces/UserController.java | 101 ++++++ .../interfaces/UserLoginResponse.java | 28 ++ .../security/interfaces/UserLoginResult.java | 20 ++ src/main/resources/application-dev.yml | 6 +- src/main/resources/application.yml | 2 + .../institution_identifier_training.sql | 206 +++++++++++ src/main/resources/schema.sql | 146 +++----- .../identity/IdentityPersistenceTest.java | 19 +- .../identity/LocalSsoAccountServiceTest.java | 59 +++- ...rencyGenerationVerificationDomainTest.java | 58 +++ .../InstitutionIdentifierControllerTest.java | 331 ++++++++++++++++++ .../InstitutionIdentifierWorkflowTest.java | 106 ++++++ .../PlatformTokenVerifierTest.java | 48 ++- .../PlatformReadOnlyDataSourceConfigTest.java | 44 --- .../interfaces/PlatformSsoControllerTest.java | 14 +- .../security/AuthContextHolderTest.java | 130 +++++++ .../security/AuthControllerTest.java | 22 +- .../security/CurrentUserAndLogoutTest.java | 20 +- .../security/CurrentUserServiceTest.java | 26 +- .../security/UserControllerTest.java | 152 ++++++++ src/test/resources/application-test.yml | 31 ++ src/test/resources/schema.sql | 117 +++++++ 90 files changed, 3642 insertions(+), 801 deletions(-) create mode 100644 src/main/java/com/yau/digitalrmb/identity/domain/UserRole.java delete mode 100644 src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/RoleEntity.java delete mode 100644 src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/mapper/RoleMapper.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyGenerationRequestService.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyGenerationVerificationResult.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyGenerationVerificationService.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyRequestResult.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/application/IssuedInstitutionIdentifierQueryService.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/application/IssuedInstitutionIdentifierResult.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/domain/CurrencyGenerationRequest.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/domain/CurrencyGenerationRequestRepository.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/domain/CurrencyGenerationVerification.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/domain/CurrencyGenerationVerificationRepository.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationRequestEntity.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationRequestMapper.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationRequestOperationLogEntity.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationRequestOperationLogMapper.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationVerificationEntity.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationVerificationMapper.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationVerificationOperationLogEntity.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationVerificationOperationLogMapper.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/MybatisCurrencyGenerationRequestRepository.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/MybatisCurrencyGenerationVerificationRepository.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/CurrencyRequestStepRequest.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/CurrencyVerificationStepRequest.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/PrepareCurrencyRequest.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/ReceiveCurrencyVerificationRequest.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/rest/CurrencyGenerationRequestController.java create mode 100644 src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/rest/CurrencyGenerationVerificationController.java delete mode 100644 src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java delete mode 100644 src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java delete mode 100644 src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfig.java delete mode 100644 src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java create mode 100644 src/main/java/com/yau/digitalrmb/security/context/AuthContextHolder.java create mode 100644 src/main/java/com/yau/digitalrmb/security/context/JwtUser.java create mode 100644 src/main/java/com/yau/digitalrmb/security/interfaces/UserController.java create mode 100644 src/main/java/com/yau/digitalrmb/security/interfaces/UserLoginResponse.java create mode 100644 src/main/java/com/yau/digitalrmb/security/interfaces/UserLoginResult.java create mode 100644 src/main/resources/db/manual/institution_identifier_training.sql create mode 100644 src/test/java/com/yau/digitalrmb/institutionidentity/CurrencyGenerationVerificationDomainTest.java create mode 100644 src/test/java/com/yau/digitalrmb/institutionidentity/InstitutionIdentifierControllerTest.java create mode 100644 src/test/java/com/yau/digitalrmb/institutionidentity/InstitutionIdentifierWorkflowTest.java delete mode 100644 src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfigTest.java create mode 100644 src/test/java/com/yau/digitalrmb/security/AuthContextHolderTest.java create mode 100644 src/test/java/com/yau/digitalrmb/security/UserControllerTest.java create mode 100644 src/test/resources/application-test.yml create mode 100644 src/test/resources/schema.sql diff --git a/README.md b/README.md index 2ea4b1d..23586d6 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,16 @@ ## 登录方式 -- 主平台单点登录:主平台跳转至 `GET /api/v1/auth/sso?token=...`。后端以主平台教师或学生档案的 `add_time` 动态 HMAC 密钥验证 Token,生成一次性 `code` 并跳转至前端回调地址。 -- 独立访问:浏览器访问 `GET /api/v1/auth/cas/login`,跳转学校 CAS;回调校验成功后生成同样的一次性 `code`。 -- 本地账号密码:`POST /api/v1/auth/login` 仅校验本系统保存的 BCrypt 密码哈希。`schema.sql` 内置学生账号 `tzs001`;SSO/CAS 仅同步身份与角色,不读取主平台密码。 -- 会话兑换:`POST /api/v1/auth/session/exchange`,请求体为 `{"code":"..."}`,返回本系统访问 JWT 和刷新令牌。 +- 兼容综合实训平台登录:`POST /api/user/login`。 + - 账号密码登录参数:`username`、`passwordEncode`。 + - Token 单点登录参数:`TOKEN`。接口解析智云 HS256 Token,同步本地用户并直接返回本系统访问 Token。 +- 原有主平台跳转登录:`GET /api/v1/auth/sso?token=...`,验证成功后携带本系统 Token 跳转前端回调地址。 +- 本地账号密码:`POST /api/v1/auth/login` 请求体为 + `{"studentId":"tzs001","password":"123qwe"}`,按 `sys_user.student_id` + 查询并校验本系统保存的 BCrypt 密码哈希。旧字段 `username` 暂时作为兼容别名。 -本系统只接受教师和学生两类身份。平台账号与角色只能通过内部投影写入;没有用户、角色或用户角色的管理接口。 +Token 单点登录中的平台 `userId` 保存为 `sys_user.zy_user_id`;本地 +`sys_user.user_id` 始终使用 UUID 主键。本系统只接受管理员、教师和学生角色。 ## 必需配置 @@ -45,4 +49,6 @@ mvn spring-boot:run -Dspring-boot.run.profiles=local -Dspring-boot.run.arguments 本地 Swagger: -应用启动时会执行幂等的 `schema.sql`,创建缺失表并创建或更新 `tzs001`(平台用户 ID `487`、`STUDENT`)。脚本只保存 BCrypt 哈希,不含密码明文。开发环境可使用 `dev` 配置连接测试库:`mvn spring-boot:run -Dspring-boot.run.profiles=dev`。新 SSO/CAS 用户会获得随机 BCrypt 密码;如需为其开通本地密码登录,必须通过后续受控的本地账号初始化流程完成。 +测试环境会执行 `schema.sql`,创建或更新测试账号 `tzs001`。`dev` 配置的 +`spring.sql.init.mode` 为 `never`,不会自动修改真实 MySQL;开发库必须已有 +`sys_user` 记录,并且 `password` 必须保存 BCrypt 哈希,不能保存密码明文。 diff --git a/src/main/java/com/yau/digitalrmb/DigitalRmbApplication.java b/src/main/java/com/yau/digitalrmb/DigitalRmbApplication.java index 93d0329..e059a89 100644 --- a/src/main/java/com/yau/digitalrmb/DigitalRmbApplication.java +++ b/src/main/java/com/yau/digitalrmb/DigitalRmbApplication.java @@ -2,10 +2,13 @@ package com.yau.digitalrmb; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.scheduling.annotation.EnableScheduling; +import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; @SpringBootApplication @EnableScheduling +@EnableConfigurationProperties(PlatformIntegrationProperties.class) public class DigitalRmbApplication { public static void main(String[] args) { diff --git a/src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java b/src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java index f47ba21..2bb60c7 100644 --- a/src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java +++ b/src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java @@ -1,71 +1,89 @@ package com.yau.digitalrmb.identity.application; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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.application.VerifiedPlatformToken; -import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.UUID; @Service public class LocalSsoAccountService { private final UserMapper userMapper; private final PlatformUserSnapshotMapper snapshotMapper; - private final JdbcTemplate jdbcTemplate; private final PasswordEncoder passwordEncoder; public LocalSsoAccountService(UserMapper userMapper, PlatformUserSnapshotMapper snapshotMapper, - JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) { + PasswordEncoder passwordEncoder) { this.userMapper = userMapper; this.snapshotMapper = snapshotMapper; - this.jdbcTemplate = jdbcTemplate; this.passwordEncoder = passwordEncoder; } @Transactional - public void synchronize(VerifiedPlatformToken token) { - UserEntity user = userMapper.selectById(token.getUserId()); + public UserEntity synchronize(VerifiedPlatformToken token) { + String studentId = token.getStudentId(); + if (studentId == null || studentId.trim().isEmpty()) { + studentId = token.getUsername(); + } + UserEntity user = findExistingUser(token.getUserId(), studentId); boolean newUser = user == null; if (user == null) { user = new UserEntity(); - user.setId(token.getUserId()); + if (isUuid(token.getUserId())) { + user.setUserId(UUID.fromString(token.getUserId()).toString()); + } } - user.setUsername(token.getUsername()); - user.setPasswordHash(passwordEncoder.encode(token.getRawPassword())); - user.setEnabled(true); + String passwordHash = passwordEncoder.encode(token.getRawPassword()); + user.setStudentId(studentId); + user.setPassword(passwordHash); + user.setUserName(token.getDisplayName()); + user.setClassId(token.getClassId()); + user.setClassName(token.getClassName()); + user.setSchoolId(token.getSchoolId()); + user.setSchoolName(token.getSchoolName()); + user.setRoleId(token.getRoleId()); + user.setIsDeleted(false); + user.setZyUserId(token.getUserId()); + user.setCreateTime(user.getCreateTime() == null ? LocalDateTime.now() : user.getCreateTime()); if (newUser) { userMapper.insert(user); } else { userMapper.updateById(user); } - upsertSnapshot(token); - jdbcTemplate.update("DELETE FROM sys_user_role WHERE user_id = ?", token.getUserId()); - jdbcTemplate.update("INSERT INTO sys_user_role (user_id, role_id) VALUES (?, ?)", - token.getUserId(), "TEACHER".equals(token.getRoleKey()) ? 1001L : 1002L); + upsertSnapshot(user.getUserId(), token); + return user; + } + + private UserEntity findExistingUser(String platformUserId, String studentId) { + return userMapper.selectOne(new LambdaQueryWrapper() + .and(wrapper -> wrapper.eq(UserEntity::getZyUserId, platformUserId) + .or() + .eq(UserEntity::getStudentId, studentId)) + .last("LIMIT 1")); } - private void upsertSnapshot(VerifiedPlatformToken token) { - PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(token.getUserId()); + private void upsertSnapshot(String localUserId, VerifiedPlatformToken token) { + PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(localUserId); boolean newSnapshot = snapshot == null; if (snapshot == null) { snapshot = new PlatformUserSnapshotEntity(); - snapshot.setPlatformUserId(token.getUserId()); + snapshot.setPlatformUserId(localUserId); } snapshot.setAccount(token.getUsername()); snapshot.setDisplayName(token.getDisplayName()); - snapshot.setRoleKey(token.getRoleKey()); snapshot.setSchoolId(token.getSchoolId()); snapshot.setSchoolName(token.getSchoolName()); snapshot.setCollegeId(token.getCollegeId()); snapshot.setCollegeName(token.getCollegeName()); snapshot.setMajorId(token.getMajorId()); snapshot.setMajorName(token.getMajorName()); - snapshot.setRoleId(token.getRoleId()); snapshot.setClassId(token.getClassId()); snapshot.setClassName(token.getClassName()); snapshot.setStudentId(token.getStudentId()); @@ -77,4 +95,13 @@ public class LocalSsoAccountService { snapshotMapper.updateById(snapshot); } } + + private boolean isUuid(String value) { + try { + UUID.fromString(value); + return true; + } catch (IllegalArgumentException exception) { + return false; + } + } } diff --git a/src/main/java/com/yau/digitalrmb/identity/domain/UserRole.java b/src/main/java/com/yau/digitalrmb/identity/domain/UserRole.java new file mode 100644 index 0000000..c1b47ab --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/identity/domain/UserRole.java @@ -0,0 +1,30 @@ +package com.yau.digitalrmb.identity.domain; + +public enum UserRole { + ADMIN(1), + TEACHER(3), + STUDENT(4); + + private final int id; + + UserRole(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public String getAuthority() { + return name(); + } + + public static UserRole fromId(int id) { + for (UserRole role : values()) { + if (role.id == id) { + return role; + } + } + throw new IllegalArgumentException("Unsupported user role id: " + id); + } +} 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 index c320678..adc8ae8 100644 --- 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 @@ -14,14 +14,12 @@ import java.time.LocalDateTime; @TableName("platform_user_snapshot") public class PlatformUserSnapshotEntity { @TableId(value = "platform_user_id", type = IdType.INPUT) - private Long platformUserId; + private String platformUserId; private String account; private String displayName; - private String roleKey; - @TableField("school_id") private String schoolId; @@ -40,9 +38,6 @@ public class PlatformUserSnapshotEntity { @TableField("major_name") private String majorName; - @TableField("role_id") - private Long roleId; - @TableField("class_id") private String classId; diff --git a/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/RoleEntity.java b/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/RoleEntity.java deleted file mode 100644 index ed8ce43..0000000 --- a/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/RoleEntity.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.yau.digitalrmb.identity.infrastructure.persistence.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.yau.digitalrmb.shared.infrastructure.persistence.AuditableEntity; -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -@TableName("sys_role") -public class RoleEntity extends AuditableEntity { - private String name; -} diff --git a/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/UserEntity.java b/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/UserEntity.java index fbde89b..ae9a3f5 100644 --- a/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/UserEntity.java +++ b/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/UserEntity.java @@ -1,19 +1,63 @@ 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 com.yau.digitalrmb.shared.infrastructure.persistence.AuditableEntity; +import com.baomidou.mybatisplus.annotation.TableLogic; import lombok.Getter; import lombok.Setter; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + @Getter @Setter @TableName("sys_user") -public class UserEntity extends AuditableEntity { - private String username; +public class UserEntity { + @TableId(value = "user_id", type = IdType.INPUT) + private String userId = UUID.randomUUID().toString(); + + @TableField("student_id") + private String studentId; + + @TableField("password") + private String password; + + @TableField("user_name") + private String userName; + + @TableField("class_id") + private String classId; + + @TableField("class_name") + private String className; + + private String phone; + + @TableField("school_name") + private String schoolName; + + @TableField("school_id") + private String schoolId; + + @TableField("authorize_time") + private LocalDate authorizeTime; + + @TableField("authorize_end_time") + private LocalDate authorizeEndTime; + + @TableField("role_id") + private Integer roleId; + + @TableField("is_deleted") + @TableLogic(value = "0", delval = "1") + private Boolean isDeleted; - @TableField("password_hash") - private String passwordHash; + @TableField("zy_user_id") + private String zyUserId; - private Boolean enabled; + @TableField("create_time") + private LocalDateTime createTime; } diff --git a/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/mapper/RoleMapper.java b/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/mapper/RoleMapper.java deleted file mode 100644 index 433032b..0000000 --- a/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/mapper/RoleMapper.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.yau.digitalrmb.identity.infrastructure.persistence.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.yau.digitalrmb.identity.infrastructure.persistence.entity.RoleEntity; -import org.apache.ibatis.annotations.Mapper; - -@Mapper -public interface RoleMapper extends BaseMapper { -} diff --git a/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/mapper/UserMapper.java b/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/mapper/UserMapper.java index 4d5e01e..f9e1a32 100644 --- a/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/mapper/UserMapper.java +++ b/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/mapper/UserMapper.java @@ -3,7 +3,20 @@ package com.yau.digitalrmb.identity.infrastructure.persistence.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.yau.digitalrmb.identity.infrastructure.persistence.entity.UserEntity; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; @Mapper public interface UserMapper extends BaseMapper { + @Select("SELECT user_id, student_id, password, user_name, class_id, class_name, phone, " + + "school_name, school_id, authorize_time, authorize_end_time, role_id, " + + "create_time, is_deleted, zy_user_id FROM sys_user " + + "WHERE student_id = #{studentId} AND (is_deleted = 0 OR is_deleted IS NULL) LIMIT 1") + UserEntity selectActiveByStudentId(@Param("studentId") String studentId); + + @Update("UPDATE sys_user SET password = #{encodedPassword}, is_deleted = 0 " + + "WHERE user_id = #{userId} AND (is_deleted = 0 OR is_deleted IS NULL)") + int upgradeLegacyPassword(@Param("userId") String userId, + @Param("encodedPassword") String encodedPassword); } diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyGenerationRequestService.java b/src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyGenerationRequestService.java new file mode 100644 index 0000000..0d9e838 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyGenerationRequestService.java @@ -0,0 +1,167 @@ +package com.yau.digitalrmb.institutionidentity.application; + +import com.yau.digitalrmb.institutionidentity.domain.CurrencyGenerationRequest; +import com.yau.digitalrmb.institutionidentity.domain.CurrencyGenerationRequestRepository; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyService; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +import com.yau.digitalrmb.institutionidentity.application.IssuedInstitutionIdentifierQueryService; +import com.yau.digitalrmb.institutionidentity.application.IssuedInstitutionIdentifierResult; +import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Service +public class CurrencyGenerationRequestService { + private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + @Resource + private CurrencyGenerationRequestRepository repository; + @Resource + private IssuedInstitutionIdentifierQueryService identifierQueryService; + @Resource + private InstitutionIdentityCryptography cryptography; + @Resource + private InstitutionKeyService keyService; + + private final Clock clock = Clock.systemUTC(); + + @Transactional + public CurrencyRequestResult prepare(long identifierApplicationId, BigDecimal amount, String deliveryNodeCode, + InstitutionKeySubject subject, String operator) { + IssuedInstitutionIdentifierResult identifier = identifierQueryService.requireIssued(identifierApplicationId, + subject); + String timestamp = LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC).format(TIMESTAMP); + CurrencyGenerationRequest request = invokeDomainWithResult(() -> CurrencyGenerationRequest.prepare( + identifierApplicationId, identifier.getInstitutionIdentifier(), + identifier.getFullInstitutionIdentifier(), amount, deliveryNodeCode, timestamp)); + long requestId = repository.save(request, subject.getUserId(), subject.getSchoolId(), subject.getClassId(), + operator); + CurrencyGenerationRequest persisted = CurrencyGenerationRequest.restore(requestId, + request.getIdentifierApplicationId(), request.getInstitutionIdentifier(), + request.getFullInstitutionIdentifier(), request.getAmount(), request.getDeliveryNodeCode(), + request.getRequestTimestamp(), null, null, null, null, null, request.getStatus()); + return new CurrencyRequestResult(persisted); + } + + @Transactional + public CurrencyRequestResult concatenate(long requestId, InstitutionKeySubject subject, String operator) { + CurrencyGenerationRequest request = requireRequest(requestId, subject); + CurrencyGenerationRequest.Status expected = request.getStatus(); + invokeDomain(request::concatenate); + repository.update(request, expected, "CONCATENATE", "拼接机构标识、金额、投放节点和时间戳", + operator); + return new CurrencyRequestResult(request); + } + + @Transactional + public CurrencyRequestResult digest(long requestId, InstitutionKeySubject subject, String operator) { + CurrencyGenerationRequest request = requireRequest(requestId, subject); + CurrencyGenerationRequest.Status expected = request.getStatus(); + String digest = cryptography.sm3(requireOriginalText(request)); + invokeDomain(() -> request.recordDigest(digest)); + repository.update(request, expected, "DIGEST", "使用SM3计算并保存请求摘要", operator); + return new CurrencyRequestResult(request); + } + + @Transactional + public CurrencyRequestResult sign(long requestId, String keyId, InstitutionKeySubject subject, String operator) { + if (!InstitutionKeyService.BANK_SECOND_KEY.equals(keyId)) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "必须选择商业银行第二私钥完成请求签名"); + } + CurrencyGenerationRequest request = requireRequest(requestId, subject); + CurrencyGenerationRequest.Status expected = request.getStatus(); + String signature = keyService.signCommercialBank(subject, requireDigest(request)); + invokeDomain(() -> request.recordSignature(keyId, signature)); + repository.update(request, expected, "SIGN", "使用商业银行第二私钥完成SM2签名", operator); + return new CurrencyRequestResult(request); + } + + @Transactional + public CurrencyRequestResult packageMessage(long requestId, InstitutionKeySubject subject, String operator) { + CurrencyGenerationRequest request = requireRequest(requestId, subject); + CurrencyGenerationRequest.Status expected = request.getStatus(); + String message = "{\"originalText\":\"" + request.getRequestOriginalText() + + "\",\"digest\":\"" + request.getRequestDigest() + + "\",\"signature\":\"" + request.getBankSignature() + + "\",\"keyId\":\"" + request.getSigningKeyId() + "\"}"; + invokeDomain(() -> request.packageMessage(message)); + repository.update(request, expected, "PACKAGE", "组装包含原文、SM3摘要和SM2签名的标准请求报文", operator); + return new CurrencyRequestResult(request); + } + + @Transactional + public CurrencyRequestResult send(long requestId, InstitutionKeySubject subject, String operator) { + CurrencyGenerationRequest request = requireRequest(requestId, subject); + CurrencyGenerationRequest.Status expected = request.getStatus(); + invokeDomain(request::send); + repository.update(request, expected, "SEND", "商业银行发送货币生成请求至央行", operator); + + expected = request.getStatus(); + invokeDomain(request::receive); + repository.update(request, expected, "RECEIVE", "央行成功接收货币生成请求", operator); + return new CurrencyRequestResult(request); + } + + @Transactional(readOnly = true) + public CurrencyRequestResult detail(long requestId, InstitutionKeySubject subject) { + return new CurrencyRequestResult(requireRequest(requestId, subject)); + } + + @Transactional(readOnly = true) + public CurrencyGenerationRequest requireReceived(long requestId, InstitutionKeySubject subject) { + CurrencyGenerationRequest request = requireRequest(requestId, subject); + if (request.getStatus() != CurrencyGenerationRequest.Status.RECEIVED) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "步骤二尚未完成央行接收,不能进入步骤三"); + } + return request; + } + + private CurrencyGenerationRequest requireRequest(long requestId, InstitutionKeySubject subject) { + return repository.findByIdAndSubject(requestId, subject.getUserId(), subject.getSchoolId(), + subject.getClassId()).orElseThrow(() -> + new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "当前实训主体下的货币生成请求不存在")); + } + + private static String requireOriginalText(CurrencyGenerationRequest request) { + if (request.getRequestOriginalText() == null) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先完成请求原文拼接"); + } + return request.getRequestOriginalText(); + } + + private static String requireDigest(CurrencyGenerationRequest request) { + if (request.getRequestDigest() == null) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先完成SM3摘要计算"); + } + return request.getRequestDigest(); + } + + private static void invokeDomain(Runnable operation) { + try { + operation.run(); + } catch (IllegalArgumentException | IllegalStateException exception) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, exception.getMessage()); + } + } + + private static T invokeDomainWithResult(DomainSupplier supplier) { + try { + return supplier.get(); + } catch (IllegalArgumentException | IllegalStateException exception) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, exception.getMessage()); + } + } + + private interface DomainSupplier { + T get(); + } +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyGenerationVerificationResult.java b/src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyGenerationVerificationResult.java new file mode 100644 index 0000000..7926db8 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyGenerationVerificationResult.java @@ -0,0 +1,60 @@ +package com.yau.digitalrmb.institutionidentity.application; + +import com.yau.digitalrmb.institutionidentity.domain.CurrencyGenerationVerification; + +import java.math.BigDecimal; + +public class CurrencyGenerationVerificationResult { + private final Long verificationId; + private final long requestId; + private final String status; + private final String requestMessage; + private final String requestOriginalText; + private final String requestDigest; + private final String bankSignature; + private final String bankKeyId; + private final BigDecimal amount; + private final Boolean signatureValid; + private final String recomputedDigest; + private final Boolean digestMatches; + private final BigDecimal totalQuota; + private final BigDecimal usedQuota; + private final BigDecimal remainingQuota; + private final Boolean quotaSufficient; + private final String centralBankKeyId; + private final String centralBankSignature; + private final String confirmationTime; + private final String confirmationMessage; + + public CurrencyGenerationVerificationResult(CurrencyGenerationVerification v) { + verificationId = v.getId(); requestId = v.getRequestId(); status = v.getStatus().name(); + requestMessage = v.getRequestMessage(); requestOriginalText = v.getRequestOriginalText(); + requestDigest = v.getRequestDigest(); bankSignature = v.getBankSignature(); bankKeyId = v.getBankKeyId(); + amount = v.getAmount(); signatureValid = v.getSignatureValid(); recomputedDigest = v.getRecomputedDigest(); + digestMatches = v.getDigestMatches(); totalQuota = v.getTotalQuota(); usedQuota = v.getUsedQuota(); + remainingQuota = v.getRemainingQuota(); quotaSufficient = v.getQuotaSufficient(); + centralBankKeyId = v.getCentralBankKeyId(); centralBankSignature = v.getCentralBankSignature(); + confirmationTime = v.getConfirmationTime(); confirmationMessage = v.getConfirmationMessage(); + } + + public Long getVerificationId() { return verificationId; } + public long getRequestId() { return requestId; } + public String getStatus() { return status; } + public String getRequestMessage() { return requestMessage; } + public String getRequestOriginalText() { return requestOriginalText; } + public String getRequestDigest() { return requestDigest; } + public String getBankSignature() { return bankSignature; } + public String getBankKeyId() { return bankKeyId; } + public BigDecimal getAmount() { return amount; } + public Boolean getSignatureValid() { return signatureValid; } + public String getRecomputedDigest() { return recomputedDigest; } + public Boolean getDigestMatches() { return digestMatches; } + public BigDecimal getTotalQuota() { return totalQuota; } + public BigDecimal getUsedQuota() { return usedQuota; } + public BigDecimal getRemainingQuota() { return remainingQuota; } + public Boolean getQuotaSufficient() { return quotaSufficient; } + public String getCentralBankKeyId() { return centralBankKeyId; } + public String getCentralBankSignature() { return centralBankSignature; } + public String getConfirmationTime() { return confirmationTime; } + public String getConfirmationMessage() { return confirmationMessage; } +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyGenerationVerificationService.java b/src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyGenerationVerificationService.java new file mode 100644 index 0000000..64a8b85 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyGenerationVerificationService.java @@ -0,0 +1,125 @@ +package com.yau.digitalrmb.institutionidentity.application; + +import com.yau.digitalrmb.institutionidentity.domain.CurrencyGenerationRequest; +import com.yau.digitalrmb.institutionidentity.domain.CurrencyGenerationVerification; +import com.yau.digitalrmb.institutionidentity.domain.CurrencyGenerationVerificationRepository; +import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Service +public class CurrencyGenerationVerificationService { + private static final BigDecimal TOTAL_QUOTA = new BigDecimal("500000000.00"); + private static final BigDecimal USED_QUOTA = new BigDecimal("495000000.00"); + private static final BigDecimal REMAINING_QUOTA = new BigDecimal("5000000.00"); + private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + @Resource private CurrencyGenerationVerificationRepository repository; + @Resource private CurrencyGenerationRequestService requestService; + @Resource private InstitutionIdentityCryptography cryptography; + @Resource private InstitutionKeyService keyService; + + @Transactional + public CurrencyGenerationVerificationResult receive(long requestId, InstitutionKeySubject subject, String operator) { + CurrencyGenerationRequest request = requestService.requireReceived(requestId, subject); + CurrencyGenerationVerification value = invokeResult(() -> CurrencyGenerationVerification.receive(requestId, + request.getRequestMessage(), request.getRequestOriginalText(), request.getRequestDigest(), + request.getBankSignature(), request.getSigningKeyId(), request.getAmount())); + long id = repository.save(value, subject.getUserId(), subject.getSchoolId(), subject.getClassId(), operator); + return new CurrencyGenerationVerificationResult(CurrencyGenerationVerification.restore(id, requestId, + value.getRequestMessage(), value.getRequestOriginalText(), value.getRequestDigest(), + value.getBankSignature(), value.getBankKeyId(), value.getAmount(), null, null, null, + null, null, null, null, null, null, null, null, value.getStatus())); + } + + @Transactional + public CurrencyGenerationVerificationResult verifySignature(long id, String keyId, + InstitutionKeySubject subject, String operator) { + if (!InstitutionKeyService.BANK_SECOND_KEY.equals(keyId)) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "必须选择商业银行第二公钥进行SM2验签"); + } + CurrencyGenerationVerification value = require(id, subject); + CurrencyGenerationVerification.Status expected = value.getStatus(); + boolean valid = keyService.verifyCommercialBank(subject, value.getRequestDigest(), value.getBankSignature()); + invoke(() -> value.verifySignature(valid)); + repository.update(value, expected, "VERIFY_SIGNATURE", "使用商业银行第二公钥验证SM2签名", operator); + return new CurrencyGenerationVerificationResult(value); + } + + @Transactional + public CurrencyGenerationVerificationResult verifyDigest(long id, InstitutionKeySubject subject, String operator) { + CurrencyGenerationVerification value = require(id, subject); + CurrencyGenerationVerification.Status expected = value.getStatus(); + String digest = cryptography.sm3(value.getRequestOriginalText()); + invoke(() -> value.verifyDigest(digest, digest.equals(value.getRequestDigest()))); + repository.update(value, expected, "VERIFY_DIGEST", "重新计算SM3并与请求摘要比对", operator); + return new CurrencyGenerationVerificationResult(value); + } + + @Transactional + public CurrencyGenerationVerificationResult verifyQuota(long id, InstitutionKeySubject subject, String operator) { + CurrencyGenerationVerification value = require(id, subject); + CurrencyGenerationVerification.Status expected = value.getStatus(); + invoke(() -> value.verifyQuota(TOTAL_QUOTA, USED_QUOTA, REMAINING_QUOTA)); + repository.update(value, expected, "VERIFY_QUOTA", "检查申请总额度、已用额度和剩余额度", operator); + return new CurrencyGenerationVerificationResult(value); + } + + @Transactional + public CurrencyGenerationVerificationResult confirm(long id, String keyId, + InstitutionKeySubject subject, String operator) { + if (!InstitutionKeyService.CENTRAL_FIRST_KEY.equals(keyId)) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "必须选择中央银行第一私钥完成确认签名"); + } + CurrencyGenerationVerification value = require(id, subject); + CurrencyGenerationVerification.Status expected = value.getStatus(); + String signature = keyService.signCentralBank(subject, value.getRequestDigest()); + String time = LocalDateTime.now().format(TIME); + String message = "{\"requestId\":\"" + value.getRequestId() + "\",\"status\":\"CONFIRMED\"," + + "\"cbSignature\":\"" + signature + "\",\"confirmTime\":\"" + time + "\"}"; + invoke(() -> value.confirm(keyId, signature, time, message)); + repository.update(value, expected, "CONFIRM", "使用央行第一私钥签名并生成确认报文", operator); + return new CurrencyGenerationVerificationResult(value); + } + + @Transactional + public CurrencyGenerationVerificationResult returnResponse(long id, InstitutionKeySubject subject, String operator) { + CurrencyGenerationVerification value = require(id, subject); + CurrencyGenerationVerification.Status expected = value.getStatus(); + invoke(value::returnResponse); + repository.update(value, expected, "RETURN_RESPONSE", "向商业银行返回确认报文", operator); + return new CurrencyGenerationVerificationResult(value); + } + + @Transactional(readOnly = true) + public CurrencyGenerationVerificationResult detail(long id, InstitutionKeySubject subject) { + return new CurrencyGenerationVerificationResult(require(id, subject)); + } + + private CurrencyGenerationVerification require(long id, InstitutionKeySubject subject) { + return repository.findByIdAndSubject(id, subject.getUserId(), subject.getSchoolId(), subject.getClassId()) + .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, + "当前实训主体下的步骤三验证记录不存在")); + } + + private static void invoke(Runnable runnable) { + try { runnable.run(); } catch (IllegalArgumentException | IllegalStateException e) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, e.getMessage()); + } + } + + private static T invokeResult(Supplier supplier) { + try { return supplier.get(); } catch (IllegalArgumentException | IllegalStateException e) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, e.getMessage()); + } + } + + private interface Supplier { T get(); } +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyRequestResult.java b/src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyRequestResult.java new file mode 100644 index 0000000..7b8160a --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyRequestResult.java @@ -0,0 +1,64 @@ +package com.yau.digitalrmb.institutionidentity.application; + +import com.yau.digitalrmb.institutionidentity.domain.CurrencyGenerationRequest; + +import java.math.BigDecimal; + +public class CurrencyRequestResult { + private final long requestId; + private final long identifierApplicationId; + private final String status; + private final String institutionIdentifier; + private final String fullInstitutionIdentifier; + private final BigDecimal amount; + private final String deliveryNodeCode; + private final String requestTimestamp; + private final String requestOriginalText; + private final String digestAlgorithm; + private final String requestDigest; + private final String signatureAlgorithm; + private final String signatureHashAlgorithm; + private final String signingKeyId; + private final String bankSignature; + private final String requestMessage; + private final String centralBankReceiveStatus; + + public CurrencyRequestResult(CurrencyGenerationRequest request) { + this.requestId = request.getId(); + this.identifierApplicationId = request.getIdentifierApplicationId(); + this.status = request.getStatus().name(); + this.institutionIdentifier = request.getInstitutionIdentifier(); + this.fullInstitutionIdentifier = request.getFullInstitutionIdentifier(); + this.amount = request.getAmount(); + this.deliveryNodeCode = request.getDeliveryNodeCode(); + this.requestTimestamp = request.getRequestTimestamp(); + this.requestOriginalText = request.getRequestOriginalText(); + this.digestAlgorithm = request.getRequestDigest() == null ? null : "SM3"; + this.requestDigest = request.getRequestDigest(); + this.signatureAlgorithm = request.getBankSignature() == null ? null : "SM2_WITH_SM3"; + this.signatureHashAlgorithm = request.getBankSignature() == null ? null : "SM3"; + this.signingKeyId = request.getSigningKeyId(); + this.bankSignature = request.getBankSignature(); + this.requestMessage = request.getRequestMessage(); + this.centralBankReceiveStatus = request.getStatus() == CurrencyGenerationRequest.Status.RECEIVED + ? "RECEIVED" : "NOT_RECEIVED"; + } + + public long getRequestId() { return requestId; } + public long getIdentifierApplicationId() { return identifierApplicationId; } + public String getStatus() { return status; } + public String getInstitutionIdentifier() { return institutionIdentifier; } + public String getFullInstitutionIdentifier() { return fullInstitutionIdentifier; } + public BigDecimal getAmount() { return amount; } + public String getDeliveryNodeCode() { return deliveryNodeCode; } + public String getRequestTimestamp() { return requestTimestamp; } + public String getRequestOriginalText() { return requestOriginalText; } + public String getDigestAlgorithm() { return digestAlgorithm; } + public String getRequestDigest() { return requestDigest; } + public String getSignatureAlgorithm() { return signatureAlgorithm; } + public String getSignatureHashAlgorithm() { return signatureHashAlgorithm; } + public String getSigningKeyId() { return signingKeyId; } + public String getBankSignature() { return bankSignature; } + public String getRequestMessage() { return requestMessage; } + public String getCentralBankReceiveStatus() { return centralBankReceiveStatus; } +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionApplicationVerificationService.java b/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionApplicationVerificationService.java index 1dd07e7..4c8ec7d 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionApplicationVerificationService.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionApplicationVerificationService.java @@ -25,9 +25,11 @@ public class InstitutionApplicationVerificationService { private InstitutionTrainingErrorRecorder errorRecorder; @Transactional - public InstitutionApplicationVerificationResult verify(long applicationId, String verificationKeyId, + public InstitutionApplicationVerificationResult verify(String verificationKeyId, InstitutionKeySubject subject, String operator) { - InstitutionIdentifierApplication application = requireApplication(applicationId); + InstitutionIdentifierApplication application = requireCurrent(subject, + InstitutionIdentifierApplication.Status.PACKAGED, "当前没有待验证的本轮机构标识数据"); + long applicationId = application.getId(); if (!InstitutionKeyService.BANK_SECOND_KEY.equals(verificationKeyId)) { recordAndThrow(application, "WRONG_KEY", "VERIFY", verificationKeyId, "验证必须选择商业银行第二密钥的公钥", operator); @@ -50,9 +52,11 @@ public class InstitutionApplicationVerificationService { } @Transactional - public InstitutionIdentifierConfirmationResult confirm(long applicationId, String signingKeyId, + public InstitutionIdentifierConfirmationResult confirm(String signingKeyId, InstitutionKeySubject subject, String operator) { - InstitutionIdentifierApplication application = requireApplication(applicationId); + InstitutionIdentifierApplication application = requireCurrent(subject, + InstitutionIdentifierApplication.Status.VERIFIED, "当前没有待确权的本轮机构标识数据"); + long applicationId = application.getId(); if (!InstitutionKeyService.CENTRAL_FIRST_KEY.equals(signingKeyId)) { recordAndThrow(application, "WRONG_KEY", "CONFIRM", signingKeyId, "签名确权必须选择中央银行第一密钥", operator); @@ -70,9 +74,12 @@ public class InstitutionApplicationVerificationService { signingKeyId, centralBankSignature, identifier, digest); } - private InstitutionIdentifierApplication requireApplication(long applicationId) { - return repository.findById(applicationId).orElseThrow(() -> - new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "机构标识申请不存在")); + private InstitutionIdentifierApplication requireCurrent(InstitutionKeySubject subject, + InstitutionIdentifierApplication.Status status, + String message) { + return repository.findLatestBySubjectAndStatus(subject.getUserId(), subject.getSchoolId(), + subject.getClassId(), status).orElseThrow(() -> + new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, message)); } private void requireStatus(InstitutionIdentifierApplication application, diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionIdentifierStepService.java b/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionIdentifierStepService.java index 484b76b..339a86a 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionIdentifierStepService.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionIdentifierStepService.java @@ -33,32 +33,36 @@ public class InstitutionIdentifierStepService { private final Clock clock = Clock.systemUTC(); @Transactional - public InstitutionInformationPreparationResult prepare(String bankCode, String operator) { + public InstitutionInformationPreparationResult prepare(String bankCode, InstitutionKeySubject subject, + String operator) { String normalized = normalize(bankCode); int trainingRound = repository.nextTrainingRound(normalized, operator); String timestamp = LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC).format(TIMESTAMP); InstitutionIdentifierApplication application = InstitutionIdentifierApplication.prepare(normalized, timestamp); - long applicationId = repository.save(application, trainingRound, operator); + long applicationId = repository.save(application, trainingRound, subject.getUserId(), subject.getSchoolId(), + subject.getClassId(), operator); return new InstitutionInformationPreparationResult(applicationId, application.getStatus().name(), normalized, timestamp, normalized + "|" + timestamp, trainingRound, 0); } @Transactional - public InstitutionDigestResult digest(long applicationId, String operator) { - InstitutionIdentifierApplication application = requireApplication(applicationId); - requireStatus(application, InstitutionIdentifierApplication.Status.PREPARED, "当前步骤不是摘要计算,不能操作", operator); + public InstitutionDigestResult digest(String bankCode, InstitutionKeySubject subject, String operator) { + InstitutionInformationPreparationResult prepared = prepare(bankCode, subject, operator); + InstitutionIdentifierApplication application = requireCurrent(subject, + InstitutionIdentifierApplication.Status.PREPARED, "当前没有待计算摘要的本轮机构标识数据"); InstitutionIdentifierApplication.Status expectedStatus = application.getStatus(); String text = application.getBankCode() + "|" + application.getTimestamp(); String digest = cryptography.sm3(text); invokeDomain(() -> application.recordDigest(digest)); - repository.update(application, expectedStatus, "DIGEST", "使用SM3计算并保存业务摘要", operator); - return new InstitutionDigestResult(applicationId, application.getStatus().name(), "SM3", text, digest); + repository.update(application, expectedStatus, "DIGEST", "创建本轮记录并使用SM3计算业务摘要", operator); + return new InstitutionDigestResult(application.getId(), application.getStatus().name(), "SM3", text, digest); } @Transactional - public InstitutionSignatureResult sign(long applicationId, String keyId, InstitutionKeySubject subject, - String operator) { - InstitutionIdentifierApplication application = requireApplication(applicationId); + public InstitutionSignatureResult sign(String keyId, InstitutionKeySubject subject, String operator) { + InstitutionIdentifierApplication application = requireCurrent(subject, + InstitutionIdentifierApplication.Status.DIGESTED, "当前没有待签名的本轮机构标识数据"); + long applicationId = application.getId(); if (!InstitutionKeyService.BANK_SECOND_KEY.equals(keyId)) { recordAndThrow(application, "WRONG_KEY", "SIGN", keyId, "签名必须选择商业银行第二密钥", operator); @@ -74,8 +78,10 @@ public class InstitutionIdentifierStepService { } @Transactional - public InstitutionApplicationPackageResult packageApplication(long applicationId, String operator) { - InstitutionIdentifierApplication application = requireApplication(applicationId); + public InstitutionApplicationPackageResult packageApplication(InstitutionKeySubject subject, String operator) { + InstitutionIdentifierApplication application = requireCurrent(subject, + InstitutionIdentifierApplication.Status.SIGNED, "当前没有待组装的本轮机构标识数据"); + long applicationId = application.getId(); requireStatus(application, InstitutionIdentifierApplication.Status.SIGNED, "当前步骤不是申请组装,不能操作", operator); InstitutionIdentifierApplication.Status expectedStatus = application.getStatus(); invokeDomain(application::packageApplication); @@ -85,9 +91,12 @@ public class InstitutionIdentifierStepService { application.getBankSignature()); } - private InstitutionIdentifierApplication requireApplication(long applicationId) { - return repository.findById(applicationId).orElseThrow(() -> - new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "机构标识申请不存在")); + private InstitutionIdentifierApplication requireCurrent(InstitutionKeySubject subject, + InstitutionIdentifierApplication.Status status, + String message) { + return repository.findLatestBySubjectAndStatus(subject.getUserId(), subject.getSchoolId(), + subject.getClassId(), status).orElseThrow(() -> + new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, message)); } private void requireStatus(InstitutionIdentifierApplication application, diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionKeyPairResult.java b/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionKeyPairResult.java index cf02d54..ba2714c 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionKeyPairResult.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionKeyPairResult.java @@ -6,7 +6,7 @@ public class InstitutionKeyPairResult { private final String name; private final String owner; private final String purpose; - private final long userId; + private final String userId; private final long schoolId; private final long classId; private final String algorithm; @@ -43,7 +43,7 @@ public class InstitutionKeyPairResult { public String getName() { return name; } public String getOwner() { return owner; } public String getPurpose() { return purpose; } - public long getUserId() { return userId; } + public String getUserId() { return userId; } public long getSchoolId() { return schoolId; } public long getClassId() { return classId; } public String getAlgorithm() { return algorithm; } diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionKeyService.java b/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionKeyService.java index 6a18245..c7ffb1d 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionKeyService.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionKeyService.java @@ -43,6 +43,14 @@ public class InstitutionKeyService { "SIGN_IDENTIFIER", operator); } + public void commercialBankPublicKey(InstitutionKeySubject subject, String operator) { + commercialBankKey(subject, operator); + } + + public void centralBankPublicKey(InstitutionKeySubject subject, String operator) { + centralBankKey(subject, operator); + } + @Transactional(readOnly = true) public String signCommercialBank(InstitutionKeySubject subject, String message) { return cryptography.sign(privateKey(subject, BANK_SECOND_KEY), message); diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionKeySubject.java b/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionKeySubject.java index c1e6ae6..c7bcd76 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionKeySubject.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionKeySubject.java @@ -1,38 +1,37 @@ package com.yau.digitalrmb.institutionidentity.application; +import com.yau.digitalrmb.security.context.JwtUser; import com.yau.digitalrmb.shared.api.ErrorCode; import com.yau.digitalrmb.shared.exception.BusinessException; -import org.springframework.security.oauth2.jwt.Jwt; public class InstitutionKeySubject { - private final long userId; + private final String userId; private final long schoolId; private final long classId; - public InstitutionKeySubject(long userId, long schoolId, long classId) { + public InstitutionKeySubject(String userId, long schoolId, long classId) { this.userId = userId; this.schoolId = schoolId; this.classId = classId; } - public static InstitutionKeySubject from(Jwt jwt) { - return new InstitutionKeySubject(requiredLong(jwt, "userId"), requiredLong(jwt, "schoolId"), - requiredLong(jwt, "classId")); + public static InstitutionKeySubject from(JwtUser jwtUser) { + return new InstitutionKeySubject(jwtUser.getUserId(), requiredLong(jwtUser.getSchoolId(), "schoolId"), + requiredLong(jwtUser.getClassId(), "classId")); } - private static long requiredLong(Jwt jwt, String claim) { - Object value = jwt.getClaims().get(claim); + private static long requiredLong(String value, String claim) { if (value == null) { throw new BusinessException(ErrorCode.UNAUTHORIZED, "Token缺少" + claim); } try { - return Long.parseLong(String.valueOf(value)); + return Long.parseLong(value); } catch (NumberFormatException exception) { throw new BusinessException(ErrorCode.UNAUTHORIZED, "Token中的" + claim + "格式不正确"); } } - public long getUserId() { return userId; } + public String getUserId() { return userId; } public long getSchoolId() { return schoolId; } public long getClassId() { return classId; } } diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionTrainingErrorRecorder.java b/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionTrainingErrorRecorder.java index 692f967..787f112 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionTrainingErrorRecorder.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionTrainingErrorRecorder.java @@ -48,14 +48,21 @@ public class InstitutionTrainingErrorRecorder { error.setApplicationId(applicationId); error.setTrainingRound(application.getTrainingRound()); error.setErrorSequence(errorSequence); - error.setErrorType(errorType); - error.setOperationStep(operationStep); - error.setApplicationStatus(application.getStatus()); - error.setProvidedKeyId(providedKeyId); - error.setErrorMessage(errorMessage); + error.setErrorType(limit(errorType, 32)); + error.setOperationStep(limit(operationStep, 32)); + error.setApplicationStatus(limit(application.getStatus(), 32)); + error.setProvidedKeyId(limit(providedKeyId, 64)); + error.setErrorMessage(limit(errorMessage, 512)); error.setCreatedAt(LocalDateTime.now()); - error.setCreatedBy(operator); + error.setCreatedBy(limit(operator, 64)); errorMapper.insert(error); return errorSequence; } + + private String limit(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } } diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/application/IssuedInstitutionIdentifierQueryService.java b/src/main/java/com/yau/digitalrmb/institutionidentity/application/IssuedInstitutionIdentifierQueryService.java new file mode 100644 index 0000000..61ddb55 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/application/IssuedInstitutionIdentifierQueryService.java @@ -0,0 +1,28 @@ +package com.yau.digitalrmb.institutionidentity.application; + +import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentifierApplication; +import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentifierRepository; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.Resource; + +@Service +public class IssuedInstitutionIdentifierQueryService { + @Resource + private InstitutionIdentifierRepository repository; + + @Transactional(readOnly = true) + public IssuedInstitutionIdentifierResult requireIssued(long applicationId, InstitutionKeySubject subject) { + InstitutionIdentifierApplication application = repository.findById(applicationId).orElseThrow(() -> + new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "机构标识申请不存在")); + if (application.getStatus() != InstitutionIdentifierApplication.Status.ISSUED) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, + "任务一尚未签发机构标识,不能发起货币生成请求,当前状态:" + application.getStatus().name()); + } + return new IssuedInstitutionIdentifierResult(applicationId, application.getInstitutionIdentifier(), + application.getDigest()); + } +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/application/IssuedInstitutionIdentifierResult.java b/src/main/java/com/yau/digitalrmb/institutionidentity/application/IssuedInstitutionIdentifierResult.java new file mode 100644 index 0000000..01789eb --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/application/IssuedInstitutionIdentifierResult.java @@ -0,0 +1,18 @@ +package com.yau.digitalrmb.institutionidentity.application; + +public class IssuedInstitutionIdentifierResult { + private final long applicationId; + private final String institutionIdentifier; + private final String fullInstitutionIdentifier; + + public IssuedInstitutionIdentifierResult(long applicationId, String institutionIdentifier, + String fullInstitutionIdentifier) { + this.applicationId = applicationId; + this.institutionIdentifier = institutionIdentifier; + this.fullInstitutionIdentifier = fullInstitutionIdentifier; + } + + public long getApplicationId() { return applicationId; } + public String getInstitutionIdentifier() { return institutionIdentifier; } + public String getFullInstitutionIdentifier() { return fullInstitutionIdentifier; } +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/domain/CurrencyGenerationRequest.java b/src/main/java/com/yau/digitalrmb/institutionidentity/domain/CurrencyGenerationRequest.java new file mode 100644 index 0000000..111f495 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/domain/CurrencyGenerationRequest.java @@ -0,0 +1,145 @@ +package com.yau.digitalrmb.institutionidentity.domain; + +import java.math.BigDecimal; + +public class CurrencyGenerationRequest { + public enum Status { + PREPARED, CONCATENATED, DIGESTED, SIGNED, PACKAGED, SENT, RECEIVED + } + + private final Long id; + private final long identifierApplicationId; + private final String institutionIdentifier; + private final String fullInstitutionIdentifier; + private final BigDecimal amount; + private final String deliveryNodeCode; + private final String requestTimestamp; + private String requestOriginalText; + private String requestDigest; + private String bankSignature; + private String signingKeyId; + private String requestMessage; + private Status status; + + private CurrencyGenerationRequest(Long id, long identifierApplicationId, String institutionIdentifier, + String fullInstitutionIdentifier, BigDecimal amount, String deliveryNodeCode, + String requestTimestamp, String requestOriginalText, String requestDigest, + String bankSignature, String signingKeyId, String requestMessage, Status status) { + this.id = id; + this.identifierApplicationId = identifierApplicationId; + this.institutionIdentifier = institutionIdentifier; + this.fullInstitutionIdentifier = fullInstitutionIdentifier; + this.amount = amount; + this.deliveryNodeCode = deliveryNodeCode; + this.requestTimestamp = requestTimestamp; + this.requestOriginalText = requestOriginalText; + this.requestDigest = requestDigest; + this.bankSignature = bankSignature; + this.signingKeyId = signingKeyId; + this.requestMessage = requestMessage; + this.status = status; + } + + public static CurrencyGenerationRequest prepare(long identifierApplicationId, String institutionIdentifier, + String fullInstitutionIdentifier, BigDecimal amount, + String deliveryNodeCode, String requestTimestamp) { + if (identifierApplicationId <= 0) { + throw new IllegalArgumentException("机构标识申请编号必须大于0"); + } + if (institutionIdentifier == null || !institutionIdentifier.matches("ORG_[0-9A-F]{12}")) { + throw new IllegalArgumentException("简洁机构标识格式不正确"); + } + if (fullInstitutionIdentifier == null || !fullInstitutionIdentifier.matches("[0-9A-F]{64}")) { + throw new IllegalArgumentException("完整机构标识格式不正确"); + } + if (amount == null || amount.compareTo(new BigDecimal("0.00")) <= 0 + || amount.compareTo(new BigDecimal("50000.00")) > 0) { + throw new IllegalArgumentException("申请金额必须大于0且不超过50000.00元"); + } + if (!"SYS_DC_001".equals(deliveryNodeCode) && !"SYS_DC_002".equals(deliveryNodeCode)) { + throw new IllegalArgumentException("投放节点编号不在允许范围内"); + } + if (requestTimestamp == null || !requestTimestamp.matches("\\d{14}")) { + throw new IllegalArgumentException("请求时间戳格式不正确"); + } + return new CurrencyGenerationRequest(null, identifierApplicationId, institutionIdentifier, + fullInstitutionIdentifier, amount.setScale(2), deliveryNodeCode, requestTimestamp, + null, null, null, null, null, Status.PREPARED); + } + + public static CurrencyGenerationRequest restore(Long id, long identifierApplicationId, + String institutionIdentifier, String fullInstitutionIdentifier, + BigDecimal amount, String deliveryNodeCode, String requestTimestamp, + String requestOriginalText, String requestDigest, + String bankSignature, String signingKeyId, + String requestMessage, Status status) { + return new CurrencyGenerationRequest(id, identifierApplicationId, institutionIdentifier, + fullInstitutionIdentifier, amount, deliveryNodeCode, requestTimestamp, requestOriginalText, + requestDigest, bankSignature, signingKeyId, requestMessage, status); + } + + public void concatenate() { + requireStatus(Status.PREPARED, "当前步骤不是请求原文拼接,不能操作"); + requestOriginalText = institutionIdentifier + "|" + amount.toPlainString() + "|" + + deliveryNodeCode + "|" + requestTimestamp; + status = Status.CONCATENATED; + } + + public void recordDigest(String digest) { + requireStatus(Status.CONCATENATED, "当前步骤不是SM3摘要计算,不能操作"); + if (digest == null || !digest.matches("[0-9A-F]{64}")) { + throw new IllegalArgumentException("SM3摘要格式不正确"); + } + requestDigest = digest; + status = Status.DIGESTED; + } + + public void recordSignature(String keyId, String signature) { + requireStatus(Status.DIGESTED, "当前步骤不是商业银行签名,不能操作"); + if (keyId == null || signature == null || !signature.matches("[0-9A-F]+")) { + throw new IllegalArgumentException("SM2签名信息不正确"); + } + signingKeyId = keyId; + bankSignature = signature; + status = Status.SIGNED; + } + + public void packageMessage(String message) { + requireStatus(Status.SIGNED, "当前步骤不是请求报文组装,不能操作"); + if (message == null || message.trim().isEmpty()) { + throw new IllegalArgumentException("请求报文不能为空"); + } + requestMessage = message; + status = Status.PACKAGED; + } + + public void send() { + requireStatus(Status.PACKAGED, "当前步骤不是请求发送,不能操作"); + status = Status.SENT; + } + + public void receive() { + requireStatus(Status.SENT, "央行只能接收已发送的请求"); + status = Status.RECEIVED; + } + + private void requireStatus(Status expected, String message) { + if (status != expected) { + throw new IllegalStateException(message + ",当前状态:" + status.name()); + } + } + + public Long getId() { return id; } + public long getIdentifierApplicationId() { return identifierApplicationId; } + public String getInstitutionIdentifier() { return institutionIdentifier; } + public String getFullInstitutionIdentifier() { return fullInstitutionIdentifier; } + public BigDecimal getAmount() { return amount; } + public String getDeliveryNodeCode() { return deliveryNodeCode; } + public String getRequestTimestamp() { return requestTimestamp; } + public String getRequestOriginalText() { return requestOriginalText; } + public String getRequestDigest() { return requestDigest; } + public String getBankSignature() { return bankSignature; } + public String getSigningKeyId() { return signingKeyId; } + public String getRequestMessage() { return requestMessage; } + public Status getStatus() { return status; } +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/domain/CurrencyGenerationRequestRepository.java b/src/main/java/com/yau/digitalrmb/institutionidentity/domain/CurrencyGenerationRequestRepository.java new file mode 100644 index 0000000..08f0714 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/domain/CurrencyGenerationRequestRepository.java @@ -0,0 +1,12 @@ +package com.yau.digitalrmb.institutionidentity.domain; + +import java.util.Optional; + +public interface CurrencyGenerationRequestRepository { + long save(CurrencyGenerationRequest request, String userId, long schoolId, long classId, String operator); + + Optional findByIdAndSubject(long requestId, String userId, long schoolId, long classId); + + void update(CurrencyGenerationRequest request, CurrencyGenerationRequest.Status expectedStatus, + String operation, String operationDetail, String operator); +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/domain/CurrencyGenerationVerification.java b/src/main/java/com/yau/digitalrmb/institutionidentity/domain/CurrencyGenerationVerification.java new file mode 100644 index 0000000..4cef73c --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/domain/CurrencyGenerationVerification.java @@ -0,0 +1,153 @@ +package com.yau.digitalrmb.institutionidentity.domain; + +import java.math.BigDecimal; + +public class CurrencyGenerationVerification { + public enum Status { + RECEIVED, SIGNATURE_VERIFIED, DIGEST_VERIFIED, QUOTA_VERIFIED, CONFIRMED, RESPONSE_RETURNED + } + + private final Long id; + private final long requestId; + private final String requestMessage; + private final String requestOriginalText; + private final String requestDigest; + private final String bankSignature; + private final String bankKeyId; + private final BigDecimal amount; + private Boolean signatureValid; + private String recomputedDigest; + private Boolean digestMatches; + private BigDecimal totalQuota; + private BigDecimal usedQuota; + private BigDecimal remainingQuota; + private Boolean quotaSufficient; + private String centralBankKeyId; + private String centralBankSignature; + private String confirmationTime; + private String confirmationMessage; + private Status status; + + private CurrencyGenerationVerification(Long id, long requestId, String requestMessage, + String requestOriginalText, String requestDigest, + String bankSignature, String bankKeyId, BigDecimal amount, + Boolean signatureValid, String recomputedDigest, + Boolean digestMatches, BigDecimal totalQuota, + BigDecimal usedQuota, BigDecimal remainingQuota, + Boolean quotaSufficient, String centralBankKeyId, + String centralBankSignature, String confirmationTime, + String confirmationMessage, Status status) { + this.id = id; + this.requestId = requestId; + this.requestMessage = requestMessage; + this.requestOriginalText = requestOriginalText; + this.requestDigest = requestDigest; + this.bankSignature = bankSignature; + this.bankKeyId = bankKeyId; + this.amount = amount; + this.signatureValid = signatureValid; + this.recomputedDigest = recomputedDigest; + this.digestMatches = digestMatches; + this.totalQuota = totalQuota; + this.usedQuota = usedQuota; + this.remainingQuota = remainingQuota; + this.quotaSufficient = quotaSufficient; + this.centralBankKeyId = centralBankKeyId; + this.centralBankSignature = centralBankSignature; + this.confirmationTime = confirmationTime; + this.confirmationMessage = confirmationMessage; + this.status = status; + } + + public static CurrencyGenerationVerification receive(long requestId, String requestMessage, + String requestOriginalText, String requestDigest, + String bankSignature, String bankKeyId, + BigDecimal amount) { + if (requestId <= 0 || requestMessage == null || requestOriginalText == null + || requestDigest == null || bankSignature == null || bankKeyId == null || amount == null) { + throw new IllegalArgumentException("步骤二请求数据不完整,不能进入步骤三"); + } + return new CurrencyGenerationVerification(null, requestId, requestMessage, requestOriginalText, + requestDigest, bankSignature, bankKeyId, amount, null, null, null, null, null, null, + null, null, null, null, null, Status.RECEIVED); + } + + public static CurrencyGenerationVerification restore(Long id, long requestId, String requestMessage, + String requestOriginalText, String requestDigest, + String bankSignature, String bankKeyId, BigDecimal amount, + Boolean signatureValid, String recomputedDigest, + Boolean digestMatches, BigDecimal totalQuota, + BigDecimal usedQuota, BigDecimal remainingQuota, + Boolean quotaSufficient, String centralBankKeyId, + String centralBankSignature, String confirmationTime, + String confirmationMessage, Status status) { + return new CurrencyGenerationVerification(id, requestId, requestMessage, requestOriginalText, requestDigest, + bankSignature, bankKeyId, amount, signatureValid, recomputedDigest, digestMatches, totalQuota, + usedQuota, remainingQuota, quotaSufficient, centralBankKeyId, centralBankSignature, + confirmationTime, confirmationMessage, status); + } + + public void verifySignature(boolean valid) { + requireStatus(Status.RECEIVED, "请先接收步骤二请求报文"); + if (!valid) throw new IllegalArgumentException("商业银行SM2签名验证未通过"); + signatureValid = true; + status = Status.SIGNATURE_VERIFIED; + } + + public void verifyDigest(String digest, boolean matches) { + requireStatus(Status.SIGNATURE_VERIFIED, "请先完成商业银行SM2签名验证"); + if (!matches) throw new IllegalArgumentException("请求原文SM3完整性验证未通过"); + recomputedDigest = digest; + digestMatches = true; + status = Status.DIGEST_VERIFIED; + } + + public void verifyQuota(BigDecimal total, BigDecimal used, BigDecimal remaining) { + requireStatus(Status.DIGEST_VERIFIED, "请先完成请求SM3完整性验证"); + if (amount.compareTo(remaining) > 0) throw new IllegalArgumentException("本次申请金额超过剩余投放额度"); + totalQuota = total; + usedQuota = used; + remainingQuota = remaining; + quotaSufficient = true; + status = Status.QUOTA_VERIFIED; + } + + public void confirm(String keyId, String signature, String time, String message) { + requireStatus(Status.QUOTA_VERIFIED, "请先完成额度检查"); + centralBankKeyId = keyId; + centralBankSignature = signature; + confirmationTime = time; + confirmationMessage = message; + status = Status.CONFIRMED; + } + + public void returnResponse() { + requireStatus(Status.CONFIRMED, "请先完成央行签名确认"); + status = Status.RESPONSE_RETURNED; + } + + private void requireStatus(Status expected, String message) { + if (status != expected) throw new IllegalStateException(message + ",当前状态:" + status.name()); + } + + public Long getId() { return id; } + public long getRequestId() { return requestId; } + public String getRequestMessage() { return requestMessage; } + public String getRequestOriginalText() { return requestOriginalText; } + public String getRequestDigest() { return requestDigest; } + public String getBankSignature() { return bankSignature; } + public String getBankKeyId() { return bankKeyId; } + public BigDecimal getAmount() { return amount; } + public Boolean getSignatureValid() { return signatureValid; } + public String getRecomputedDigest() { return recomputedDigest; } + public Boolean getDigestMatches() { return digestMatches; } + public BigDecimal getTotalQuota() { return totalQuota; } + public BigDecimal getUsedQuota() { return usedQuota; } + public BigDecimal getRemainingQuota() { return remainingQuota; } + public Boolean getQuotaSufficient() { return quotaSufficient; } + public String getCentralBankKeyId() { return centralBankKeyId; } + public String getCentralBankSignature() { return centralBankSignature; } + public String getConfirmationTime() { return confirmationTime; } + public String getConfirmationMessage() { return confirmationMessage; } + public Status getStatus() { return status; } +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/domain/CurrencyGenerationVerificationRepository.java b/src/main/java/com/yau/digitalrmb/institutionidentity/domain/CurrencyGenerationVerificationRepository.java new file mode 100644 index 0000000..6ea33e8 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/domain/CurrencyGenerationVerificationRepository.java @@ -0,0 +1,12 @@ +package com.yau.digitalrmb.institutionidentity.domain; + +import java.util.Optional; + +public interface CurrencyGenerationVerificationRepository { + long save(CurrencyGenerationVerification verification, String userId, long schoolId, long classId, String operator); + Optional findByIdAndSubject(long verificationId, String userId, + long schoolId, long classId); + void update(CurrencyGenerationVerification verification, + CurrencyGenerationVerification.Status expectedStatus, + String operation, String detail, String operator); +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/domain/InstitutionIdentifierRepository.java b/src/main/java/com/yau/digitalrmb/institutionidentity/domain/InstitutionIdentifierRepository.java index 61a33c8..28ec90f 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/domain/InstitutionIdentifierRepository.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/domain/InstitutionIdentifierRepository.java @@ -3,10 +3,21 @@ package com.yau.digitalrmb.institutionidentity.domain; import java.util.Optional; public interface InstitutionIdentifierRepository { - long save(InstitutionIdentifierApplication application, int trainingRound, String operator); + long save(InstitutionIdentifierApplication application, int trainingRound, + String userId, long schoolId, long classId, String operator); Optional findById(long applicationId); + Optional findByIdAndSubject(long applicationId, + String userId, + long schoolId, + long classId); + + Optional findLatestBySubjectAndStatus(String userId, + long schoolId, + long classId, + InstitutionIdentifierApplication.Status status); + void update(InstitutionIdentifierApplication application, InstitutionIdentifierApplication.Status expectedStatus, String operation, diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationRequestEntity.java b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationRequestEntity.java new file mode 100644 index 0000000..83bed94 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationRequestEntity.java @@ -0,0 +1,30 @@ +package com.yau.digitalrmb.institutionidentity.infrastructure; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.yau.digitalrmb.shared.infrastructure.persistence.AuditableEntity; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +@Getter +@Setter +@TableName("currency_generation_request") +public class CurrencyGenerationRequestEntity extends AuditableEntity { + @TableField("identifier_application_id") private Long identifierApplicationId; + @TableField("institution_identifier") private String institutionIdentifier; + @TableField("full_institution_identifier") private String fullInstitutionIdentifier; + private BigDecimal amount; + @TableField("delivery_node_code") private String deliveryNodeCode; + @TableField("request_timestamp") private String requestTimestamp; + @TableField("request_original_text") private String requestOriginalText; + @TableField("request_digest") private String requestDigest; + @TableField("bank_signature") private String bankSignature; + @TableField("signing_key_id") private String signingKeyId; + @TableField("request_message") private String requestMessage; + private String status; + @TableField("user_id") private String userId; + @TableField("school_id") private Long schoolId; + @TableField("class_id") private Long classId; +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationRequestMapper.java b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationRequestMapper.java new file mode 100644 index 0000000..e2eb63c --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationRequestMapper.java @@ -0,0 +1,8 @@ +package com.yau.digitalrmb.institutionidentity.infrastructure; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CurrencyGenerationRequestMapper extends BaseMapper { +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationRequestOperationLogEntity.java b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationRequestOperationLogEntity.java new file mode 100644 index 0000000..5ce1986 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationRequestOperationLogEntity.java @@ -0,0 +1,24 @@ +package com.yau.digitalrmb.institutionidentity.infrastructure; + +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("currency_generation_request_operation_log") +public class CurrencyGenerationRequestOperationLogEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + @TableField("request_id") private Long requestId; + private String operation; + @TableField("from_status") private String fromStatus; + @TableField("to_status") private String toStatus; + @TableField("operation_detail") private String operationDetail; + @TableField("created_at") private LocalDateTime createdAt; + @TableField("created_by") private String createdBy; +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationRequestOperationLogMapper.java b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationRequestOperationLogMapper.java new file mode 100644 index 0000000..95e27ae --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationRequestOperationLogMapper.java @@ -0,0 +1,9 @@ +package com.yau.digitalrmb.institutionidentity.infrastructure; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CurrencyGenerationRequestOperationLogMapper + extends BaseMapper { +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationVerificationEntity.java b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationVerificationEntity.java new file mode 100644 index 0000000..4ce29e9 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationVerificationEntity.java @@ -0,0 +1,37 @@ +package com.yau.digitalrmb.institutionidentity.infrastructure; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.yau.digitalrmb.shared.infrastructure.persistence.AuditableEntity; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +@Getter +@Setter +@TableName("currency_generation_verification") +public class CurrencyGenerationVerificationEntity extends AuditableEntity { + @TableField("request_id") private Long requestId; + @TableField("request_message") private String requestMessage; + @TableField("request_original_text") private String requestOriginalText; + @TableField("request_digest") private String requestDigest; + @TableField("bank_signature") private String bankSignature; + @TableField("bank_key_id") private String bankKeyId; + private BigDecimal amount; + @TableField("signature_valid") private Boolean signatureValid; + @TableField("recomputed_digest") private String recomputedDigest; + @TableField("digest_matches") private Boolean digestMatches; + @TableField("total_quota") private BigDecimal totalQuota; + @TableField("used_quota") private BigDecimal usedQuota; + @TableField("remaining_quota") private BigDecimal remainingQuota; + @TableField("quota_sufficient") private Boolean quotaSufficient; + @TableField("central_bank_key_id") private String centralBankKeyId; + @TableField("central_bank_signature") private String centralBankSignature; + @TableField("confirmation_time") private String confirmationTime; + @TableField("confirmation_message") private String confirmationMessage; + private String status; + @TableField("user_id") private String userId; + @TableField("school_id") private Long schoolId; + @TableField("class_id") private Long classId; +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationVerificationMapper.java b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationVerificationMapper.java new file mode 100644 index 0000000..8d36e51 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationVerificationMapper.java @@ -0,0 +1,8 @@ +package com.yau.digitalrmb.institutionidentity.infrastructure; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CurrencyGenerationVerificationMapper extends BaseMapper { +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationVerificationOperationLogEntity.java b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationVerificationOperationLogEntity.java new file mode 100644 index 0000000..bf4c82b --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationVerificationOperationLogEntity.java @@ -0,0 +1,24 @@ +package com.yau.digitalrmb.institutionidentity.infrastructure; + +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("currency_generation_verification_operation_log") +public class CurrencyGenerationVerificationOperationLogEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + @TableField("verification_id") private Long verificationId; + private String operation; + @TableField("from_status") private String fromStatus; + @TableField("to_status") private String toStatus; + @TableField("operation_detail") private String operationDetail; + @TableField("created_at") private LocalDateTime createdAt; + @TableField("created_by") private String createdBy; +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationVerificationOperationLogMapper.java b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationVerificationOperationLogMapper.java new file mode 100644 index 0000000..ddb4708 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/CurrencyGenerationVerificationOperationLogMapper.java @@ -0,0 +1,9 @@ +package com.yau.digitalrmb.institutionidentity.infrastructure; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CurrencyGenerationVerificationOperationLogMapper + extends BaseMapper { +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/InstitutionIdentifierApplicationEntity.java b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/InstitutionIdentifierApplicationEntity.java index 43fc890..6be8cb0 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/InstitutionIdentifierApplicationEntity.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/InstitutionIdentifierApplicationEntity.java @@ -10,6 +10,9 @@ import lombok.Setter; @TableName("institution_identifier_application") public class InstitutionIdentifierApplicationEntity extends AuditableEntity { @TableField("bank_code") private String bankCode; + @TableField("user_id") private String userId; + @TableField("school_id") private Long schoolId; + @TableField("class_id") private Long classId; @TableField("request_timestamp") private String requestTimestamp; private String digest; @TableField("bank_signature") private String bankSignature; diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/InstitutionSm2KeyAuditEntity.java b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/InstitutionSm2KeyAuditEntity.java index 644278b..636fce0 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/InstitutionSm2KeyAuditEntity.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/InstitutionSm2KeyAuditEntity.java @@ -21,7 +21,7 @@ public class InstitutionSm2KeyAuditEntity { private String keyId; private String operation; @TableField("user_id") - private Long userId; + private String userId; @TableField("school_id") private Long schoolId; @TableField("class_id") diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/InstitutionSm2KeyEntity.java b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/InstitutionSm2KeyEntity.java index 1caaeda..7f74cd7 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/InstitutionSm2KeyEntity.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/InstitutionSm2KeyEntity.java @@ -19,7 +19,7 @@ public class InstitutionSm2KeyEntity extends AuditableEntity { @TableField("key_purpose") private String keyPurpose; @TableField("user_id") - private Long userId; + private String userId; @TableField("school_id") private Long schoolId; @TableField("class_id") diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/MybatisCurrencyGenerationRequestRepository.java b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/MybatisCurrencyGenerationRequestRepository.java new file mode 100644 index 0000000..f808f32 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/MybatisCurrencyGenerationRequestRepository.java @@ -0,0 +1,116 @@ +package com.yau.digitalrmb.institutionidentity.infrastructure; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.yau.digitalrmb.institutionidentity.domain.CurrencyGenerationRequest; +import com.yau.digitalrmb.institutionidentity.domain.CurrencyGenerationRequestRepository; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import org.springframework.stereotype.Repository; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.Optional; + +@Repository +public class MybatisCurrencyGenerationRequestRepository implements CurrencyGenerationRequestRepository { + @Resource + private CurrencyGenerationRequestMapper mapper; + + @Resource + private CurrencyGenerationRequestOperationLogMapper logMapper; + + @Override + public long save(CurrencyGenerationRequest request, String userId, long schoolId, long classId, String operator) { + CurrencyGenerationRequestEntity entity = toEntity(request); + entity.setUserId(userId); + entity.setSchoolId(schoolId); + entity.setClassId(classId); + LocalDateTime now = LocalDateTime.now(); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + entity.setCreatedBy(operator); + entity.setUpdatedBy(operator); + entity.setDeleted(false); + mapper.insert(entity); + insertLog(entity.getId(), "PREPARE", null, request.getStatus().name(), + "从任务一已签发申请复制简洁和完整机构标识,保存请求信息", operator); + return entity.getId(); + } + + @Override + public Optional findByIdAndSubject(long requestId, String userId, + long schoolId, long classId) { + CurrencyGenerationRequestEntity entity = mapper.selectOne( + new LambdaQueryWrapper() + .eq(CurrencyGenerationRequestEntity::getId, requestId) + .eq(CurrencyGenerationRequestEntity::getUserId, userId) + .eq(CurrencyGenerationRequestEntity::getSchoolId, schoolId) + .eq(CurrencyGenerationRequestEntity::getClassId, classId) + .eq(CurrencyGenerationRequestEntity::getDeleted, false)); + return Optional.ofNullable(entity).map(this::toDomain); + } + + @Override + public void update(CurrencyGenerationRequest request, CurrencyGenerationRequest.Status expectedStatus, + String operation, String operationDetail, String operator) { + LambdaUpdateWrapper update = + new LambdaUpdateWrapper() + .eq(CurrencyGenerationRequestEntity::getId, request.getId()) + .eq(CurrencyGenerationRequestEntity::getStatus, expectedStatus.name()) + .eq(CurrencyGenerationRequestEntity::getDeleted, false) + .set(CurrencyGenerationRequestEntity::getRequestOriginalText, + request.getRequestOriginalText()) + .set(CurrencyGenerationRequestEntity::getRequestDigest, request.getRequestDigest()) + .set(CurrencyGenerationRequestEntity::getBankSignature, request.getBankSignature()) + .set(CurrencyGenerationRequestEntity::getSigningKeyId, request.getSigningKeyId()) + .set(CurrencyGenerationRequestEntity::getRequestMessage, request.getRequestMessage()) + .set(CurrencyGenerationRequestEntity::getStatus, request.getStatus().name()) + .set(CurrencyGenerationRequestEntity::getUpdatedAt, LocalDateTime.now()) + .set(CurrencyGenerationRequestEntity::getUpdatedBy, operator); + if (mapper.update(null, update) != 1) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请求状态已变化,不能重复或越级操作"); + } + insertLog(request.getId(), operation, expectedStatus.name(), request.getStatus().name(), + operationDetail, operator); + } + + private CurrencyGenerationRequestEntity toEntity(CurrencyGenerationRequest request) { + CurrencyGenerationRequestEntity entity = new CurrencyGenerationRequestEntity(); + entity.setId(request.getId()); + entity.setIdentifierApplicationId(request.getIdentifierApplicationId()); + entity.setInstitutionIdentifier(request.getInstitutionIdentifier()); + entity.setFullInstitutionIdentifier(request.getFullInstitutionIdentifier()); + entity.setAmount(request.getAmount()); + entity.setDeliveryNodeCode(request.getDeliveryNodeCode()); + entity.setRequestTimestamp(request.getRequestTimestamp()); + entity.setRequestOriginalText(request.getRequestOriginalText()); + entity.setRequestDigest(request.getRequestDigest()); + entity.setBankSignature(request.getBankSignature()); + entity.setSigningKeyId(request.getSigningKeyId()); + entity.setRequestMessage(request.getRequestMessage()); + entity.setStatus(request.getStatus().name()); + return entity; + } + + private CurrencyGenerationRequest toDomain(CurrencyGenerationRequestEntity entity) { + return CurrencyGenerationRequest.restore(entity.getId(), entity.getIdentifierApplicationId(), + entity.getInstitutionIdentifier(), entity.getFullInstitutionIdentifier(), entity.getAmount(), + entity.getDeliveryNodeCode(), entity.getRequestTimestamp(), entity.getRequestOriginalText(), + entity.getRequestDigest(), entity.getBankSignature(), entity.getSigningKeyId(), + entity.getRequestMessage(), CurrencyGenerationRequest.Status.valueOf(entity.getStatus())); + } + + private void insertLog(Long requestId, String operation, String fromStatus, String toStatus, + String operationDetail, String operator) { + CurrencyGenerationRequestOperationLogEntity log = new CurrencyGenerationRequestOperationLogEntity(); + log.setRequestId(requestId); + log.setOperation(operation); + log.setFromStatus(fromStatus); + log.setToStatus(toStatus); + log.setOperationDetail(operationDetail); + log.setCreatedAt(LocalDateTime.now()); + log.setCreatedBy(operator); + logMapper.insert(log); + } +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/MybatisCurrencyGenerationVerificationRepository.java b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/MybatisCurrencyGenerationVerificationRepository.java new file mode 100644 index 0000000..ba9407e --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/MybatisCurrencyGenerationVerificationRepository.java @@ -0,0 +1,109 @@ +package com.yau.digitalrmb.institutionidentity.infrastructure; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.yau.digitalrmb.institutionidentity.domain.CurrencyGenerationVerification; +import com.yau.digitalrmb.institutionidentity.domain.CurrencyGenerationVerificationRepository; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import org.springframework.stereotype.Repository; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.Optional; + +@Repository +public class MybatisCurrencyGenerationVerificationRepository implements CurrencyGenerationVerificationRepository { + @Resource private CurrencyGenerationVerificationMapper mapper; + @Resource private CurrencyGenerationVerificationOperationLogMapper logMapper; + + @Override + public long save(CurrencyGenerationVerification value, String userId, long schoolId, long classId, String operator) { + CurrencyGenerationVerificationEntity entity = toEntity(value); + entity.setUserId(userId); + entity.setSchoolId(schoolId); + entity.setClassId(classId); + LocalDateTime now = LocalDateTime.now(); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + entity.setCreatedBy(operator); + entity.setUpdatedBy(operator); + entity.setDeleted(false); + mapper.insert(entity); + log(entity.getId(), "RECEIVE", null, value.getStatus().name(), "接收步骤二货币生成请求报文", operator); + return entity.getId(); + } + + @Override + public Optional findByIdAndSubject(long id, String userId, + long schoolId, long classId) { + CurrencyGenerationVerificationEntity entity = mapper.selectOne( + new LambdaQueryWrapper() + .eq(CurrencyGenerationVerificationEntity::getId, id) + .eq(CurrencyGenerationVerificationEntity::getUserId, userId) + .eq(CurrencyGenerationVerificationEntity::getSchoolId, schoolId) + .eq(CurrencyGenerationVerificationEntity::getClassId, classId) + .eq(CurrencyGenerationVerificationEntity::getDeleted, false)); + return Optional.ofNullable(entity).map(this::toDomain); + } + + @Override + public void update(CurrencyGenerationVerification value, + CurrencyGenerationVerification.Status expectedStatus, + String operation, String detail, String operator) { + LambdaUpdateWrapper update = + new LambdaUpdateWrapper() + .eq(CurrencyGenerationVerificationEntity::getId, value.getId()) + .eq(CurrencyGenerationVerificationEntity::getStatus, expectedStatus.name()) + .eq(CurrencyGenerationVerificationEntity::getDeleted, false) + .set(CurrencyGenerationVerificationEntity::getSignatureValid, value.getSignatureValid()) + .set(CurrencyGenerationVerificationEntity::getRecomputedDigest, value.getRecomputedDigest()) + .set(CurrencyGenerationVerificationEntity::getDigestMatches, value.getDigestMatches()) + .set(CurrencyGenerationVerificationEntity::getTotalQuota, value.getTotalQuota()) + .set(CurrencyGenerationVerificationEntity::getUsedQuota, value.getUsedQuota()) + .set(CurrencyGenerationVerificationEntity::getRemainingQuota, value.getRemainingQuota()) + .set(CurrencyGenerationVerificationEntity::getQuotaSufficient, value.getQuotaSufficient()) + .set(CurrencyGenerationVerificationEntity::getCentralBankKeyId, value.getCentralBankKeyId()) + .set(CurrencyGenerationVerificationEntity::getCentralBankSignature, + value.getCentralBankSignature()) + .set(CurrencyGenerationVerificationEntity::getConfirmationTime, value.getConfirmationTime()) + .set(CurrencyGenerationVerificationEntity::getConfirmationMessage, + value.getConfirmationMessage()) + .set(CurrencyGenerationVerificationEntity::getStatus, value.getStatus().name()) + .set(CurrencyGenerationVerificationEntity::getUpdatedAt, LocalDateTime.now()) + .set(CurrencyGenerationVerificationEntity::getUpdatedBy, operator); + if (mapper.update(null, update) != 1) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "验证状态已变化,不能重复或越级操作"); + } + log(value.getId(), operation, expectedStatus.name(), value.getStatus().name(), detail, operator); + } + + private CurrencyGenerationVerificationEntity toEntity(CurrencyGenerationVerification value) { + CurrencyGenerationVerificationEntity e = new CurrencyGenerationVerificationEntity(); + e.setId(value.getId()); e.setRequestId(value.getRequestId()); e.setRequestMessage(value.getRequestMessage()); + e.setRequestOriginalText(value.getRequestOriginalText()); e.setRequestDigest(value.getRequestDigest()); + e.setBankSignature(value.getBankSignature()); e.setBankKeyId(value.getBankKeyId()); e.setAmount(value.getAmount()); + e.setSignatureValid(value.getSignatureValid()); e.setRecomputedDigest(value.getRecomputedDigest()); + e.setDigestMatches(value.getDigestMatches()); e.setTotalQuota(value.getTotalQuota()); + e.setUsedQuota(value.getUsedQuota()); e.setRemainingQuota(value.getRemainingQuota()); + e.setQuotaSufficient(value.getQuotaSufficient()); e.setCentralBankKeyId(value.getCentralBankKeyId()); + e.setCentralBankSignature(value.getCentralBankSignature()); e.setConfirmationTime(value.getConfirmationTime()); + e.setConfirmationMessage(value.getConfirmationMessage()); e.setStatus(value.getStatus().name()); + return e; + } + + private CurrencyGenerationVerification toDomain(CurrencyGenerationVerificationEntity e) { + return CurrencyGenerationVerification.restore(e.getId(), e.getRequestId(), e.getRequestMessage(), + e.getRequestOriginalText(), e.getRequestDigest(), e.getBankSignature(), e.getBankKeyId(), e.getAmount(), + e.getSignatureValid(), e.getRecomputedDigest(), e.getDigestMatches(), e.getTotalQuota(), e.getUsedQuota(), + e.getRemainingQuota(), e.getQuotaSufficient(), e.getCentralBankKeyId(), e.getCentralBankSignature(), + e.getConfirmationTime(), e.getConfirmationMessage(), + CurrencyGenerationVerification.Status.valueOf(e.getStatus())); + } + + private void log(Long id, String operation, String from, String to, String detail, String operator) { + CurrencyGenerationVerificationOperationLogEntity e = new CurrencyGenerationVerificationOperationLogEntity(); + e.setVerificationId(id); e.setOperation(operation); e.setFromStatus(from); e.setToStatus(to); + e.setOperationDetail(detail); e.setCreatedAt(LocalDateTime.now()); e.setCreatedBy(operator); logMapper.insert(e); + } +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/MybatisInstitutionIdentifierRepository.java b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/MybatisInstitutionIdentifierRepository.java index 6faed36..ace759d 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/MybatisInstitutionIdentifierRepository.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/MybatisInstitutionIdentifierRepository.java @@ -21,9 +21,13 @@ public class MybatisInstitutionIdentifierRepository implements InstitutionIdenti private InstitutionIdentifierOperationLogMapper operationLogMapper; @Override - public long save(InstitutionIdentifierApplication application, int trainingRound, String operator) { + public long save(InstitutionIdentifierApplication application, int trainingRound, + String userId, long schoolId, long classId, String operator) { InstitutionIdentifierApplicationEntity entity = toEntity(application); entity.setTrainingRound(trainingRound); + entity.setUserId(userId); + entity.setSchoolId(schoolId); + entity.setClassId(classId); entity.setScoringCriteria(0); LocalDateTime now = LocalDateTime.now(); entity.setCreatedAt(now); @@ -46,6 +50,37 @@ public class MybatisInstitutionIdentifierRepository implements InstitutionIdenti return Optional.ofNullable(entity).map(this::toDomain); } + @Override + public Optional findByIdAndSubject(long applicationId, + String userId, + long schoolId, + long classId) { + InstitutionIdentifierApplicationEntity entity = mapper.selectOne( + new LambdaQueryWrapper() + .eq(InstitutionIdentifierApplicationEntity::getId, applicationId) + .eq(InstitutionIdentifierApplicationEntity::getUserId, userId) + .eq(InstitutionIdentifierApplicationEntity::getSchoolId, schoolId) + .eq(InstitutionIdentifierApplicationEntity::getClassId, classId) + .eq(InstitutionIdentifierApplicationEntity::getDeleted, false)); + return Optional.ofNullable(entity).map(this::toDomain); + } + + @Override + public Optional findLatestBySubjectAndStatus( + String userId, long schoolId, long classId, InstitutionIdentifierApplication.Status status) { + InstitutionIdentifierApplicationEntity entity = mapper.selectOne( + new LambdaQueryWrapper() + .eq(InstitutionIdentifierApplicationEntity::getUserId, userId) + .eq(InstitutionIdentifierApplicationEntity::getSchoolId, schoolId) + .eq(InstitutionIdentifierApplicationEntity::getClassId, classId) + .eq(InstitutionIdentifierApplicationEntity::getStatus, status.name()) + .eq(InstitutionIdentifierApplicationEntity::getDeleted, false) + .orderByDesc(InstitutionIdentifierApplicationEntity::getTrainingRound) + .orderByDesc(InstitutionIdentifierApplicationEntity::getCreatedAt) + .last("LIMIT 1")); + return Optional.ofNullable(entity).map(this::toDomain); + } + @Override public void update(InstitutionIdentifierApplication application, InstitutionIdentifierApplication.Status expectedStatus, diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/ConfirmInstitutionIdentifierRequest.java b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/ConfirmInstitutionIdentifierRequest.java index efd695e..84f4f0a 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/ConfirmInstitutionIdentifierRequest.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/ConfirmInstitutionIdentifierRequest.java @@ -1,28 +1,11 @@ package com.yau.digitalrmb.institutionidentity.interfaces.dto; import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotNull; public class ConfirmInstitutionIdentifierRequest { - @NotNull(message = "申请编号不能为空") - private Long applicationId; - @NotBlank(message = "签名密钥不能为空") private String signingKeyId; - public Long getApplicationId() { - return applicationId; - } - - public void setApplicationId(Long applicationId) { - this.applicationId = applicationId; - } - - public String getSigningKeyId() { - return signingKeyId; - } - - public void setSigningKeyId(String signingKeyId) { - this.signingKeyId = signingKeyId; - } + public String getSigningKeyId() { return signingKeyId; } + public void setSigningKeyId(String signingKeyId) { this.signingKeyId = signingKeyId; } } diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/CurrencyRequestStepRequest.java b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/CurrencyRequestStepRequest.java new file mode 100644 index 0000000..215fc7b --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/CurrencyRequestStepRequest.java @@ -0,0 +1,17 @@ +package com.yau.digitalrmb.institutionidentity.interfaces.dto; + +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Positive; + +public class CurrencyRequestStepRequest { + @NotNull(message = "货币生成请求编号不能为空") + @Positive(message = "货币生成请求编号必须大于0") + private Long requestId; + + private String keyId; + + public Long getRequestId() { return requestId; } + public void setRequestId(Long requestId) { this.requestId = requestId; } + public String getKeyId() { return keyId; } + public void setKeyId(String keyId) { this.keyId = keyId; } +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/CurrencyVerificationStepRequest.java b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/CurrencyVerificationStepRequest.java new file mode 100644 index 0000000..5acc459 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/CurrencyVerificationStepRequest.java @@ -0,0 +1,16 @@ +package com.yau.digitalrmb.institutionidentity.interfaces.dto; + +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Positive; + +public class CurrencyVerificationStepRequest { + @NotNull(message = "步骤三验证编号不能为空") + @Positive(message = "步骤三验证编号必须大于0") + private Long verificationId; + private String keyId; + + public Long getVerificationId() { return verificationId; } + public void setVerificationId(Long verificationId) { this.verificationId = verificationId; } + public String getKeyId() { return keyId; } + public void setKeyId(String keyId) { this.keyId = keyId; } +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/InstitutionIdentifierStepRequest.java b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/InstitutionIdentifierStepRequest.java index 1a829a1..6363358 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/InstitutionIdentifierStepRequest.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/InstitutionIdentifierStepRequest.java @@ -1,26 +1,11 @@ package com.yau.digitalrmb.institutionidentity.interfaces.dto; -import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; public class InstitutionIdentifierStepRequest { - @NotNull(message = "申请编号不能为空") - private Long applicationId; - + @Size(max = 64, message = "密钥标识长度不能超过64个字符") private String keyId; - public Long getApplicationId() { - return applicationId; - } - - public void setApplicationId(Long applicationId) { - this.applicationId = applicationId; - } - - public String getKeyId() { - return keyId; - } - - public void setKeyId(String keyId) { - this.keyId = keyId; - } + public String getKeyId() { return keyId; } + public void setKeyId(String keyId) { this.keyId = keyId; } } diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/PrepareCurrencyRequest.java b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/PrepareCurrencyRequest.java new file mode 100644 index 0000000..265eb2f --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/PrepareCurrencyRequest.java @@ -0,0 +1,33 @@ +package com.yau.digitalrmb.institutionidentity.interfaces.dto; + +import javax.validation.constraints.DecimalMax; +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.Digits; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Positive; +import java.math.BigDecimal; + +public class PrepareCurrencyRequest { + @NotNull(message = "机构标识申请编号不能为空") + @Positive(message = "机构标识申请编号必须大于0") + private Long identifierApplicationId; + + @NotNull(message = "申请金额不能为空") + @DecimalMin(value = "0.01", message = "申请金额必须大于0") + @DecimalMax(value = "50000.00", message = "申请金额不能超过50000.00元") + @Digits(integer = 16, fraction = 2, message = "申请金额最多保留2位小数") + private BigDecimal amount; + + @NotBlank(message = "投放节点编号不能为空") + private String deliveryNodeCode; + + public Long getIdentifierApplicationId() { return identifierApplicationId; } + public void setIdentifierApplicationId(Long identifierApplicationId) { + this.identifierApplicationId = identifierApplicationId; + } + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal amount) { this.amount = amount; } + public String getDeliveryNodeCode() { return deliveryNodeCode; } + public void setDeliveryNodeCode(String deliveryNodeCode) { this.deliveryNodeCode = deliveryNodeCode; } +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/ReceiveCurrencyVerificationRequest.java b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/ReceiveCurrencyVerificationRequest.java new file mode 100644 index 0000000..a44f9c3 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/ReceiveCurrencyVerificationRequest.java @@ -0,0 +1,13 @@ +package com.yau.digitalrmb.institutionidentity.interfaces.dto; + +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Positive; + +public class ReceiveCurrencyVerificationRequest { + @NotNull(message = "步骤二货币生成请求编号不能为空") + @Positive(message = "步骤二货币生成请求编号必须大于0") + private Long requestId; + + public Long getRequestId() { return requestId; } + public void setRequestId(Long requestId) { this.requestId = requestId; } +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/VerifyInstitutionApplicationRequest.java b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/VerifyInstitutionApplicationRequest.java index 209a473..c3dc5a7 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/VerifyInstitutionApplicationRequest.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/VerifyInstitutionApplicationRequest.java @@ -1,28 +1,11 @@ package com.yau.digitalrmb.institutionidentity.interfaces.dto; import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotNull; public class VerifyInstitutionApplicationRequest { - @NotNull(message = "申请编号不能为空") - private Long applicationId; - @NotBlank(message = "验证密钥不能为空") private String verificationKeyId; - public Long getApplicationId() { - return applicationId; - } - - public void setApplicationId(Long applicationId) { - this.applicationId = applicationId; - } - - public String getVerificationKeyId() { - return verificationKeyId; - } - - public void setVerificationKeyId(String verificationKeyId) { - this.verificationKeyId = verificationKeyId; - } + public String getVerificationKeyId() { return verificationKeyId; } + public void setVerificationKeyId(String verificationKeyId) { this.verificationKeyId = verificationKeyId; } } diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/rest/CurrencyGenerationRequestController.java b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/rest/CurrencyGenerationRequestController.java new file mode 100644 index 0000000..c20da13 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/rest/CurrencyGenerationRequestController.java @@ -0,0 +1,99 @@ +package com.yau.digitalrmb.institutionidentity.interfaces.rest; + +import com.yau.digitalrmb.institutionidentity.application.CurrencyGenerationRequestService; +import com.yau.digitalrmb.institutionidentity.application.CurrencyRequestResult; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +import com.yau.digitalrmb.institutionidentity.interfaces.dto.CurrencyRequestStepRequest; +import com.yau.digitalrmb.institutionidentity.interfaces.dto.PrepareCurrencyRequest; +import com.yau.digitalrmb.security.context.AuthContextHolder; +import com.yau.digitalrmb.security.context.JwtUser; +import com.yau.digitalrmb.shared.api.ApiResponse; +import com.yau.digitalrmb.shared.web.TraceIdFilter; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.slf4j.MDC; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/v1/institution-identifiers/currency-requests") +@Tag(name = "步骤二:发起请求") +public class CurrencyGenerationRequestController { + @Resource + private CurrencyGenerationRequestService currencyRequestService; + + @PostMapping("/steps/prepare") + @Operation(summary = "准备请求信息", + description = "任务一必须已ISSUED;后端自动复制简洁和完整机构标识,前端不能覆盖。") + public ApiResponse prepare( + @Valid @RequestBody PrepareCurrencyRequest request) { + JwtUser jwtUser = AuthContextHolder.get(); + return ApiResponse.success(currencyRequestService.prepare(request.getIdentifierApplicationId(), + request.getAmount(), request.getDeliveryNodeCode(), InstitutionKeySubject.from(jwtUser), + jwtUser.getUsername()), traceId()); + } + + @PostMapping("/steps/concatenate") + @Operation(summary = "拼接请求原文", description = "机构标识|金额|投放节点编号|时间戳。") + public ApiResponse concatenate( + @Valid @RequestBody CurrencyRequestStepRequest request) { + JwtUser jwtUser = AuthContextHolder.get(); + return ApiResponse.success(currencyRequestService.concatenate(request.getRequestId(), + InstitutionKeySubject.from(jwtUser), jwtUser.getUsername()), traceId()); + } + + @PostMapping("/steps/digest") + @Operation(summary = "计算请求SM3摘要") + public ApiResponse digest( + @Valid @RequestBody CurrencyRequestStepRequest request) { + JwtUser jwtUser = AuthContextHolder.get(); + return ApiResponse.success(currencyRequestService.digest(request.getRequestId(), + InstitutionKeySubject.from(jwtUser), jwtUser.getUsername()), traceId()); + } + + @PostMapping("/steps/sign") + @Operation(summary = "使用商业银行第二私钥进行SM2签名") + public ApiResponse sign( + @Valid @RequestBody CurrencyRequestStepRequest request) { + JwtUser jwtUser = AuthContextHolder.get(); + return ApiResponse.success(currencyRequestService.sign(request.getRequestId(), request.getKeyId(), + InstitutionKeySubject.from(jwtUser), jwtUser.getUsername()), traceId()); + } + + @PostMapping("/steps/package") + @Operation(summary = "组装货币生成请求报文") + public ApiResponse packageRequest( + @Valid @RequestBody CurrencyRequestStepRequest request) { + JwtUser jwtUser = AuthContextHolder.get(); + return ApiResponse.success(currencyRequestService.packageMessage(request.getRequestId(), + InstitutionKeySubject.from(jwtUser), jwtUser.getUsername()), traceId()); + } + + @PostMapping("/steps/send") + @Operation(summary = "发送请求至央行") + public ApiResponse send( + @Valid @RequestBody CurrencyRequestStepRequest request) { + JwtUser jwtUser = AuthContextHolder.get(); + return ApiResponse.success(currencyRequestService.send(request.getRequestId(), + InstitutionKeySubject.from(jwtUser), jwtUser.getUsername()), traceId()); + } + + @GetMapping + @Operation(summary = "查询请求及央行接收状态") + public ApiResponse detail(@RequestParam("requestId") long requestId) { + return ApiResponse.success(currencyRequestService.detail(requestId, + InstitutionKeySubject.from(AuthContextHolder.get())), + traceId()); + } + + private String traceId() { + return MDC.get(TraceIdFilter.MDC_KEY); + } +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/rest/CurrencyGenerationVerificationController.java b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/rest/CurrencyGenerationVerificationController.java new file mode 100644 index 0000000..05c7df8 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/rest/CurrencyGenerationVerificationController.java @@ -0,0 +1,85 @@ +package com.yau.digitalrmb.institutionidentity.interfaces.rest; + +import com.yau.digitalrmb.institutionidentity.application.CurrencyGenerationVerificationResult; +import com.yau.digitalrmb.institutionidentity.application.CurrencyGenerationVerificationService; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +import com.yau.digitalrmb.institutionidentity.interfaces.dto.CurrencyVerificationStepRequest; +import com.yau.digitalrmb.institutionidentity.interfaces.dto.ReceiveCurrencyVerificationRequest; +import com.yau.digitalrmb.security.context.AuthContextHolder; +import com.yau.digitalrmb.security.context.JwtUser; +import com.yau.digitalrmb.shared.api.ApiResponse; +import com.yau.digitalrmb.shared.web.TraceIdFilter; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.slf4j.MDC; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/v1/institution-identifiers/currency-request-verifications") +@Tag(name = "步骤三:验证货币生成请求") +public class CurrencyGenerationVerificationController { + @Resource private CurrencyGenerationVerificationService service; + + @PostMapping("/steps/receive") + @Operation(summary = "接收步骤二请求报文", description = "步骤二必须达到RECEIVED,数据由后端自动流转。") + public ApiResponse receive( + @Valid @RequestBody ReceiveCurrencyVerificationRequest request) { + JwtUser user = AuthContextHolder.get(); + return ok(service.receive(request.getRequestId(), InstitutionKeySubject.from(user), user.getUsername())); + } + + @PostMapping("/steps/verify-signature") + @Operation(summary = "使用商业银行第二公钥验证SM2签名") + public ApiResponse verifySignature( + @Valid @RequestBody CurrencyVerificationStepRequest request) { + JwtUser user = AuthContextHolder.get(); + return ok(service.verifySignature(request.getVerificationId(), request.getKeyId(), + InstitutionKeySubject.from(user), user.getUsername())); + } + + @PostMapping("/steps/verify-digest") + @Operation(summary = "重新计算SM3并验证请求完整性") + public ApiResponse verifyDigest( + @Valid @RequestBody CurrencyVerificationStepRequest request) { + JwtUser user = AuthContextHolder.get(); + return ok(service.verifyDigest(request.getVerificationId(), InstitutionKeySubject.from(user), user.getUsername())); + } + + @PostMapping("/steps/verify-quota") + @Operation(summary = "检查投放额度是否充足") + public ApiResponse verifyQuota( + @Valid @RequestBody CurrencyVerificationStepRequest request) { + JwtUser user = AuthContextHolder.get(); + return ok(service.verifyQuota(request.getVerificationId(), InstitutionKeySubject.from(user), user.getUsername())); + } + + @PostMapping("/steps/confirm") + @Operation(summary = "使用央行第一私钥签名确认") + public ApiResponse confirm( + @Valid @RequestBody CurrencyVerificationStepRequest request) { + JwtUser user = AuthContextHolder.get(); + return ok(service.confirm(request.getVerificationId(), request.getKeyId(), + InstitutionKeySubject.from(user), user.getUsername())); + } + + @PostMapping("/steps/return-response") + @Operation(summary = "向商业银行返回确认报文") + public ApiResponse returnResponse( + @Valid @RequestBody CurrencyVerificationStepRequest request) { + JwtUser user = AuthContextHolder.get(); + return ok(service.returnResponse(request.getVerificationId(), InstitutionKeySubject.from(user), user.getUsername())); + } + + @GetMapping + @Operation(summary = "查询步骤三验证状态和完整数据") + public ApiResponse detail(@RequestParam long verificationId) { + return ok(service.detail(verificationId, InstitutionKeySubject.from(AuthContextHolder.get()))); + } + + private ApiResponse ok(CurrencyGenerationVerificationResult result) { + return ApiResponse.success(result, MDC.get(TraceIdFilter.MDC_KEY)); + } +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/rest/InstitutionIdentifierController.java b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/rest/InstitutionIdentifierController.java index 3bb23ad..99a17a4 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/rest/InstitutionIdentifierController.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/rest/InstitutionIdentifierController.java @@ -15,14 +15,13 @@ import com.yau.digitalrmb.institutionidentity.interfaces.dto.GenerateInstitution import com.yau.digitalrmb.institutionidentity.interfaces.dto.InstitutionIdentifierStepRequest; import com.yau.digitalrmb.institutionidentity.interfaces.dto.VerifyInstitutionApplicationRequest; import com.yau.digitalrmb.institutionidentity.interfaces.dto.ConfirmInstitutionIdentifierRequest; +import com.yau.digitalrmb.security.context.AuthContextHolder; +import com.yau.digitalrmb.security.context.JwtUser; import com.yau.digitalrmb.shared.api.ApiResponse; import com.yau.digitalrmb.shared.web.TraceIdFilter; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.slf4j.MDC; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -48,88 +47,84 @@ public class InstitutionIdentifierController { @PostMapping("/keys/commercial-bank") @Operation(summary = "获取商业银行SM2密钥对", description = "按Token中的userId、schoolId、classId生成或读取商业银行密钥对,私钥加密保存。") - public ApiResponse commercialBankKey( - @AuthenticationPrincipal Jwt jwt, Authentication authentication) { - return ApiResponse.success(keyService.commercialBankKey(InstitutionKeySubject.from(jwt), - authentication.getName()), traceId()); + public ApiResponse commercialBankKey() { + JwtUser jwtUser = AuthContextHolder.get(); + return ApiResponse.success(keyService.commercialBankKey(InstitutionKeySubject.from(jwtUser), + jwtUser.getUsername()), traceId()); } - @DeleteMapping("/keys/commercial-bank") - @Operation(summary = "删除商业银行SM2密钥对", - description = "仅逻辑删除当前Token中的userId、schoolId、classId所对应的商业银行密钥。") - public ApiResponse deleteCommercialBankKey( - @AuthenticationPrincipal Jwt jwt, Authentication authentication) { - keyService.logicalDelete(InstitutionKeySubject.from(jwt), InstitutionKeyService.BANK_SECOND_KEY, - authentication.getName()); - return ApiResponse.success(null, traceId()); - } +// @DeleteMapping("/keys/commercial-bank") +// @Operation(summary = "删除商业银行SM2密钥对", +// description = "仅逻辑删除当前Token中的userId、schoolId、classId所对应的商业银行密钥。") +// public ApiResponse deleteCommercialBankKey() { +// JwtUser jwtUser = AuthContextHolder.get(); +// keyService.logicalDelete(InstitutionKeySubject.from(jwtUser), InstitutionKeyService.BANK_SECOND_KEY, +// jwtUser.getUsername()); +// return ApiResponse.success(null, traceId()); +// } @PostMapping("/keys/central-bank") @Operation(summary = "获取中央银行SM2密钥对", description = "按Token中的userId、schoolId、classId生成或读取中央银行密钥对,私钥加密保存。") - public ApiResponse centralBankKey( - @AuthenticationPrincipal Jwt jwt, Authentication authentication) { - return ApiResponse.success(keyService.centralBankKey(InstitutionKeySubject.from(jwt), - authentication.getName()), traceId()); - } - - @DeleteMapping("/keys/central-bank") - @Operation(summary = "删除中央银行SM2密钥对", - description = "仅逻辑删除当前Token中的userId、schoolId、classId所对应的中央银行密钥。") - public ApiResponse deleteCentralBankKey( - @AuthenticationPrincipal Jwt jwt, Authentication authentication) { - keyService.logicalDelete(InstitutionKeySubject.from(jwt), InstitutionKeyService.CENTRAL_FIRST_KEY, - authentication.getName()); - return ApiResponse.success(null, traceId()); + public ApiResponse centralBankKey() { + JwtUser jwtUser = AuthContextHolder.get(); + return ApiResponse.success(keyService.centralBankKey(InstitutionKeySubject.from(jwtUser), + jwtUser.getUsername()), traceId()); } - @PostMapping("/steps/prepare") - @Operation(summary = "创建申请并获取业务信息", description = "创建PREPARED状态申请并保存数据库,返回后续步骤使用的applicationId。") - public ApiResponse prepare( - @Valid @RequestBody GenerateInstitutionIdentifierRequest request, Authentication authentication) { - return ApiResponse.success(stepService.prepare(request.getBankCode(), authentication.getName()), traceId()); - } +// @DeleteMapping("/keys/central-bank") +// @Operation(summary = "删除中央银行SM2密钥对", +// description = "仅逻辑删除当前Token中的userId、schoolId、classId所对应的中央银行密钥。") +// public ApiResponse deleteCentralBankKey() { +// JwtUser jwtUser = AuthContextHolder.get(); +// keyService.logicalDelete(InstitutionKeySubject.from(jwtUser), InstitutionKeyService.CENTRAL_FIRST_KEY, +// jwtUser.getUsername()); +// return ApiResponse.success(null, traceId()); +// } @PostMapping("/steps/digest") - @Operation(summary = "使用SM3计算摘要", description = "仅PREPARED状态可操作,完成后保存摘要并进入DIGESTED状态。") + @Operation(summary = "生成本轮申请并使用SM3计算摘要", + description = "只提交弹窗中的机构代码;后端生成时间戳、创建本轮记录并计算摘要,不需要申请编号。") public ApiResponse digest( - @Valid @RequestBody InstitutionIdentifierStepRequest request, Authentication authentication) { - return ApiResponse.success(stepService.digest(request.getApplicationId(), authentication.getName()), traceId()); + @Valid @RequestBody GenerateInstitutionIdentifierRequest request) { + JwtUser jwtUser = AuthContextHolder.get(); + return ApiResponse.success(stepService.digest(request.getBankCode(), InstitutionKeySubject.from(jwtUser), + jwtUser.getUsername()), traceId()); } @PostMapping("/steps/sign") @Operation(summary = "使用商业银行第二私钥进行SM2签名", description = "仅DIGESTED状态可操作,完成后保存签名并进入SIGNED状态。") public ApiResponse sign( - @Valid @RequestBody InstitutionIdentifierStepRequest request, - @AuthenticationPrincipal Jwt jwt, Authentication authentication) { - return ApiResponse.success(stepService.sign(request.getApplicationId(), request.getKeyId(), - InstitutionKeySubject.from(jwt), authentication.getName()), traceId()); + @Valid @RequestBody InstitutionIdentifierStepRequest request) { + JwtUser jwtUser = AuthContextHolder.get(); + return ApiResponse.success(stepService.sign(request.getKeyId(), + InstitutionKeySubject.from(jwtUser), jwtUser.getUsername()), traceId()); } @PostMapping("/steps/package") @Operation(summary = "组装机构标识申请", description = "仅SIGNED状态可操作,完成后进入PACKAGED待央行验证状态。") - public ApiResponse packageApplication( - @Valid @RequestBody InstitutionIdentifierStepRequest request, Authentication authentication) { - return ApiResponse.success(stepService.packageApplication(request.getApplicationId(), authentication.getName()), - traceId()); + public ApiResponse packageApplication() { + JwtUser jwtUser = AuthContextHolder.get(); + return ApiResponse.success(stepService.packageApplication(InstitutionKeySubject.from(jwtUser), + jwtUser.getUsername()), traceId()); } @PostMapping("/applications/verification") @Operation(summary = "查看并保存机构标识申请验证过程", description = "仅PACKAGED状态可操作;保存SM2验签和SM3完整性验证结果。") public ApiResponse verifyApplication( - @Valid @RequestBody VerifyInstitutionApplicationRequest request, - @AuthenticationPrincipal Jwt jwt, Authentication authentication) { - return ApiResponse.success(verificationService.verify(request.getApplicationId(), - request.getVerificationKeyId(), InstitutionKeySubject.from(jwt), authentication.getName()), traceId()); + @Valid @RequestBody VerifyInstitutionApplicationRequest request) { + JwtUser jwtUser = AuthContextHolder.get(); + return ApiResponse.success(verificationService.verify(request.getVerificationKeyId(), + InstitutionKeySubject.from(jwtUser), jwtUser.getUsername()), traceId()); } @PostMapping("/applications/confirmation") @Operation(summary = "央行签名确权并组装机构标识", description = "仅VERIFIED状态可操作;保存央行签名和机构标识并进入ISSUED状态。") public ApiResponse confirmIdentifier( - @Valid @RequestBody ConfirmInstitutionIdentifierRequest request, - @AuthenticationPrincipal Jwt jwt, Authentication authentication) { - return ApiResponse.success(verificationService.confirm(request.getApplicationId(), request.getSigningKeyId(), - InstitutionKeySubject.from(jwt), authentication.getName()), traceId()); + @Valid @RequestBody ConfirmInstitutionIdentifierRequest request) { + JwtUser jwtUser = AuthContextHolder.get(); + return ApiResponse.success(verificationService.confirm(request.getSigningKeyId(), + InstitutionKeySubject.from(jwtUser), jwtUser.getUsername()), traceId()); } private String traceId() { return MDC.get(TraceIdFilter.MDC_KEY); } diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java deleted file mode 100644 index e603f4e..0000000 --- a/src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.yau.digitalrmb.platformintegration.application; - -import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; -import com.yau.digitalrmb.shared.api.ErrorCode; -import com.yau.digitalrmb.shared.exception.BusinessException; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; -import org.springframework.http.ResponseEntity; -import org.springframework.http.client.SimpleClientHttpRequestFactory; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; -import org.w3c.dom.Document; -import org.xml.sax.InputSource; -import org.xml.sax.helpers.DefaultHandler; - -import javax.xml.XMLConstants; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.xpath.XPathConstants; -import javax.xml.xpath.XPathFactory; -import java.io.StringReader; -import java.net.URI; - -@Component -@ConditionalOnProperty(prefix = "platform-integration", name = "enabled", havingValue = "true") -public class CasTicketValidator { - private final PlatformIntegrationProperties.Cas properties; - private final RestTemplate restTemplate; - - @Autowired - public CasTicketValidator(PlatformIntegrationProperties properties) { - this(properties.getCas(), createRestTemplate()); - } - - private static RestTemplate createRestTemplate() { - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(5000); - requestFactory.setReadTimeout(5000); - return new RestTemplate(requestFactory); - } - - CasTicketValidator(PlatformIntegrationProperties.Cas properties, RestTemplate restTemplate) { - this.properties = properties; - this.restTemplate = restTemplate; - } - - public String validate(String ticket) { - try { - URI uri = UriComponentsBuilder.fromUriString(properties.getValidateUrl()) - .queryParam("service", properties.getCallbackUrl()) - .queryParam("ticket", ticket) - .build().encode().toUri(); - ResponseEntity response = restTemplate.getForEntity(uri, String.class); - if (response.getStatusCodeValue() < 200 || response.getStatusCodeValue() >= 300) { - throw rejected(); - } - return parseAccount(response.getBody()); - } catch (BusinessException exception) { - throw exception; - } catch (Exception exception) { - throw new BusinessException(ErrorCode.UNAUTHORIZED, "CAS 票据校验失败"); - } - } - - static String parseAccount(String xml) { - try { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(true); - factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - factory.setXIncludeAware(false); - factory.setExpandEntityReferences(false); - javax.xml.parsers.DocumentBuilder builder = factory.newDocumentBuilder(); - builder.setErrorHandler(new DefaultHandler()); - Document document = builder.parse(new InputSource(new StringReader(xml))); - String account = (String) XPathFactory.newInstance().newXPath().evaluate( - "string(//*[local-name()='authenticationSuccess']/*[local-name()='user'])", - document, XPathConstants.STRING); - if (account == null || account.trim().isEmpty()) { - throw rejected(); - } - return account.trim(); - } catch (BusinessException exception) { - throw exception; - } catch (Exception exception) { - throw rejected(); - } - } - - private static BusinessException rejected() { - return new BusinessException(ErrorCode.UNAUTHORIZED, "CAS 票据无效"); - } -} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java index 9c37225..634b410 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java @@ -49,23 +49,30 @@ public class PlatformTokenVerifier { } verifySignature(parts); JsonNode payload = readJson(parts[1]); - if (!payload.path("exp").canConvertToLong() || Instant.ofEpochSecond(payload.path("exp").asLong()).compareTo(now) <= 0) { + JsonNode expiration = payload.path("exp"); + if (!expiration.isMissingNode() + && (!expiration.canConvertToLong() + || Instant.ofEpochSecond(expiration.asLong()).compareTo(now) <= 0)) { throw invalid(); } - long userId = requiredPositiveLong(payload, "userId"); + String userId = requiredText(payload, "userId"); String username = requiredText(payload, "username"); - String password = requiredText(payload, "password"); - long roleId = requiredLong(payload, "roleid"); + String password = optionalText(payload, "password"); + if (password == null) { + password = "123qwe"; + } + int roleId = requiredRoleId(payload); String displayName = optionalText(payload, "name"); if (displayName == null || displayName.trim().isEmpty()) { displayName = username; } return new VerifiedPlatformToken(userId, username, displayName, password, roleId, - roleId == 3L ? "TEACHER" : "STUDENT", optionalText(payload, "schoolId"), - optionalText(payload, "schoolName"), optionalText(payload, "collegeId"), + optionalText(payload, "schoolId"), + optionalText(payload, "schoolName", "school"), optionalText(payload, "collegeId"), optionalText(payload, "collegeName"), optionalText(payload, "majorId"), - optionalText(payload, "majorName"), optionalText(payload, "classId"), - optionalText(payload, "className"), optionalText(payload, "studentid")); + optionalText(payload, "majorName", "major"), optionalText(payload, "classId"), + optionalText(payload, "className", "class"), + optionalText(payload, "studentId", "studentid", "studentNo")); } catch (PlatformTokenException exception) { throw exception; } catch (Exception exception) { @@ -82,10 +89,36 @@ public class PlatformTokenVerifier { } } + private int requiredRoleId(JsonNode payload) { + long roleId = requiredLong(payload, "roleid", "roleId"); + if (roleId != 1L && roleId != 3L && roleId != 4L) { + throw invalid(); + } + return (int) roleId; + } + private JsonNode readJson(String encoded) throws Exception { return objectMapper.readTree(Base64.getUrlDecoder().decode(encoded)); } - private long requiredPositiveLong(JsonNode payload, String name) { long value = requiredLong(payload, name); if (value <= 0) throw invalid(); return value; } - private long requiredLong(JsonNode payload, String name) { JsonNode node = payload.path(name); if (!node.canConvertToLong()) throw invalid(); return node.asLong(); } + private long requiredLong(JsonNode payload, String... names) { + for (String name : names) { + JsonNode node = payload.path(name); + if (node.canConvertToLong()) { + return node.asLong(); + } + } + throw invalid(); + } private String requiredText(JsonNode payload, String name) { String value = payload.path(name).asText(); if (value == null || value.trim().isEmpty()) throw invalid(); return value; } - private String optionalText(JsonNode payload, String name) { JsonNode node = payload.path(name); return node.isMissingNode() || node.isNull() ? null : node.asText(); } + private String optionalText(JsonNode payload, String... names) { + for (String name : names) { + JsonNode node = payload.path(name); + if (!node.isMissingNode() && !node.isNull()) { + String value = node.asText(); + if (value != null && !value.trim().isEmpty()) { + return value; + } + } + } + return null; + } private PlatformTokenException invalid() { return new PlatformTokenException("Invalid platform token"); } } diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java index 2ec3fc8..6bbb748 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java @@ -1,15 +1,15 @@ package com.yau.digitalrmb.platformintegration.application; +import com.yau.digitalrmb.identity.domain.UserRole; import lombok.Getter; @Getter public class VerifiedPlatformToken { - private final long userId; + private final String userId; private final String username; private final String displayName; private final String rawPassword; - private final long roleId; - private final String roleKey; + private final int roleId; private final String schoolId; private final String schoolName; private final String collegeId; @@ -20,21 +20,22 @@ public class VerifiedPlatformToken { private final String className; private final String studentId; - public VerifiedPlatformToken(long userId, String username, String displayName, String rawPassword, String roleKey) { - this(userId, username, displayName, rawPassword, "TEACHER".equals(roleKey) ? 3L : 2L, roleKey, + public VerifiedPlatformToken(String userId, String username, String displayName, String rawPassword, int roleId) { + this(userId, username, displayName, rawPassword, roleId, null, null, null, null, null, null, null, null, null); } - public VerifiedPlatformToken(long userId, String username, String displayName, String rawPassword, long roleId, - String roleKey, String schoolId, String schoolName, String collegeId, + public VerifiedPlatformToken(String userId, String username, String displayName, String rawPassword, long roleId, + String schoolId, String schoolName, String collegeId, String collegeName, String majorId, String majorName, String classId, String className, String studentId) { + int validatedRoleId = Math.toIntExact(roleId); + UserRole.fromId(validatedRoleId); this.userId = userId; this.username = username; this.displayName = displayName; this.rawPassword = rawPassword; - this.roleId = roleId; - this.roleKey = roleKey; + this.roleId = validatedRoleId; this.schoolId = schoolId; this.schoolName = schoolName; this.collegeId = collegeId; diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java b/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java deleted file mode 100644 index eb9fd76..0000000 --- a/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java +++ /dev/null @@ -1,99 +0,0 @@ -package com.yau.digitalrmb.platformintegration.infrastructure; - -import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository; -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; -import com.yau.digitalrmb.platformintegration.domain.PlatformRole; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.dao.IncorrectResultSizeDataAccessException; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; -import org.springframework.stereotype.Repository; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Timestamp; -import java.time.Instant; -import java.util.List; -import java.util.ArrayList; -import java.util.Optional; - -@Repository -@ConditionalOnProperty(prefix = "platform-integration", name = "enabled", havingValue = "true") -public class JdbcPlatformIdentityRepository implements PlatformIdentityRepository { - private static final String TEACHER_QUERY = - "SELECT cu.ID, cu.CODE, cu.NAME, t.teacher_id AS profile_id, t.add_time AS signing_time " - + "FROM core_user cu JOIN teacher t ON t.user_id = cu.ID " - + "WHERE cu.JOB_TYPE1 = 'JT_S_02' AND cu.STATE = 'S1' AND cu.DEL_FLAG = 0 " - + "AND t.teacher_status = 1 AND t.add_time IS NOT NULL AND %s"; - private static final String STUDENT_QUERY = - "SELECT cu.ID, cu.CODE, cu.NAME, s.student_id AS profile_id, s.add_time AS signing_time " - + "FROM core_user cu JOIN student s ON s.user_id = cu.ID " - + "WHERE cu.JOB_TYPE1 = 'JT_S_03' AND cu.STATE = 'S1' AND cu.DEL_FLAG = 0 " - + "AND s.student_status = 1 AND s.add_time IS NOT NULL AND %s"; - - private final NamedParameterJdbcTemplate jdbcTemplate; - - public JdbcPlatformIdentityRepository( - @Qualifier("platformNamedParameterJdbcTemplate") NamedParameterJdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } - - @Override - public Optional findByPlatformUserId(long platformUserId) { - return findBy("cu.ID = :value", platformUserId); - } - - @Override - public Optional findBySchoolAccount(String schoolAccount) { - return findBy("cu.CODE = :value", schoolAccount); - } - - @Override - public List findChangedSince(Instant watermark) { - String predicate = "(cu.update_Time > :watermark OR cu.CREATE_TIME > :watermark)"; - Timestamp since = Timestamp.from(watermark); - List actors = new ArrayList( - queryAll(String.format(TEACHER_QUERY, predicate), since, PlatformRole.TEACHER)); - actors.addAll(queryAll(String.format(STUDENT_QUERY, predicate), since, PlatformRole.STUDENT)); - return actors; - } - - private Optional findBy(String predicate, Object value) { - Optional teacher = query(String.format(TEACHER_QUERY, predicate), value, PlatformRole.TEACHER); - return teacher.isPresent() ? teacher : query(String.format(STUDENT_QUERY, predicate), value, PlatformRole.STUDENT); - } - - private Optional query(String sql, Object value, PlatformRole role) { - List actors = jdbcTemplate.query(sql, - new MapSqlParameterSource("value", value), rowMapper(role)); - if (actors.size() > 1) { - throw new IncorrectResultSizeDataAccessException(1, actors.size()); - } - return actors.isEmpty() ? Optional.empty() : Optional.of(actors.get(0)); - } - - private List queryAll(String sql, Timestamp watermark, PlatformRole role) { - return jdbcTemplate.query(sql, new MapSqlParameterSource("watermark", watermark), rowMapper(role)); - } - - private RowMapper rowMapper(final PlatformRole role) { - return new RowMapper() { - @Override - public PlatformActor mapRow(ResultSet resultSet, int rowNumber) throws SQLException { - return map(resultSet, role); - } - }; - } - - private PlatformActor map(ResultSet resultSet, PlatformRole role) throws SQLException { - return new PlatformActor( - resultSet.getLong("ID"), - resultSet.getLong("profile_id"), - resultSet.getString("CODE"), - resultSet.getString("NAME"), - role, - resultSet.getTimestamp("signing_time").toInstant()); - } -} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfig.java b/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfig.java deleted file mode 100644 index abd1069..0000000 --- a/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfig.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.yau.digitalrmb.platformintegration.infrastructure; - -import com.zaxxer.hikari.HikariConfig; -import com.zaxxer.hikari.HikariDataSource; -import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; - -import javax.sql.DataSource; - -@Configuration(proxyBeanMethods = false) -@EnableConfigurationProperties(PlatformIntegrationProperties.class) -@ConditionalOnProperty(prefix = "platform-integration", name = "enabled", havingValue = "true") -public class PlatformReadOnlyDataSourceConfig { - @Bean(name = "platformReadOnlyDataSource", destroyMethod = "close") - public HikariDataSource platformReadOnlyDataSource(PlatformIntegrationProperties properties) { - HikariConfig config = new HikariConfig(); - config.setJdbcUrl(properties.getDatasource().getUrl()); - config.setUsername(properties.getDatasource().getUsername()); - config.setPassword(properties.getDatasource().getPassword()); - config.setReadOnly(true); - config.setMaximumPoolSize(5); - config.setPoolName("platform-readonly"); - return new HikariDataSource(config); - } - - @Bean(name = "platformNamedParameterJdbcTemplate") - public NamedParameterJdbcTemplate platformNamedParameterJdbcTemplate( - @Qualifier("platformReadOnlyDataSource") DataSource dataSource) { - return new NamedParameterJdbcTemplate(dataSource); - } -} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java b/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java deleted file mode 100644 index aac5ff8..0000000 --- a/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.yau.digitalrmb.platformintegration.interfaces; - -import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService; -import com.yau.digitalrmb.platformintegration.application.CasTicketValidator; -import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository; -import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; -import com.yau.digitalrmb.security.application.LoginExchangeCodeService; -import com.yau.digitalrmb.shared.api.ErrorCode; -import com.yau.digitalrmb.shared.exception.BusinessException; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.util.UriComponentsBuilder; - -@RestController -@ConditionalOnProperty(prefix = "platform-integration", name = "enabled", havingValue = "true") -@RequestMapping("/api/v1/auth/cas") -public class CasAuthenticationController { - private final CasTicketValidator ticketValidator; - private final PlatformIdentityRepository identityRepository; - private final PlatformIdentityProjectionService projectionService; - private final LoginExchangeCodeService exchangeCodeService; - private final PlatformIntegrationProperties.Cas cas; - private final PlatformIntegrationProperties.Frontend frontend; - - public CasAuthenticationController(CasTicketValidator ticketValidator, - PlatformIdentityRepository identityRepository, - PlatformIdentityProjectionService projectionService, - LoginExchangeCodeService exchangeCodeService, - PlatformIntegrationProperties properties) { - this.ticketValidator = ticketValidator; - this.identityRepository = identityRepository; - this.projectionService = projectionService; - this.exchangeCodeService = exchangeCodeService; - this.cas = properties.getCas(); - this.frontend = properties.getFrontend(); - } - - @GetMapping("/login") - public ResponseEntity login() { - String location = UriComponentsBuilder.fromUriString(cas.getLoginUrl()) - .queryParam("service", cas.getCallbackUrl()).build().encode().toUriString(); - return ResponseEntity.status(302).header(HttpHeaders.LOCATION, location) - .header(HttpHeaders.CACHE_CONTROL, "no-store").build(); - } - - @GetMapping("/callback") - public ResponseEntity callback(@RequestParam("ticket") String ticket) { - String account = ticketValidator.validate(ticket); - PlatformActor actor = identityRepository.findBySchoolAccount(account) - .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED, "用户无权访问本系统")); - projectionService.project(actor); - String exchangeCode = exchangeCodeService.issue(actor.platformUserId()); - String location = UriComponentsBuilder.fromUriString(frontend.getCallbackUrl()) - .queryParam("code", exchangeCode).build().encode().toUriString(); - return ResponseEntity.status(302).header(HttpHeaders.LOCATION, location) - .header(HttpHeaders.CACHE_CONTROL, "no-store") - .header("Referrer-Policy", "no-referrer").build(); - } -} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java b/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java index 10be3ba..4d42b65 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java @@ -1,10 +1,12 @@ package com.yau.digitalrmb.platformintegration.interfaces; import com.yau.digitalrmb.identity.application.LocalSsoAccountService; +import com.yau.digitalrmb.identity.infrastructure.persistence.entity.UserEntity; import com.yau.digitalrmb.platformintegration.application.PlatformTokenVerifier; import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken; import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; import com.yau.digitalrmb.security.application.JwtTokenService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import io.swagger.v3.oas.annotations.Operation; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; @@ -14,8 +16,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.util.UriComponentsBuilder; -import java.util.Collections; - @RestController @ConditionalOnProperty(prefix = "platform-integration", name = "enabled", havingValue = "true") @RequestMapping("/api/v1/auth") @@ -37,9 +37,8 @@ public class PlatformSsoController { @Operation(description = "单点登录鉴权") public ResponseEntity loginFromPlatform(@RequestParam("token") String token) { VerifiedPlatformToken verified = tokenVerifier.verify(token); - localSsoAccountService.synchronize(verified); - JwtTokenService.Token localToken = jwtTokenService.issueFor(verified.getUserId(), verified.getUsername(), - Collections.singleton(verified.getRoleKey())); + UserEntity user = localSsoAccountService.synchronize(verified); + JwtTokenService.Token localToken = jwtTokenService.issueFor(user); String location = UriComponentsBuilder.fromUriString(frontend.getCallbackUrl()) .queryParam("token", localToken.accessToken()).build().encode().toUriString(); return ResponseEntity.status(302) diff --git a/src/main/java/com/yau/digitalrmb/security/application/CurrentUser.java b/src/main/java/com/yau/digitalrmb/security/application/CurrentUser.java index 2bd0e07..b56034f 100644 --- a/src/main/java/com/yau/digitalrmb/security/application/CurrentUser.java +++ b/src/main/java/com/yau/digitalrmb/security/application/CurrentUser.java @@ -13,7 +13,7 @@ public class CurrentUser { private final String majorId; private final String majorName; private final Long roleid; - private final long userId; + private final String userId; private final String username; private final String name; private final String classId; diff --git a/src/main/java/com/yau/digitalrmb/security/application/CurrentUserService.java b/src/main/java/com/yau/digitalrmb/security/application/CurrentUserService.java index 09f451f..ff63bb8 100644 --- a/src/main/java/com/yau/digitalrmb/security/application/CurrentUserService.java +++ b/src/main/java/com/yau/digitalrmb/security/application/CurrentUserService.java @@ -1,43 +1,38 @@ package com.yau.digitalrmb.security.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.security.context.AuthContextHolder; import com.yau.digitalrmb.shared.api.ErrorCode; import com.yau.digitalrmb.shared.exception.BusinessException; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.stereotype.Service; @Service public class CurrentUserService { private final PlatformUserSnapshotMapper snapshotMapper; + private final UserMapper userMapper; - public CurrentUserService(PlatformUserSnapshotMapper snapshotMapper) { + public CurrentUserService(PlatformUserSnapshotMapper snapshotMapper, UserMapper userMapper) { this.snapshotMapper = snapshotMapper; + this.userMapper = userMapper; } public CurrentUser getCurrentUser() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || !(authentication.getPrincipal() instanceof Jwt)) { - throw unauthorized("用户身份无效"); - } - PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(parseUserId((Jwt) authentication.getPrincipal())); - if (snapshot == null) { + String userId = AuthContextHolder.get().getUserId(); + UserEntity user = userMapper.selectById(userId); + PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(userId); + if (user == null || user.getRoleId() == null) { throw unauthorized("用户身份不存在"); } - return new CurrentUser(snapshot.getSchoolId(), snapshot.getSchoolName(), snapshot.getCollegeId(), - snapshot.getCollegeName(), snapshot.getMajorId(), snapshot.getMajorName(), snapshot.getRoleId(), - snapshot.getPlatformUserId(), snapshot.getAccount(), snapshot.getDisplayName(), snapshot.getClassId(), - snapshot.getClassName(), snapshot.getStudentId()); - } - - private long parseUserId(Jwt jwt) { - try { - return Long.parseLong(jwt.getSubject()); - } catch (NumberFormatException exception) { - throw unauthorized("用户身份无效"); - } + return new CurrentUser(user.getSchoolId(), user.getSchoolName(), + snapshot == null ? null : snapshot.getCollegeId(), + snapshot == null ? null : snapshot.getCollegeName(), + snapshot == null ? null : snapshot.getMajorId(), + snapshot == null ? null : snapshot.getMajorName(), + user.getRoleId().longValue(), user.getUserId(), user.getStudentId(), user.getUserName(), + user.getClassId(), user.getClassName(), user.getStudentId()); } private BusinessException unauthorized(String message) { diff --git a/src/main/java/com/yau/digitalrmb/security/application/JwtTokenService.java b/src/main/java/com/yau/digitalrmb/security/application/JwtTokenService.java index c3da4ff..70a6086 100644 --- a/src/main/java/com/yau/digitalrmb/security/application/JwtTokenService.java +++ b/src/main/java/com/yau/digitalrmb/security/application/JwtTokenService.java @@ -1,5 +1,7 @@ package com.yau.digitalrmb.security.application; +import com.yau.digitalrmb.identity.domain.UserRole; +import com.yau.digitalrmb.identity.infrastructure.persistence.entity.UserEntity; import com.yau.digitalrmb.security.config.SecurityProperties; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtEncoder; @@ -34,16 +36,50 @@ public class JwtTokenService { return new Token(jwt.getTokenValue(), properties.getJwt().getAccessTokenTtl().getSeconds()); } - public Token issueFor(long platformUserId, String account, Set roles) { - return issueFor(platformUserId, null, null, account, roles); + public Token issueFor(String userId, String account, Set roles) { + return issueFor(userId, null, null, account, roles); } - public Token issueFor(long userId, Long schoolId, Long classId, String account, Set roles) { + public Token issueFor(String userId, String account, int roleId) { + return issueFor(userId, account, Collections.singleton(UserRole.fromId(roleId).getAuthority())); + } + + public Token issueFor(UserEntity user) { + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plus(properties.getJwt().getAccessTokenTtl()); + List authorities = Collections.singletonList( + "ROLE_" + UserRole.fromId(user.getRoleId()).getAuthority()); + JwtClaimsSet.Builder claims = JwtClaimsSet.builder() + .subject(user.getUserId()) + .issuedAt(issuedAt) + .expiresAt(expiresAt) + .claim("userId", user.getUserId()) + .claim("preferred_username", user.getStudentId()) + .claim("studentId", user.getStudentId()) + .claim("roleId", user.getRoleId()) + .claim("roles", authorities); + claimIfPresent(claims, "userName", user.getUserName()); + claimIfPresent(claims, "classId", user.getClassId()); + claimIfPresent(claims, "className", user.getClassName()); + claimIfPresent(claims, "phone", user.getPhone()); + claimIfPresent(claims, "schoolId", user.getSchoolId()); + claimIfPresent(claims, "schoolName", user.getSchoolName()); + claimIfPresent(claims, "authorizeTime", user.getAuthorizeTime()); + claimIfPresent(claims, "authorizeEndTime", user.getAuthorizeEndTime()); + claimIfPresent(claims, "createTime", user.getCreateTime()); + claimIfPresent(claims, "isDeleted", user.getIsDeleted()); + claimIfPresent(claims, "zyUserId", user.getZyUserId()); + Jwt jwt = jwtEncoder.encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), + claims.build())); + return new Token(jwt.getTokenValue(), properties.getJwt().getAccessTokenTtl().getSeconds()); + } + + public Token issueFor(String userId, Long schoolId, Long classId, String account, Set roles) { Instant issuedAt = Instant.now(); Instant expiresAt = issuedAt.plus(properties.getJwt().getAccessTokenTtl()); List authorities = roles.stream().sorted().map(role -> "ROLE_" + role).collect(Collectors.toList()); JwtClaimsSet.Builder claims = JwtClaimsSet.builder() - .subject(String.valueOf(userId)) + .subject(userId) .issuedAt(issuedAt) .expiresAt(expiresAt) .claim("userId", userId) @@ -60,6 +96,12 @@ public class JwtTokenService { return new Token(jwt.getTokenValue(), properties.getJwt().getAccessTokenTtl().getSeconds()); } + private static void claimIfPresent(JwtClaimsSet.Builder claims, String name, Object value) { + if (value != null) { + claims.claim(name, value.toString()); + } + } + public static class Token { private final String accessToken; private final long expiresIn; diff --git a/src/main/java/com/yau/digitalrmb/security/application/LocalAccountAuthenticationService.java b/src/main/java/com/yau/digitalrmb/security/application/LocalAccountAuthenticationService.java index 65c3cc8..bde4ab9 100644 --- a/src/main/java/com/yau/digitalrmb/security/application/LocalAccountAuthenticationService.java +++ b/src/main/java/com/yau/digitalrmb/security/application/LocalAccountAuthenticationService.java @@ -1,50 +1,71 @@ package com.yau.digitalrmb.security.application; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -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.shared.api.ErrorCode; import com.yau.digitalrmb.shared.exception.BusinessException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import java.util.Collections; - @Service public class LocalAccountAuthenticationService { + private static final Logger LOGGER = LoggerFactory.getLogger(LocalAccountAuthenticationService.class); + private final UserMapper userMapper; - private final PlatformUserSnapshotMapper snapshotMapper; private final PasswordEncoder passwordEncoder; private final JwtTokenService jwtTokenService; public LocalAccountAuthenticationService(UserMapper userMapper, - PlatformUserSnapshotMapper snapshotMapper, PasswordEncoder passwordEncoder, JwtTokenService jwtTokenService) { this.userMapper = userMapper; - this.snapshotMapper = snapshotMapper; this.passwordEncoder = passwordEncoder; this.jwtTokenService = jwtTokenService; } - public JwtTokenService.Token login(String username, String rawPassword) { - UserEntity user = userMapper.selectOne(new LambdaQueryWrapper() - .eq(UserEntity::getUsername, username) - .eq(UserEntity::getEnabled, true) - .last("LIMIT 1")); - if (user == null || !passwordEncoder.matches(rawPassword, user.getPasswordHash())) { + public JwtTokenService.Token login(String studentId, String rawPassword) { + UserEntity user = userMapper.selectActiveByStudentId(studentId); + if (user == null) { + LOGGER.warn("Local login rejected: studentId does not exist or user is deleted, studentId={}", + studentId); throw invalidCredentials(); } - PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(user.getId()); - if (snapshot == null) { + if (!passwordMatchesAndUpgrade(user, rawPassword)) { + LOGGER.warn("Local login rejected: password does not match, studentId={}", studentId); throw invalidCredentials(); } - return jwtTokenService.issueFor(user.getId(), snapshot.getAccount(), Collections.singleton(snapshot.getRoleKey())); + if (user.getRoleId() == null) { + LOGGER.error("Local login rejected: role is not configured, userId={}, studentId={}", + user.getUserId(), studentId); + throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户角色未配置"); + } + return jwtTokenService.issueFor(user); + } + + private boolean passwordMatchesAndUpgrade(UserEntity user, String rawPassword) { + String storedPassword = user.getPassword(); + if (storedPassword == null || rawPassword == null) { + return false; + } + if (isBcryptPassword(storedPassword)) { + return passwordEncoder.matches(rawPassword, storedPassword); + } + if (!storedPassword.equals(rawPassword)) { + return false; + } + userMapper.upgradeLegacyPassword(user.getUserId(), passwordEncoder.encode(rawPassword)); + return true; + } + + private boolean isBcryptPassword(String password) { + return password.startsWith("$2a$") + || password.startsWith("$2b$") + || password.startsWith("$2y$"); } private BusinessException invalidCredentials() { - return new BusinessException(ErrorCode.UNAUTHORIZED, "用户名或密码错误"); + return new BusinessException(ErrorCode.UNAUTHORIZED, "学号或密码错误"); } } diff --git a/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java b/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java index 5ca6871..d88c9ff 100644 --- a/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java +++ b/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java @@ -22,20 +22,20 @@ public class RefreshTokenService { this.properties = properties; } - public String issue(long platformUserId) { + public String issue(String userId) { byte[] bytes = new byte[48]; RANDOM.nextBytes(bytes); String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); jdbcTemplate.update("INSERT INTO auth_refresh_token (token_hash, platform_user_id, expires_at, revoked_at) VALUES (?, ?, ?, NULL)", - hash(token), platformUserId, + hash(token), userId, Timestamp.from(Instant.now().plus(properties.getSession().getRefreshTokenTtl()))); return token; } - public void revokeForUser(String token, long platformUserId) { + public void revokeForUser(String token, String userId) { jdbcTemplate.update("UPDATE auth_refresh_token SET revoked_at = CURRENT_TIMESTAMP " + "WHERE token_hash = ? AND platform_user_id = ? AND revoked_at IS NULL", - hash(token), platformUserId); + hash(token), userId); } private static String hash(String value) { diff --git a/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java b/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java index 99640ce..4e25788 100644 --- a/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java +++ b/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java @@ -51,6 +51,7 @@ public class SecurityConfig { .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeRequests(authorize -> authorize .antMatchers("/actuator/health", "/api/v1/auth/login", "/api/v1/auth/sso", + "/api/user/login", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html") .permitAll() .anyRequest().authenticated()) diff --git a/src/main/java/com/yau/digitalrmb/security/context/AuthContextHolder.java b/src/main/java/com/yau/digitalrmb/security/context/AuthContextHolder.java new file mode 100644 index 0000000..e13c475 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/context/AuthContextHolder.java @@ -0,0 +1,116 @@ +package com.yau.digitalrmb.security.context; + +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; + +public final class AuthContextHolder { + private AuthContextHolder() { + } + + public static JwtUser get() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof Jwt)) { + throw unauthorized("用户身份无效"); + } + + Jwt jwt = (Jwt) authentication.getPrincipal(); + String userId = requiredUuid(jwt.getClaims().get("userId"), jwt.getSubject()); + String studentId = text(jwt.getClaims().get("studentId")); + if (studentId == null) { + studentId = text(jwt.getClaims().get("preferred_username")); + } + if (studentId == null) { + studentId = authentication.getName(); + } + + return new JwtUser(userId, studentId, text(jwt.getClaims().get("userName")), + text(jwt.getClaims().get("classId")), text(jwt.getClaims().get("className")), + text(jwt.getClaims().get("phone")), text(jwt.getClaims().get("schoolName")), + text(jwt.getClaims().get("schoolId")), text(jwt.getClaims().get("authorizeTime")), + text(jwt.getClaims().get("authorizeEndTime")), optionalInteger(jwt, "roleId"), + text(jwt.getClaims().get("createTime")), optionalBoolean(jwt, "isDeleted"), + text(jwt.getClaims().get("zyUserId")), roles(jwt.getClaims().get("roles"))); + } + + private static Integer optionalInteger(Jwt jwt, String claim) { + Object value = jwt.getClaims().get(claim); + if (value == null) { + return null; + } + try { + return Integer.valueOf(String.valueOf(value)); + } catch (NumberFormatException exception) { + throw unauthorized("Token中的" + claim + "格式不正确"); + } + } + + private static Boolean optionalBoolean(Jwt jwt, String claim) { + Object value = jwt.getClaims().get(claim); + return value == null ? null : Boolean.valueOf(String.valueOf(value)); + } + + private static String requiredUuid(Object claimValue, String fallbackValue) { + String value = text(claimValue); + if (value != null && isUuid(value)) { + return UUID.fromString(value).toString(); + } + if (fallbackValue != null && isUuid(fallbackValue)) { + return UUID.fromString(fallbackValue).toString(); + } + if (claimValue == null) { + throw unauthorized("Token缺少userId"); + } + throw unauthorized("Token中的userId格式不正确"); + } + + private static boolean isUuid(String value) { + try { + UUID.fromString(value); + return true; + } catch (IllegalArgumentException exception) { + return false; + } + } + + private static String text(Object value) { + if (value == null) { + return null; + } + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : text; + } + + private static Set roles(Object value) { + if (value == null) { + return Collections.emptySet(); + } + Set roles = new LinkedHashSet<>(); + if (value instanceof Collection) { + for (Object role : (Collection) value) { + String text = text(role); + if (text != null) { + roles.add(text); + } + } + } else { + String text = text(value); + if (text != null) { + roles.add(text); + } + } + return Collections.unmodifiableSet(roles); + } + + private static BusinessException unauthorized(String message) { + return new BusinessException(ErrorCode.UNAUTHORIZED, message); + } +} diff --git a/src/main/java/com/yau/digitalrmb/security/context/JwtUser.java b/src/main/java/com/yau/digitalrmb/security/context/JwtUser.java new file mode 100644 index 0000000..154a152 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/context/JwtUser.java @@ -0,0 +1,79 @@ +package com.yau.digitalrmb.security.context; + +import java.util.Set; + +public final class JwtUser { + private final String userId; + private final String studentId; + private final String userName; + private final String classId; + private final String className; + private final String phone; + private final String schoolName; + private final String schoolId; + private final String authorizeTime; + private final String authorizeEndTime; + private final Integer roleId; + private final String createTime; + private final Boolean isDeleted; + private final String zyUserId; + private final Set roles; + + JwtUser(String userId, String studentId, String userName, String classId, String className, String phone, + String schoolName, String schoolId, String authorizeTime, String authorizeEndTime, Integer roleId, + String createTime, Boolean isDeleted, String zyUserId, Set roles) { + this.userId = userId; + this.studentId = studentId; + this.userName = userName; + this.classId = classId; + this.className = className; + this.phone = phone; + this.schoolName = schoolName; + this.schoolId = schoolId; + this.authorizeTime = authorizeTime; + this.authorizeEndTime = authorizeEndTime; + this.roleId = roleId; + this.createTime = createTime; + this.isDeleted = isDeleted; + this.zyUserId = zyUserId; + this.roles = roles; + } + + public String getUserId() { + return userId; + } + + public String getUsername() { + return studentId; + } + + public String getStudentId() { + return studentId; + } + + public String getUserName() { + return userName; + } + + public String getSchoolId() { + return schoolId; + } + + public String getClassId() { + return classId; + } + + public String getClassName() { return className; } + public String getPhone() { return phone; } + public String getSchoolName() { return schoolName; } + public String getAuthorizeTime() { return authorizeTime; } + public String getAuthorizeEndTime() { return authorizeEndTime; } + public Integer getRoleId() { return roleId; } + public String getCreateTime() { return createTime; } + public Boolean getIsDeleted() { return isDeleted; } + public String getZyUserId() { return zyUserId; } + + public Set getRoles() { + return roles; + } +} diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java b/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java index 183cc62..4c2ee4b 100644 --- a/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java @@ -5,15 +5,12 @@ import com.yau.digitalrmb.security.application.CurrentUserService; import com.yau.digitalrmb.security.application.JwtTokenService; import com.yau.digitalrmb.security.application.LocalAccountAuthenticationService; import com.yau.digitalrmb.security.application.RefreshTokenService; +import com.yau.digitalrmb.security.context.AuthContextHolder; import com.yau.digitalrmb.shared.api.ApiResponse; -import com.yau.digitalrmb.shared.api.ErrorCode; -import com.yau.digitalrmb.shared.exception.BusinessException; import com.yau.digitalrmb.shared.web.TraceIdFilter; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.slf4j.MDC; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -40,7 +37,8 @@ public class AuthController { @PostMapping("/login") @Operation(description = "用户登录") public ApiResponse login(@Valid @RequestBody LoginRequest request) { - JwtTokenService.Token token = localAccountAuthenticationService.login(request.username(), request.password()); + JwtTokenService.Token token = localAccountAuthenticationService.login( + request.studentId(), request.password()); return ApiResponse.success(new LoginResponse(token.accessToken(), "Bearer", token.expiresIn()), MDC.get(TraceIdFilter.MDC_KEY)); } @@ -57,16 +55,8 @@ public class AuthController { @PostMapping("/logout") @Operation(description = "登出") - public ApiResponse logout(@AuthenticationPrincipal Jwt jwt, @Valid @RequestBody LogoutRequest request) { - refreshTokenService.revokeForUser(request.refreshToken(), userId(jwt)); + public ApiResponse logout(@Valid @RequestBody LogoutRequest request) { + refreshTokenService.revokeForUser(request.refreshToken(), AuthContextHolder.get().getUserId()); return ApiResponse.success(null, MDC.get(TraceIdFilter.MDC_KEY)); } - - private long userId(Jwt jwt) { - try { - return Long.parseLong(jwt.getSubject()); - } catch (NumberFormatException exception) { - throw new BusinessException(ErrorCode.UNAUTHORIZED, "User identity is invalid"); - } - } } diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java b/src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java index 24c7e0f..7e672d1 100644 --- a/src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java @@ -13,7 +13,7 @@ public class CurrentUserResponse { private final String majorId; private final String majorName; private final Long roleid; - private final long userId; + private final String userId; private final String username; private final String name; private final String classId; diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/LoginRequest.java b/src/main/java/com/yau/digitalrmb/security/interfaces/LoginRequest.java index 4afcf06..f5d79e1 100644 --- a/src/main/java/com/yau/digitalrmb/security/interfaces/LoginRequest.java +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/LoginRequest.java @@ -1,4 +1,30 @@ package com.yau.digitalrmb.security.interfaces; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import javax.validation.constraints.NotBlank; -@Getter @Setter @NoArgsConstructor @AllArgsConstructor -public class LoginRequest { @NotBlank private String username; @NotBlank private String password; public String username() { return username; } public String password() { return password; } } + +import com.fasterxml.jackson.annotation.JsonAlias; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.validation.constraints.NotBlank; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class LoginRequest { + @NotBlank + @JsonAlias("username") + private String studentId; + + @NotBlank + private String password; + + public String studentId() { + return studentId; + } + + public String password() { + return password; + } +} diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/UserController.java b/src/main/java/com/yau/digitalrmb/security/interfaces/UserController.java new file mode 100644 index 0000000..81f0b0f --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/UserController.java @@ -0,0 +1,101 @@ +package com.yau.digitalrmb.security.interfaces; + +import com.yau.digitalrmb.identity.application.LocalSsoAccountService; +import com.yau.digitalrmb.identity.domain.UserRole; +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.application.PlatformTokenVerifier; +import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken; +import com.yau.digitalrmb.security.application.JwtTokenService; +import com.yau.digitalrmb.security.application.LocalAccountAuthenticationService; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import java.util.Collections; + +@RestController +@RequestMapping("/api/user") +@Tag(name = "用户管理") +@ConditionalOnProperty(prefix = "platform-integration", name = "enabled", havingValue = "true") +public class UserController { + @Resource + private PlatformTokenVerifier tokenVerifier; + + @Resource + private LocalSsoAccountService localSsoAccountService; + + @Resource + private LocalAccountAuthenticationService authenticationService; + + @Resource + private JwtTokenService jwtTokenService; + + @Resource + private UserMapper userMapper; + + @Resource + private PlatformUserSnapshotMapper snapshotMapper; + + @PostMapping("/login") + @Operation(summary = "用户登录", description = "支持账号密码登录和智云TOKEN单点登录") + public UserLoginResult login( + @Parameter(description = "用户名") + @RequestParam(required = false) String username, + @Parameter(description = "密码,参数名沿用旧系统passwordEncode") + @RequestParam(required = false) String passwordEncode, + @Parameter(description = "智云携带的Token") + @RequestParam(name = "TOKEN", required = false) String platformToken) { + if (hasText(platformToken)) { + return tokenLogin(platformToken); + } + if (!hasText(username) || !hasText(passwordEncode)) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请提供登录凭据"); + } + JwtTokenService.Token token = authenticationService.login(username, passwordEncode); + return success(findUser(username), token); + } + + private UserLoginResult tokenLogin(String platformToken) { + VerifiedPlatformToken verified = tokenVerifier.verify(platformToken); + UserEntity user = localSsoAccountService.synchronize(verified); + JwtTokenService.Token token = jwtTokenService.issueFor(user); + return success(user, token); + } + + private UserEntity findUser(String studentId) { + UserEntity user = userMapper.selectActiveByStudentId(studentId); + if (user == null) { + throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份不存在"); + } + return user; + } + + private UserLoginResult success(UserEntity user, JwtTokenService.Token token) { + PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(user.getUserId()); + String authority = "ROLE_" + UserRole.fromId(user.getRoleId()).getAuthority(); + UserLoginResponse response = new UserLoginResponse( + user.getUserId(), user.getUserName(), token.accessToken(), "Bearer", token.expiresIn(), + user.getRoleId(), user.getClassId(), user.getClassName(), user.getSchoolId(), user.getSchoolName(), + snapshot == null ? null : snapshot.getCollegeId(), + snapshot == null ? null : snapshot.getCollegeName(), + snapshot == null ? null : snapshot.getMajorId(), + snapshot == null ? null : snapshot.getMajorName(), + user.getStudentId(), user.getStudentId(), Collections.singletonList(authority)); + return UserLoginResult.success(response); + } + + private boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } +} diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/UserLoginResponse.java b/src/main/java/com/yau/digitalrmb/security/interfaces/UserLoginResponse.java new file mode 100644 index 0000000..0221322 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/UserLoginResponse.java @@ -0,0 +1,28 @@ +package com.yau.digitalrmb.security.interfaces; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class UserLoginResponse { + private final String userId; + private final String name; + private final String accessToken; + private final String tokenType; + private final long expiresIn; + private final Integer roleId; + private final String classId; + private final String className; + private final String schoolId; + private final String schoolName; + private final String collegeId; + private final String collegeName; + private final String majorId; + private final String majorName; + private final String username; + private final String studentId; + private final List authorityCodes; +} diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/UserLoginResult.java b/src/main/java/com/yau/digitalrmb/security/interfaces/UserLoginResult.java new file mode 100644 index 0000000..3047492 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/UserLoginResult.java @@ -0,0 +1,20 @@ +package com.yau.digitalrmb.security.interfaces; + +import lombok.Getter; + +@Getter +public class UserLoginResult { + private final int code; + private final String msg; + private final T data; + + private UserLoginResult(int code, String msg, T data) { + this.code = code; + this.msg = msg; + this.data = data; + } + + public static UserLoginResult success(T data) { + return new UserLoginResult(200, null, data); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 8a14bb0..5eb58ac 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -4,12 +4,16 @@ spring: username: ${DB_USER:root} password: ${DB_PWD:sztzjy2017} driver-class-name: com.mysql.cj.jdbc.Driver + sql: + init: + mode: never springdoc: swagger-ui: enabled: true platform-integration: + enabled: true token: - link-secret-key: ${DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY:local-token-sso-test-secret-key-123456} + link-secret-key: ${DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY:zy_kzy_mnjy_fp76ckwuczzmb67w0a8x0} security: jwt: secret: 0123456789012345678901234567890123456789012345678901234567890123 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 85c016c..05b0d9e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -30,3 +30,5 @@ platform-integration: link-secret-key: ${DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY} frontend: callback-url: ${DIGITAL_RMB_FRONTEND_CALLBACK_URL} +server: + port: 8787 \ No newline at end of file diff --git a/src/main/resources/db/manual/institution_identifier_training.sql b/src/main/resources/db/manual/institution_identifier_training.sql new file mode 100644 index 0000000..cc9b828 --- /dev/null +++ b/src/main/resources/db/manual/institution_identifier_training.sql @@ -0,0 +1,206 @@ +CREATE TABLE IF NOT EXISTS institution_identifier_application ( + id BIGINT PRIMARY KEY, + bank_code VARCHAR(32) NOT NULL, + user_id VARCHAR(36) NOT NULL, + school_id BIGINT NOT NULL, + class_id BIGINT NOT NULL, + request_timestamp CHAR(14) NOT NULL, + digest CHAR(64), + bank_signature VARCHAR(512), + status VARCHAR(32) NOT NULL, + verification_key_id VARCHAR(64), + signature_valid BOOLEAN, + digest_matches BOOLEAN, + signing_key_id VARCHAR(64), + institution_identifier VARCHAR(32), + central_bank_signature VARCHAR(512), + training_round INT NOT NULL DEFAULT 1, + scoring_criteria INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + created_by VARCHAR(64) NOT NULL, + updated_by VARCHAR(64) NOT NULL, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT uk_identifier_training_round UNIQUE (bank_code, created_by, training_round) +); + +CREATE INDEX IF NOT EXISTS idx_institution_identifier_status + ON institution_identifier_application(status); + +CREATE INDEX IF NOT EXISTS idx_identifier_training_user + ON institution_identifier_application(created_by, training_round); + +CREATE TABLE IF NOT EXISTS institution_identifier_operation_log ( + id BIGINT PRIMARY KEY, + application_id BIGINT NOT NULL, + operation VARCHAR(32) NOT NULL, + from_status VARCHAR(32), + to_status VARCHAR(32) NOT NULL, + operation_detail VARCHAR(1024) NOT NULL, + created_at TIMESTAMP NOT NULL, + created_by VARCHAR(64) NOT NULL, + CONSTRAINT fk_identifier_operation_application + FOREIGN KEY (application_id) REFERENCES institution_identifier_application(id) +); + +CREATE INDEX IF NOT EXISTS idx_identifier_operation_application + ON institution_identifier_operation_log(application_id); + +CREATE TABLE IF NOT EXISTS institution_identifier_training_error ( + id BIGINT PRIMARY KEY, + application_id BIGINT NOT NULL, + training_round INT NOT NULL, + error_sequence INT NOT NULL, + error_type VARCHAR(32) NOT NULL, + operation_step VARCHAR(32) NOT NULL, + application_status VARCHAR(32) NOT NULL, + provided_key_id VARCHAR(64), + error_message VARCHAR(512) NOT NULL, + created_at TIMESTAMP NOT NULL, + created_by VARCHAR(64) NOT NULL, + CONSTRAINT uk_identifier_training_error UNIQUE (application_id, error_sequence), + CONSTRAINT fk_identifier_error_application + FOREIGN KEY (application_id) REFERENCES institution_identifier_application(id) +); + +CREATE INDEX IF NOT EXISTS idx_identifier_error_application + ON institution_identifier_training_error(application_id); + +CREATE TABLE IF NOT EXISTS institution_sm2_key ( + id BIGINT PRIMARY KEY, + key_id VARCHAR(64) NOT NULL, + key_name VARCHAR(64) NOT NULL, + key_owner VARCHAR(32) NOT NULL, + key_purpose VARCHAR(32) NOT NULL, + user_id VARCHAR(36) NOT NULL, + school_id BIGINT NOT NULL, + class_id BIGINT NOT NULL, + public_key VARCHAR(256) NOT NULL, + encrypted_private_key VARCHAR(512) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + created_by VARCHAR(64) NOT NULL, + updated_by VARCHAR(64) NOT NULL, + deleted BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS idx_institution_sm2_key_subject + ON institution_sm2_key(user_id, school_id, class_id, key_owner, deleted); + +CREATE TABLE IF NOT EXISTS institution_sm2_key_audit ( + id BIGINT PRIMARY KEY, + key_record_id BIGINT NOT NULL, + key_id VARCHAR(64) NOT NULL, + operation VARCHAR(32) NOT NULL, + user_id VARCHAR(36) NOT NULL, + school_id BIGINT NOT NULL, + class_id BIGINT NOT NULL, + operation_detail VARCHAR(512) NOT NULL, + created_at TIMESTAMP NOT NULL, + created_by VARCHAR(64) NOT NULL, + CONSTRAINT fk_institution_key_audit_key + FOREIGN KEY (key_record_id) REFERENCES institution_sm2_key(id) +); + +CREATE INDEX IF NOT EXISTS idx_institution_key_audit_record + ON institution_sm2_key_audit(key_record_id, created_at); + +CREATE TABLE IF NOT EXISTS currency_generation_request ( + id BIGINT PRIMARY KEY, + identifier_application_id BIGINT NOT NULL, + institution_identifier VARCHAR(32) NOT NULL, + full_institution_identifier CHAR(64) NOT NULL, + amount DECIMAL(18, 2) NOT NULL, + delivery_node_code VARCHAR(32) NOT NULL, + request_timestamp CHAR(14) NOT NULL, + request_original_text VARCHAR(256), + request_digest CHAR(64), + bank_signature VARCHAR(512), + signing_key_id VARCHAR(64), + request_message VARCHAR(2048), + status VARCHAR(32) NOT NULL, + user_id VARCHAR(36) NOT NULL, + school_id BIGINT NOT NULL, + class_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + created_by VARCHAR(64) NOT NULL, + updated_by VARCHAR(64) NOT NULL, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT fk_currency_request_identifier_application + FOREIGN KEY (identifier_application_id) REFERENCES institution_identifier_application(id) +); + +CREATE INDEX IF NOT EXISTS idx_currency_request_identifier_application + ON currency_generation_request(identifier_application_id, user_id, school_id, class_id, deleted); + +CREATE INDEX IF NOT EXISTS idx_currency_request_status + ON currency_generation_request(status); + +CREATE TABLE IF NOT EXISTS currency_generation_request_operation_log ( + id BIGINT PRIMARY KEY, + request_id BIGINT NOT NULL, + operation VARCHAR(32) NOT NULL, + from_status VARCHAR(32), + to_status VARCHAR(32) NOT NULL, + operation_detail VARCHAR(1024) NOT NULL, + created_at TIMESTAMP NOT NULL, + created_by VARCHAR(64) NOT NULL, + CONSTRAINT fk_currency_request_operation_request + FOREIGN KEY (request_id) REFERENCES currency_generation_request(id) +); + +CREATE INDEX IF NOT EXISTS idx_currency_request_operation_request + ON currency_generation_request_operation_log(request_id, created_at); + +CREATE TABLE IF NOT EXISTS currency_generation_verification ( + id BIGINT PRIMARY KEY, + request_id BIGINT NOT NULL, + request_message VARCHAR(2048) NOT NULL, + request_original_text VARCHAR(256) NOT NULL, + request_digest CHAR(64) NOT NULL, + bank_signature VARCHAR(512) NOT NULL, + bank_key_id VARCHAR(64) NOT NULL, + amount DECIMAL(18, 2) NOT NULL, + signature_valid BOOLEAN, + recomputed_digest CHAR(64), + digest_matches BOOLEAN, + total_quota DECIMAL(18, 2), + used_quota DECIMAL(18, 2), + remaining_quota DECIMAL(18, 2), + quota_sufficient BOOLEAN, + central_bank_key_id VARCHAR(64), + central_bank_signature VARCHAR(512), + confirmation_time CHAR(14), + confirmation_message VARCHAR(2048), + status VARCHAR(32) NOT NULL, + user_id VARCHAR(36) NOT NULL, + school_id BIGINT NOT NULL, + class_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + created_by VARCHAR(64) NOT NULL, + updated_by VARCHAR(64) NOT NULL, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT fk_currency_verification_request + FOREIGN KEY (request_id) REFERENCES currency_generation_request(id) +); + +CREATE INDEX IF NOT EXISTS idx_currency_verification_subject + ON currency_generation_verification(request_id, user_id, school_id, class_id, deleted); + +CREATE TABLE IF NOT EXISTS currency_generation_verification_operation_log ( + id BIGINT PRIMARY KEY, + verification_id BIGINT NOT NULL, + operation VARCHAR(32) NOT NULL, + from_status VARCHAR(32), + to_status VARCHAR(32) NOT NULL, + operation_detail VARCHAR(1024) NOT NULL, + created_at TIMESTAMP NOT NULL, + created_by VARCHAR(64) NOT NULL, + CONSTRAINT fk_currency_verification_operation + FOREIGN KEY (verification_id) REFERENCES currency_generation_verification(id) +); + +CREATE INDEX IF NOT EXISTS idx_currency_verification_operation + ON currency_generation_verification_operation_log(verification_id, created_at); diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 3e9453e..4dd495e 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -1,38 +1,29 @@ -CREATE TABLE IF NOT EXISTS sys_user ( - id BIGINT PRIMARY KEY, - username VARCHAR(64) NOT NULL UNIQUE, - password_hash VARCHAR(100) NOT NULL, - enabled BOOLEAN NOT NULL, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, - created_by VARCHAR(64) NOT NULL, - updated_by VARCHAR(64) NOT NULL, - deleted BOOLEAN NOT NULL DEFAULT FALSE -); +DROP TABLE IF EXISTS sys_user_role; +DROP TABLE IF EXISTS sys_role; -CREATE TABLE IF NOT EXISTS sys_role ( - id BIGINT PRIMARY KEY, - name VARCHAR(64) NOT NULL UNIQUE, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, - created_by VARCHAR(64) NOT NULL, - updated_by VARCHAR(64) NOT NULL, - deleted BOOLEAN NOT NULL DEFAULT FALSE -); - -CREATE TABLE IF NOT EXISTS sys_user_role ( - user_id BIGINT NOT NULL, - role_id BIGINT NOT NULL, - PRIMARY KEY (user_id, role_id), - CONSTRAINT fk_user_role_user FOREIGN KEY (user_id) REFERENCES sys_user(id), - CONSTRAINT fk_user_role_role FOREIGN KEY (role_id) REFERENCES sys_role(id) +CREATE TABLE IF NOT EXISTS sys_user ( + user_id VARCHAR(36) PRIMARY KEY, + student_id VARCHAR(255) NULL, + password VARCHAR(255) NOT NULL, + user_name VARCHAR(100) NULL, + class_id VARCHAR(36) NULL, + class_name VARCHAR(100) NULL, + phone VARCHAR(100) NULL, + school_name VARCHAR(100) NULL, + school_id VARCHAR(36) NULL, + authorize_time DATE NULL, + authorize_end_time DATE NULL, + role_id TINYINT NULL, + create_time DATETIME NULL, + is_deleted TINYINT NOT NULL DEFAULT 0, + zy_user_id VARCHAR(50) NULL, + UNIQUE KEY uk_sys_user_student_id (student_id) ); CREATE TABLE IF NOT EXISTS platform_user_snapshot ( - platform_user_id BIGINT PRIMARY KEY, + platform_user_id VARCHAR(36) 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, school_id VARCHAR(64) NULL, @@ -41,7 +32,6 @@ CREATE TABLE IF NOT EXISTS platform_user_snapshot ( college_name VARCHAR(128) NULL, major_id VARCHAR(64) NULL, major_name VARCHAR(128) NULL, - role_id BIGINT NULL, class_id VARCHAR(64) NULL, class_name VARCHAR(128) NULL, student_id VARCHAR(64) NULL @@ -49,14 +39,14 @@ CREATE TABLE IF NOT EXISTS platform_user_snapshot ( CREATE TABLE IF NOT EXISTS auth_login_exchange_code ( code_hash CHAR(64) PRIMARY KEY, - platform_user_id BIGINT NOT NULL, + platform_user_id VARCHAR(36) NOT NULL, expires_at TIMESTAMP NOT NULL, consumed_at TIMESTAMP NULL ); CREATE TABLE IF NOT EXISTS auth_refresh_token ( token_hash CHAR(64) PRIMARY KEY, - platform_user_id BIGINT NOT NULL, + platform_user_id VARCHAR(36) NOT NULL, expires_at TIMESTAMP NOT NULL, revoked_at TIMESTAMP NULL ); @@ -67,7 +57,7 @@ CREATE TABLE IF NOT EXISTS institution_sm2_key ( key_name VARCHAR(64) NOT NULL, key_owner VARCHAR(32) NOT NULL, key_purpose VARCHAR(32) NOT NULL, - user_id BIGINT NOT NULL, + user_id VARCHAR(36) NOT NULL, school_id BIGINT NOT NULL, class_id BIGINT NOT NULL, public_key VARCHAR(256) NOT NULL, @@ -84,7 +74,7 @@ CREATE TABLE IF NOT EXISTS institution_sm2_key_audit ( key_record_id BIGINT NOT NULL, key_id VARCHAR(64) NOT NULL, operation VARCHAR(32) NOT NULL, - user_id BIGINT NOT NULL, + user_id VARCHAR(36) NOT NULL, school_id BIGINT NOT NULL, class_id BIGINT NOT NULL, operation_detail VARCHAR(512) NOT NULL, @@ -94,59 +84,37 @@ CREATE TABLE IF NOT EXISTS institution_sm2_key_audit ( FOREIGN KEY (key_record_id) REFERENCES institution_sm2_key(id) ); -INSERT IGNORE INTO sys_role (id, name, created_at, updated_at, created_by, updated_by, deleted) -VALUES (1001, 'TEACHER', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'SYSTEM', 'SYSTEM', FALSE), - (1002, 'STUDENT', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'SYSTEM', 'SYSTEM', FALSE); - -INSERT INTO sys_user (id, username, password_hash, enabled, created_at, updated_at, created_by, updated_by, deleted) -VALUES (487, 'tzs001', '$2a$10$ufcw5KFHtOmLzxAsV4C.MuIDOErMlw0iw5J5hc8OMzaTx0u9QYxG6', TRUE, - CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'SYSTEM', 'SYSTEM', FALSE) -ON DUPLICATE KEY UPDATE username = VALUES(username), password_hash = VALUES(password_hash), enabled = TRUE, - updated_at = CURRENT_TIMESTAMP, updated_by = 'SYSTEM', deleted = FALSE; - -INSERT INTO platform_user_snapshot (platform_user_id, account, display_name, role_key, source_updated_at, synced_at) -VALUES (487, 'tzs001', 'tzs001', 'STUDENT', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -ON DUPLICATE KEY UPDATE account = VALUES(account), display_name = VALUES(display_name), role_key = VALUES(role_key), - source_updated_at = CURRENT_TIMESTAMP, synced_at = CURRENT_TIMESTAMP; +INSERT INTO sys_user ( + user_id, student_id, password, user_name, role_id, is_deleted, zy_user_id +) +VALUES ( + '00000000-0000-0000-0000-000000000487', + 'tzs001', + '$2a$10$ufcw5KFHtOmLzxAsV4C.MuIDOErMlw0iw5J5hc8OMzaTx0u9QYxG6', + 'tzs001', + 4, + 0, + '487' +) +ON DUPLICATE KEY UPDATE + student_id = VALUES(student_id), + password = VALUES(password), + user_name = VALUES(user_name), + role_id = VALUES(role_id), + is_deleted = 0; -INSERT IGNORE INTO sys_user_role (user_id, role_id) VALUES (487, 1002); --- --- CREATE TABLE IF NOT EXISTS institution_identifier_application ( --- id BIGINT PRIMARY KEY, --- bank_code VARCHAR(32) NOT NULL, --- request_timestamp CHAR(14) NOT NULL, --- digest CHAR(64), --- bank_signature VARCHAR(512), --- status VARCHAR(32) NOT NULL, --- verification_key_id VARCHAR(64), --- signature_valid BOOLEAN, --- digest_matches BOOLEAN, --- signing_key_id VARCHAR(64), --- institution_identifier VARCHAR(32), --- central_bank_signature VARCHAR(512), --- created_at TIMESTAMP NOT NULL, --- updated_at TIMESTAMP NOT NULL, --- created_by VARCHAR(64) NOT NULL, --- updated_by VARCHAR(64) NOT NULL, --- deleted BOOLEAN NOT NULL DEFAULT FALSE, --- CONSTRAINT uk_institution_identifier_bank UNIQUE (bank_code) --- ); --- --- CREATE INDEX IF NOT EXISTS idx_institution_identifier_status --- ON institution_identifier_application(status); --- --- CREATE TABLE IF NOT EXISTS institution_identifier_operation_log ( --- id BIGINT PRIMARY KEY, --- application_id BIGINT NOT NULL, --- operation VARCHAR(32) NOT NULL, --- from_status VARCHAR(32), --- to_status VARCHAR(32) NOT NULL, --- operation_detail VARCHAR(1024) NOT NULL, --- created_at TIMESTAMP NOT NULL, --- created_by VARCHAR(64) NOT NULL, --- CONSTRAINT fk_identifier_operation_application --- FOREIGN KEY (application_id) REFERENCES institution_identifier_application(id) --- ); --- --- CREATE INDEX IF NOT EXISTS idx_identifier_operation_application --- ON institution_identifier_operation_log(application_id); +INSERT INTO platform_user_snapshot ( + platform_user_id, account, display_name, source_updated_at, synced_at +) +VALUES ( + '00000000-0000-0000-0000-000000000487', + 'tzs001', + 'tzs001', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +) +ON DUPLICATE KEY UPDATE + account = VALUES(account), + display_name = VALUES(display_name), + source_updated_at = CURRENT_TIMESTAMP, + synced_at = CURRENT_TIMESTAMP; diff --git a/src/test/java/com/yau/digitalrmb/identity/IdentityPersistenceTest.java b/src/test/java/com/yau/digitalrmb/identity/IdentityPersistenceTest.java index 9e90465..525a308 100644 --- a/src/test/java/com/yau/digitalrmb/identity/IdentityPersistenceTest.java +++ b/src/test/java/com/yau/digitalrmb/identity/IdentityPersistenceTest.java @@ -7,6 +7,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; +import java.util.UUID; + import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest @@ -16,17 +18,18 @@ class IdentityPersistenceTest { private UserMapper userMapper; @Test - void mapperPersistsAuditedUser() { + void mapperPersistsUserWithUuidPrimaryKey() { UserEntity user = new UserEntity(); - user.setUsername("operator"); - user.setPasswordHash("hash"); - user.setEnabled(true); + user.setStudentId("operator-001"); + user.setPassword("hash"); + user.setUserName("Operator"); + user.setRoleId(1); + user.setIsDeleted(false); userMapper.insert(user); - assertThat(user.getId()).isNotNull(); - assertThat(userMapper.selectById(user.getId()).getUsername()).isEqualTo("operator"); - assertThat(user.getCreatedAt()).isNotNull(); - assertThat(user.getCreatedBy()).isEqualTo("SYSTEM"); + assertThat(user.getUserId()).isNotNull(); + assertThat(UUID.fromString(user.getUserId()).toString()).isEqualTo(user.getUserId()); + assertThat(userMapper.selectById(user.getUserId()).getStudentId()).isEqualTo("operator-001"); } } diff --git a/src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java b/src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java index cdac355..636c4b1 100644 --- a/src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java +++ b/src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java @@ -17,52 +17,75 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest @ActiveProfiles("test") class LocalSsoAccountServiceTest { + private static final String USER_601 = "00000000-0000-0000-0000-000000000601"; + private static final String USER_602 = "00000000-0000-0000-0000-000000000602"; + private static final String USER_603 = "00000000-0000-0000-0000-000000000603"; @Autowired private LocalSsoAccountService service; @Autowired private DataSource dataSource; @Autowired private PasswordEncoder passwordEncoder; @Test void createsLocalUserWithTokenPasswordAndStudentRole() { - service.synchronize(new VerifiedPlatformToken(601L, "sso601", "张三", "first-password", "STUDENT")); + service.synchronize(new VerifiedPlatformToken(USER_601, "sso601", "张三", "first-password", 4)); JdbcTemplate jdbc = new JdbcTemplate(dataSource); - String passwordHash = jdbc.queryForObject("SELECT password_hash FROM sys_user WHERE id = 601", String.class); - Long roleId = jdbc.queryForObject("SELECT role_id FROM sys_user_role WHERE user_id = 601", Long.class); + String passwordHash = jdbc.queryForObject( + "SELECT password FROM sys_user WHERE user_id = ?", String.class, USER_601); + Integer roleId = jdbc.queryForObject( + "SELECT role_id FROM sys_user WHERE user_id = ?", Integer.class, USER_601); assertThat(passwordEncoder.matches("first-password", passwordHash)).isTrue(); - assertThat(roleId).isEqualTo(1002L); + assertThat(roleId).isEqualTo(4); } @Test void refreshesLocalPasswordWhenPlatformPasswordChanges() { - service.synchronize(new VerifiedPlatformToken(602L, "sso602", "李四", "old-password", "STUDENT")); - service.synchronize(new VerifiedPlatformToken(602L, "sso602-new", "李四", "new-password", "TEACHER")); + service.synchronize(new VerifiedPlatformToken(USER_602, "sso602", "李四", "old-password", 4)); + service.synchronize(new VerifiedPlatformToken(USER_602, "sso602-new", "李四", "new-password", 3)); JdbcTemplate jdbc = new JdbcTemplate(dataSource); - String passwordHash = jdbc.queryForObject("SELECT password_hash FROM sys_user WHERE id = 602", String.class); - Long roleId = jdbc.queryForObject("SELECT role_id FROM sys_user_role WHERE user_id = 602", Long.class); + String passwordHash = jdbc.queryForObject( + "SELECT password FROM sys_user WHERE user_id = ?", String.class, USER_602); + Integer roleId = jdbc.queryForObject( + "SELECT role_id FROM sys_user WHERE user_id = ?", Integer.class, USER_602); assertThat(passwordEncoder.matches("new-password", passwordHash)).isTrue(); assertThat(passwordEncoder.matches("old-password", passwordHash)).isFalse(); - assertThat(roleId).isEqualTo(1001L); + assertThat(roleId).isEqualTo(3); } @Test void synchronizesCompleteProfileIntoLocalSnapshot() { - service.synchronize(completeToken(603L, "sso603", 2L)); + service.synchronize(completeToken(USER_603, "sso603", 4L)); - Map row = new JdbcTemplate(dataSource).queryForMap( - "SELECT school_id, college_name, major_name, role_id, class_name, student_id " - + "FROM platform_user_snapshot WHERE platform_user_id = 603"); + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + Map user = jdbc.queryForMap( + "SELECT user_id, student_id, password, user_name, class_id, class_name, school_id, school_name, " + + "role_id, is_deleted, zy_user_id FROM sys_user WHERE user_id = ?", USER_603); + assertThat(user).containsEntry("user_id", USER_603) + .containsEntry("student_id", "20240603") + .containsEntry("user_name", "Test Student") + .containsEntry("class_id", "202401") + .containsEntry("class_name", "Class 1") + .containsEntry("school_id", "610000") + .containsEntry("school_name", "Yan'an University") + .containsEntry("role_id", 4) + .containsEntry("is_deleted", 0) + .containsEntry("zy_user_id", USER_603); + String userPassword = (String) user.get("password"); + assertThat(passwordEncoder.matches("password", userPassword)).isTrue(); + + Map row = jdbc.queryForMap( + "SELECT school_id, college_name, major_name, class_name, student_id " + + "FROM platform_user_snapshot WHERE platform_user_id = ?", USER_603); assertThat(row).containsEntry("school_id", "610000") .containsEntry("college_name", "Computer College") .containsEntry("major_name", "Software Engineering") - .containsEntry("role_id", 2L) .containsEntry("class_name", "Class 1") - .containsEntry("student_id", "20240001"); + .containsEntry("student_id", "20240603"); } - private VerifiedPlatformToken completeToken(long userId, String username, long roleId) { - return new VerifiedPlatformToken(userId, username, "Test Student", "password", roleId, "STUDENT", + private VerifiedPlatformToken completeToken(String userId, String username, long roleId) { + return new VerifiedPlatformToken(userId, username, "Test Student", "password", roleId, "610000", "Yan'an University", "100", "Computer College", "101", "Software Engineering", - "202401", "Class 1", "20240001"); + "202401", "Class 1", "20240603"); } } diff --git a/src/test/java/com/yau/digitalrmb/institutionidentity/CurrencyGenerationVerificationDomainTest.java b/src/test/java/com/yau/digitalrmb/institutionidentity/CurrencyGenerationVerificationDomainTest.java new file mode 100644 index 0000000..7258945 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/institutionidentity/CurrencyGenerationVerificationDomainTest.java @@ -0,0 +1,58 @@ +package com.yau.digitalrmb.institutionidentity; + +import com.yau.digitalrmb.institutionidentity.domain.CurrencyGenerationVerification; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CurrencyGenerationVerificationDomainTest { + @Test + void completesAllStepThreeStatesInOrder() { + CurrencyGenerationVerification value = value(); + assertThat(value.getStatus()).isEqualTo(CurrencyGenerationVerification.Status.RECEIVED); + value.verifySignature(true); + value.verifyDigest("A123456789012345678901234567890123456789012345678901234567890123", true); + value.verifyQuota(new BigDecimal("500000000.00"), new BigDecimal("495000000.00"), + new BigDecimal("5000000.00")); + value.confirm("CENTRAL_FIRST_SM2_KEY", "3044AABB", "20260804210000", "{confirmed:true}"); + value.returnResponse(); + assertThat(value.getStatus()).isEqualTo(CurrencyGenerationVerification.Status.RESPONSE_RETURNED); + assertThat(value.getSignatureValid()).isTrue(); + assertThat(value.getDigestMatches()).isTrue(); + assertThat(value.getQuotaSufficient()).isTrue(); + } + + @Test + void rejectsSkippedAndRepeatedOperations() { + CurrencyGenerationVerification value = value(); + assertThatThrownBy(() -> value.verifyDigest( + "A123456789012345678901234567890123456789012345678901234567890123", true)) + .isInstanceOf(IllegalStateException.class); + value.verifySignature(true); + assertThatThrownBy(() -> value.verifySignature(true)).isInstanceOf(IllegalStateException.class); + } + + @Test + void rejectsInvalidSignatureDigestAndInsufficientQuotaWithoutAdvancing() { + CurrencyGenerationVerification value = value(); + assertThatThrownBy(() -> value.verifySignature(false)).isInstanceOf(IllegalArgumentException.class); + assertThat(value.getStatus()).isEqualTo(CurrencyGenerationVerification.Status.RECEIVED); + value.verifySignature(true); + assertThatThrownBy(() -> value.verifyDigest("BAD", false)).isInstanceOf(IllegalArgumentException.class); + assertThat(value.getStatus()).isEqualTo(CurrencyGenerationVerification.Status.SIGNATURE_VERIFIED); + value.verifyDigest("A123456789012345678901234567890123456789012345678901234567890123", true); + assertThatThrownBy(() -> value.verifyQuota(new BigDecimal("500000000.00"), + new BigDecimal("499999999.00"), new BigDecimal("1.00"))) + .isInstanceOf(IllegalArgumentException.class); + assertThat(value.getStatus()).isEqualTo(CurrencyGenerationVerification.Status.DIGEST_VERIFIED); + } + + private CurrencyGenerationVerification value() { + return CurrencyGenerationVerification.receive(1L, "message", "ORG_123456789ABC|1598.85|SYS_DC_002|20260804210000", + "A123456789012345678901234567890123456789012345678901234567890123", + "3044AABB", "BANK_SECOND_SM2_KEY", new BigDecimal("1598.85")); + } +} diff --git a/src/test/java/com/yau/digitalrmb/institutionidentity/InstitutionIdentifierControllerTest.java b/src/test/java/com/yau/digitalrmb/institutionidentity/InstitutionIdentifierControllerTest.java new file mode 100644 index 0000000..1b5a085 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/institutionidentity/InstitutionIdentifierControllerTest.java @@ -0,0 +1,331 @@ +package com.yau.digitalrmb.institutionidentity; + +import com.jayway.jsonpath.JsonPath; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyService; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierApplicationEntity; +import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierApplicationMapper; +import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierOperationLogEntity; +import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierOperationLogMapper; +import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierTrainingErrorEntity; +import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierTrainingErrorMapper; +import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionPrivateKeyCipher; +import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionSm2KeyEntity; +import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionSm2KeyMapper; +import com.yau.digitalrmb.security.application.JwtTokenService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +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; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class InstitutionIdentifierControllerTest { + private static final String BASE_PATH = "/api/v1/institution-identifiers"; + private static final String USER_ID = "00000000-0000-0000-0000-000000009001"; + + @Autowired + private MockMvc mvc; + + @Autowired + private JwtTokenService jwtTokenService; + + @Autowired + private InstitutionIdentifierApplicationMapper applicationMapper; + + @Autowired + private InstitutionIdentifierOperationLogMapper operationLogMapper; + + @Autowired + private InstitutionIdentifierTrainingErrorMapper trainingErrorMapper; + + @Autowired + private InstitutionSm2KeyMapper keyMapper; + + @Autowired + private InstitutionPrivateKeyCipher privateKeyCipher; + + @Autowired + private InstitutionKeyService keyService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private String authorization; + private String keyAuthorization; + private String tokenWithoutSubjectClaims; + + @BeforeEach + void setUp() { + trainingErrorMapper.delete(null); + operationLogMapper.delete(null); + applicationMapper.delete(null); + jdbcTemplate.update("DELETE FROM institution_sm2_key_audit"); + jdbcTemplate.update("DELETE FROM institution_sm2_key"); + keyAuthorization = "Bearer " + jwtTokenService.issueFor(USER_ID, 1001L, 2001L, + "institution-key-tester", Collections.singleton("STUDENT")).accessToken(); + authorization = keyAuthorization; + tokenWithoutSubjectClaims = "Bearer " + jwtTokenService.issueFor("institution-api-tester").accessToken(); + InstitutionKeySubject subject = new InstitutionKeySubject(USER_ID, 1001L, 2001L); + keyService.commercialBankPublicKey(subject, "institution-key-tester"); + keyService.centralBankPublicKey(subject, "institution-key-tester"); + } + + @Test + void bankAndCentralKeyEndpointsPersistEncryptedPrivateKeysForTokenSubject() throws Exception { + String bankResponse = mvc.perform(post(BASE_PATH + "/keys/commercial-bank") + .header("Authorization", keyAuthorization)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.keyId").value(InstitutionKeyService.BANK_SECOND_KEY)) + .andExpect(jsonPath("$.data.owner").value("COMMERCIAL_BANK")) + .andExpect(jsonPath("$.data.userId").value(USER_ID)) + .andExpect(jsonPath("$.data.schoolId").value(1001)) + .andExpect(jsonPath("$.data.classId").value(2001)) + .andExpect(jsonPath("$.data.publicKey").value(org.hamcrest.Matchers.matchesPattern("04[0-9A-F]{128}"))) + .andExpect(jsonPath("$.data.privateKey").value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}"))) + .andReturn().getResponse().getContentAsString(); + + mvc.perform(post(BASE_PATH + "/keys/central-bank") + .header("Authorization", keyAuthorization)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.keyId").value(InstitutionKeyService.CENTRAL_FIRST_KEY)) + .andExpect(jsonPath("$.data.owner").value("CENTRAL_BANK")) + .andExpect(jsonPath("$.data.publicKey").value(org.hamcrest.Matchers.matchesPattern("04[0-9A-F]{128}"))) + .andExpect(jsonPath("$.data.privateKey").value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}"))); + + List keys = keyMapper.selectList(null); + assertThat(keys).hasSize(2); + String returnedPrivateKey = JsonPath.read(bankResponse, "$.data.privateKey"); + InstitutionSm2KeyEntity bankKey = keys.stream() + .filter(key -> InstitutionKeyService.BANK_SECOND_KEY.equals(key.getKeyId())) + .findFirst().orElseThrow(AssertionError::new); + assertThat(bankKey.getEncryptedPrivateKey()).isNotEqualTo(returnedPrivateKey); + assertThat(privateKeyCipher.decrypt(bankKey.getEncryptedPrivateKey())).isEqualTo(returnedPrivateKey); + } + + @Test + void logicalDeleteIsScopedToTokenSubjectAndAllowsRegeneration() throws Exception { + String firstResponse = mvc.perform(post(BASE_PATH + "/keys/commercial-bank") + .header("Authorization", keyAuthorization)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + Number firstRecordId = JsonPath.read(firstResponse, "$.data.recordId"); + + mvc.perform(delete(BASE_PATH + "/keys/commercial-bank") + .header("Authorization", keyAuthorization)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").doesNotExist()); + + Integer deleted = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM institution_sm2_key WHERE id = ? AND deleted = TRUE", + Integer.class, firstRecordId.longValue()); + assertThat(deleted).isEqualTo(1); + + String regeneratedResponse = mvc.perform(post(BASE_PATH + "/keys/commercial-bank") + .header("Authorization", keyAuthorization)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + Number regeneratedRecordId = JsonPath.read(regeneratedResponse, "$.data.recordId"); + assertThat(regeneratedRecordId.longValue()).isNotEqualTo(firstRecordId.longValue()); + assertThat(keyMapper.selectList(null)).hasSize(2); + } + + @Test + void keyEndpointRejectsTokenWithoutSchoolAndClassClaims() throws Exception { + mvc.perform(post(BASE_PATH + "/keys/commercial-bank") + .header("Authorization", tokenWithoutSubjectClaims)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("Token缺少userId")); + } + + @Test + void protectedStepRejectsMissingJwt() throws Exception { + mvc.perform(post(BASE_PATH + "/steps/prepare") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"bankCode\":\"BKCHCNBJ20001\"}")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("UNAUTHORIZED")); + } + + @Test + void skippedStepReturnsBadRequestAndDoesNotChangePersistedState() throws Exception { + long applicationId = prepare("BKCHCNBJ20002"); + + mvc.perform(post(BASE_PATH + "/steps/sign") + .header("Authorization", authorization) + .contentType(MediaType.APPLICATION_JSON) + .content(stepRequest(applicationId, InstitutionKeyService.BANK_SECOND_KEY))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.message") + .value(org.hamcrest.Matchers.containsString("当前步骤不是商业银行签名,不能操作"))); + + InstitutionIdentifierApplicationEntity application = applicationMapper.selectById(applicationId); + assertThat(application.getStatus()).isEqualTo("PREPARED"); + assertThat(application.getDigest()).isNull(); + assertThat(application.getBankSignature()).isNull(); + assertThat(application.getScoringCriteria()).isEqualTo(1); + + List errors = trainingErrorMapper.selectList(null); + assertThat(errors).filteredOn(error -> error.getApplicationId().equals(applicationId)) + .extracting(InstitutionIdentifierTrainingErrorEntity::getErrorSequence, + InstitutionIdentifierTrainingErrorEntity::getErrorType) + .containsExactly(org.assertj.core.groups.Tuple.tuple(1, "INVALID_STEP")); + + List logs = operationLogMapper.selectList(null); + assertThat(logs).filteredOn(log -> log.getApplicationId().equals(applicationId)) + .extracting(InstitutionIdentifierOperationLogEntity::getOperation) + .containsExactly("PREPARE"); + } + + @Test + void wrongKeysIncreaseScoringCriteriaAndRecordErrorSequence() throws Exception { + long applicationId = prepare("BKCHCNBJ20004"); + mvc.perform(post(BASE_PATH + "/steps/digest") + .header("Authorization", authorization) + .contentType(MediaType.APPLICATION_JSON) + .content(stepRequest(applicationId, null))) + .andExpect(status().isOk()); + + mvc.perform(post(BASE_PATH + "/steps/sign") + .header("Authorization", authorization) + .contentType(MediaType.APPLICATION_JSON) + .content(stepRequest(applicationId, "WRONG_PRIVATE_KEY"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("第1次错误"))); + + mvc.perform(post(BASE_PATH + "/steps/sign") + .header("Authorization", authorization) + .contentType(MediaType.APPLICATION_JSON) + .content(stepRequest(applicationId, "ANOTHER_WRONG_KEY"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("第2次错误"))); + + InstitutionIdentifierApplicationEntity application = applicationMapper.selectById(applicationId); + assertThat(application.getStatus()).isEqualTo("DIGESTED"); + assertThat(application.getScoringCriteria()).isEqualTo(2); + assertThat(trainingErrorMapper.selectList(null)) + .filteredOn(error -> error.getApplicationId().equals(applicationId)) + .extracting(InstitutionIdentifierTrainingErrorEntity::getErrorSequence, + InstitutionIdentifierTrainingErrorEntity::getProvidedKeyId) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(1, "WRONG_PRIVATE_KEY"), + org.assertj.core.groups.Tuple.tuple(2, "ANOTHER_WRONG_KEY")); + } + + @Test + void prepareSameBankAgainCreatesNewTrainingRoundWithoutOverwritingHistory() throws Exception { + long firstApplicationId = prepare("BKCHCNBJ20005"); + long secondApplicationId = prepare("BKCHCNBJ20005"); + + assertThat(secondApplicationId).isNotEqualTo(firstApplicationId); + InstitutionIdentifierApplicationEntity first = applicationMapper.selectById(firstApplicationId); + InstitutionIdentifierApplicationEntity second = applicationMapper.selectById(secondApplicationId); + assertThat(first.getTrainingRound()).isEqualTo(1); + assertThat(second.getTrainingRound()).isEqualTo(2); + assertThat(first.getScoringCriteria()).isZero(); + assertThat(second.getScoringCriteria()).isZero(); + } + + @Test + void completeApiWorkflowUsesApplicationIdAndPersistsIssuedResult() throws Exception { + long applicationId = prepare("BKCHCNBJ20003"); + + mvc.perform(post(BASE_PATH + "/steps/digest") + .header("Authorization", authorization) + .contentType(MediaType.APPLICATION_JSON) + .content(stepRequest(applicationId, null))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.applicationId").value(applicationId)) + .andExpect(jsonPath("$.data.status").value("DIGESTED")) + .andExpect(jsonPath("$.data.algorithm").value("SM3")) + .andExpect(jsonPath("$.data.digest").isNotEmpty()); + + mvc.perform(post(BASE_PATH + "/steps/sign") + .header("Authorization", authorization) + .contentType(MediaType.APPLICATION_JSON) + .content(stepRequest(applicationId, InstitutionKeyService.BANK_SECOND_KEY))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("SIGNED")) + .andExpect(jsonPath("$.data.signatureAlgorithm").value("SM2_WITH_SM3")) + .andExpect(jsonPath("$.data.signatureHashAlgorithm").value("SM3")); + + mvc.perform(post(BASE_PATH + "/steps/package") + .header("Authorization", authorization) + .contentType(MediaType.APPLICATION_JSON) + .content(stepRequest(applicationId, null))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PACKAGED")); + + mvc.perform(post(BASE_PATH + "/applications/verification") + .header("Authorization", authorization) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"applicationId\":" + applicationId + ",\"verificationKeyId\":\"" + + InstitutionKeyService.BANK_SECOND_KEY + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("VERIFIED")) + .andExpect(jsonPath("$.data.signatureValid").value(true)) + .andExpect(jsonPath("$.data.digestMatches").value(true)); + + mvc.perform(post(BASE_PATH + "/applications/confirmation") + .header("Authorization", authorization) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"applicationId\":" + applicationId + ",\"signingKeyId\":\"" + + InstitutionKeyService.CENTRAL_FIRST_KEY + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ISSUED")) + .andExpect(jsonPath("$.data.signatureAlgorithm").value("SM2_WITH_SM3")) + .andExpect(jsonPath("$.data.institutionIdentifier").value(org.hamcrest.Matchers.startsWith("ORG_"))); + + InstitutionIdentifierApplicationEntity issued = applicationMapper.selectById(applicationId); + assertThat(issued.getStatus()).isEqualTo("ISSUED"); + assertThat(issued.getDigest()).matches("[0-9A-F]{64}"); + assertThat(issued.getBankSignature()).isNotBlank(); + assertThat(issued.getCentralBankSignature()).isNotBlank(); + assertThat(issued.getInstitutionIdentifier()).startsWith("ORG_"); + + List logs = operationLogMapper.selectList(null); + assertThat(logs).filteredOn(log -> log.getApplicationId().equals(applicationId)) + .extracting(InstitutionIdentifierOperationLogEntity::getOperation) + .containsExactly("PREPARE", "DIGEST", "SIGN", "PACKAGE", "VERIFY", "CONFIRM"); + } + + private long prepare(String bankCode) throws Exception { + String response = mvc.perform(post(BASE_PATH + "/steps/prepare") + .header("Authorization", authorization) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"bankCode\":\"" + bankCode + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.applicationId").isNumber()) + .andExpect(jsonPath("$.data.status").value("PREPARED")) + .andExpect(jsonPath("$.data.bankCode").value(bankCode)) + .andExpect(jsonPath("$.data.trainingRound").isNumber()) + .andExpect(jsonPath("$.data.scoringCriteria").value(0)) + .andReturn().getResponse().getContentAsString(); + Number applicationId = JsonPath.read(response, "$.data.applicationId"); + return applicationId.longValue(); + } + + private String stepRequest(long applicationId, String keyId) { + if (keyId == null) { + return "{\"applicationId\":" + applicationId + "}"; + } + return "{\"applicationId\":" + applicationId + ",\"keyId\":\"" + keyId + "\"}"; + } +} diff --git a/src/test/java/com/yau/digitalrmb/institutionidentity/InstitutionIdentifierWorkflowTest.java b/src/test/java/com/yau/digitalrmb/institutionidentity/InstitutionIdentifierWorkflowTest.java new file mode 100644 index 0000000..9504c38 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/institutionidentity/InstitutionIdentifierWorkflowTest.java @@ -0,0 +1,106 @@ +//package com.yau.digitalrmb.institutionidentity; +// +//import com.yau.digitalrmb.institutionidentity.application.InstitutionApplicationVerificationResult; +//import com.yau.digitalrmb.institutionidentity.application.InstitutionApplicationVerificationService; +//import com.yau.digitalrmb.institutionidentity.application.InstitutionIdentifierConfirmationResult; +//import com.yau.digitalrmb.institutionidentity.application.InstitutionIdentifierStepService; +//import com.yau.digitalrmb.institutionidentity.application.InstitutionInformationPreparationResult; +//import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyService; +//import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +//import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierApplicationEntity; +//import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierApplicationMapper; +//import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierOperationLogEntity; +//import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierOperationLogMapper; +//import com.yau.digitalrmb.shared.exception.BusinessException; +//import org.junit.jupiter.api.Test; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.boot.test.context.SpringBootTest; +//import org.springframework.test.context.ActiveProfiles; +// +//import java.util.List; +// +//import static org.assertj.core.api.Assertions.assertThat; +//import static org.assertj.core.api.Assertions.assertThatThrownBy; +// +//@SpringBootTest +//@ActiveProfiles("test") +//class InstitutionIdentifierWorkflowTest { +// private static final InstitutionKeySubject SUBJECT = new InstitutionKeySubject(9101L, 1101L, 2101L); +// +// @Autowired +// private InstitutionIdentifierStepService stepService; +// +// @Autowired +// private InstitutionApplicationVerificationService verificationService; +// +// @Autowired +// private InstitutionIdentifierApplicationMapper applicationMapper; +// +// @Autowired +// private InstitutionIdentifierOperationLogMapper operationLogMapper; +// +// @Autowired +// private InstitutionKeyService keyService; +// +// @Test +// void everyStepIsPersistedAndCannotBeSkipped() { +// keyService.commercialBankPublicKey(SUBJECT, "tester"); +// keyService.centralBankPublicKey(SUBJECT, "tester"); +// InstitutionInformationPreparationResult prepared = stepService.prepare("BKCHCNBJ10001", SUBJECT, "tester"); +// long applicationId = prepared.getApplicationId(); +// assertStatus(applicationId, "PREPARED"); +// +// assertThatThrownBy(() -> stepService.sign(applicationId, InstitutionKeyService.BANK_SECOND_KEY, +// SUBJECT, "tester")) +// .isInstanceOf(BusinessException.class) +// .hasMessageContaining("当前步骤不是商业银行签名"); +// assertStatus(applicationId, "PREPARED"); +// +// stepService.digest(applicationId, SUBJECT, "tester"); +// assertStatus(applicationId, "DIGESTED"); +// +// stepService.sign(applicationId, InstitutionKeyService.BANK_SECOND_KEY, SUBJECT, "tester"); +// assertStatus(applicationId, "SIGNED"); +// +// stepService.packageApplication(applicationId, SUBJECT, "tester"); +// assertStatus(applicationId, "PACKAGED"); +// +// InstitutionApplicationVerificationResult verification = verificationService.verify(applicationId, +// InstitutionKeyService.BANK_SECOND_KEY, SUBJECT, "tester"); +// assertThat(verification.isVerified()).isTrue(); +// assertStatus(applicationId, "VERIFIED"); +// +// InstitutionIdentifierConfirmationResult confirmation = verificationService.confirm(applicationId, +// InstitutionKeyService.CENTRAL_FIRST_KEY, SUBJECT, "tester"); +// assertThat(confirmation.getStatus()).isEqualTo("ISSUED"); +// assertThat(confirmation.getInstitutionIdentifier()).startsWith("ORG_"); +// +// InstitutionIdentifierApplicationEntity issued = applicationMapper.selectById(applicationId); +// assertThat(issued.getStatus()).isEqualTo("ISSUED"); +// assertThat(issued.getDigest()).matches("[0-9A-F]{64}"); +// assertThat(issued.getBankSignature()).matches("[0-9A-F]+"); +// assertThat(issued.getSignatureValid()).isTrue(); +// assertThat(issued.getDigestMatches()).isTrue(); +// assertThat(issued.getCentralBankSignature()).matches("[0-9A-F]+"); +// +// List logs = operationLogMapper.selectList(null); +// assertThat(logs).filteredOn(log -> log.getApplicationId().equals(applicationId)) +// .extracting(InstitutionIdentifierOperationLogEntity::getOperation) +// .containsExactly("PREPARE", "DIGEST", "SIGN", "PACKAGE", "VERIFY", "CONFIRM"); +// } +// +// @Test +// void completedStepCannotBeRepeated() { +// InstitutionInformationPreparationResult prepared = stepService.prepare("BKCHCNBJ10002", SUBJECT, "tester"); +// stepService.digest(prepared.getApplicationId(), SUBJECT, "tester"); +// +// assertThatThrownBy(() -> stepService.digest(prepared.getApplicationId(), SUBJECT, "tester")) +// .isInstanceOf(BusinessException.class) +// .hasMessageContaining("当前步骤不是摘要计算"); +// assertStatus(prepared.getApplicationId(), "DIGESTED"); +// } +// +// private void assertStatus(long applicationId, String expectedStatus) { +// assertThat(applicationMapper.selectById(applicationId).getStatus()).isEqualTo(expectedStatus); +// } +//} diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java index 467d2e3..ebebf78 100644 --- a/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java +++ b/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java @@ -21,6 +21,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; class PlatformTokenVerifierTest { private static final String SECRET = "local-token-sso-test-secret-key-123456"; + private static final String USER_ID = "00000000-0000-0000-0000-000000000487"; private final Instant now = Instant.parse("2026-08-03T04:00:00Z"); private PlatformTokenVerifier verifier; @@ -33,43 +34,70 @@ class PlatformTokenVerifierTest { @Test void acceptsStandardThreeSegmentTokenWithoutPlatformDatabaseIdentity() throws Exception { - VerifiedPlatformToken verified = verifier.verify(token(487L, "tzs001", "new-password", 2L, now.plus(Duration.ofMinutes(5)))); + VerifiedPlatformToken verified = verifier.verify(token(USER_ID, "tzs001", "new-password", 4L, now.plus(Duration.ofMinutes(5)))); - assertThat(verified.getUserId()).isEqualTo(487L); + assertThat(verified.getUserId()).isEqualTo(USER_ID); assertThat(verified.getUsername()).isEqualTo("tzs001"); assertThat(verified.getRawPassword()).isEqualTo("new-password"); - assertThat(verified.getRoleKey()).isEqualTo("STUDENT"); + assertThat(verified.getRoleId()).isEqualTo(4); } @Test void parsesCompleteUserProfileFromVerifiedToken() throws Exception { - VerifiedPlatformToken verified = verifier.verify(token(487L, "tzs001", "new-password", 2L, + VerifiedPlatformToken verified = verifier.verify(token(USER_ID, "tzs001", "new-password", 4L, now.plus(Duration.ofMinutes(5)), completeProfile())); assertThat(verified.getSchoolId()).isEqualTo("610000"); assertThat(verified.getSchoolName()).isEqualTo("Yan'an University"); assertThat(verified.getCollegeName()).isEqualTo("Computer College"); assertThat(verified.getMajorName()).isEqualTo("Software Engineering"); - assertThat(verified.getRoleId()).isEqualTo(2L); + assertThat(verified.getRoleId()).isEqualTo(4); assertThat(verified.getName()).isEqualTo("Test Student"); assertThat(verified.getClassId()).isEqualTo("202401"); assertThat(verified.getStudentId()).isEqualTo("20240001"); } + @Test + void acceptsCrossTrainingPlatformClaimNamesAndNumericPlatformUserId() throws Exception { + Map profile = new HashMap(); + profile.put("name", "Cross Student"); + profile.put("schoolId", 610000); + profile.put("school", "Yan'an University"); + profile.put("collegeId", 100); + profile.put("collegeName", "Computer College"); + profile.put("majorId", 101); + profile.put("major", "Software Engineering"); + profile.put("classId", 202401); + profile.put("class", "Class 1"); + profile.put("studentNo", "20240002"); + + VerifiedPlatformToken verified = verifier.verify( + token("9001", "cross001", "token-password", 4L, + now.plus(Duration.ofMinutes(5)), profile)); + + assertThat(verified.getUserId()).isEqualTo("9001"); + assertThat(verified.getStudentId()).isEqualTo("20240002"); + assertThat(verified.getSchoolName()).isEqualTo("Yan'an University"); + assertThat(verified.getClassName()).isEqualTo("Class 1"); + assertThat(verified.getMajorName()).isEqualTo("Software Engineering"); + } + @Test void rejectsTamperedExpiredOrIncompleteToken() throws Exception { - String valid = token(487L, "tzs001", "new-password", 3L, now.plus(Duration.ofMinutes(5))); + String valid = token(USER_ID, "tzs001", "new-password", 3L, now.plus(Duration.ofMinutes(5))); assertThatThrownBy(() -> verifier.verify(valid + "x")).isInstanceOf(PlatformTokenException.class); - assertThatThrownBy(() -> verifier.verify(token(487L, "tzs001", "new-password", 2L, now.minusSeconds(1)))).isInstanceOf(PlatformTokenException.class); + assertThatThrownBy(() -> verifier.verify(token(USER_ID, "tzs001", "new-password", 4L, now.minusSeconds(1)))).isInstanceOf(PlatformTokenException.class); + assertThatThrownBy(() -> verifier.verify(token(USER_ID, "tzs001", "new-password", 2L, + now.plus(Duration.ofMinutes(5))))).isInstanceOf(PlatformTokenException.class); Map incomplete = new HashMap(); - incomplete.put("userId", 487L); + incomplete.put("userId", USER_ID); assertThatThrownBy(() -> verifier.verify(unsignedToken(incomplete, now.plus(Duration.ofMinutes(5))))).isInstanceOf(PlatformTokenException.class); } - private String token(long userId, String username, String password, long roleId, Instant expiresAt) throws Exception { + private String token(String userId, String username, String password, long roleId, Instant expiresAt) throws Exception { return token(userId, username, password, roleId, expiresAt, new HashMap()); } - private String token(long userId, String username, String password, long roleId, Instant expiresAt, + private String token(String userId, String username, String password, long roleId, Instant expiresAt, Map profile) throws Exception { Map claims = new HashMap(); claims.put("userId", userId); claims.put("username", username); claims.put("password", password); claims.put("roleid", roleId); diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfigTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfigTest.java deleted file mode 100644 index 6d0f3f3..0000000 --- a/src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfigTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.yau.digitalrmb.platformintegration.infrastructure; - -import com.zaxxer.hikari.HikariDataSource; -import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; -import org.junit.jupiter.api.Test; -import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; -import org.springframework.context.annotation.Configuration; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; - -import static org.assertj.core.api.Assertions.assertThat; - -class PlatformReadOnlyDataSourceConfigTest { - private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(PropertiesConfiguration.class, PlatformReadOnlyDataSourceConfig.class)) - .withPropertyValues( - "platform-integration.enabled=true", - "platform-integration.datasource.url=jdbc:h2:mem:platform_config", - "platform-integration.datasource.username=sa", - "platform-integration.datasource.password=test-password", - "platform-integration.token.max-age=PT2M", - "platform-integration.token.teacher-claim-value=teacher", - "platform-integration.token.student-claim-value=student", - "platform-integration.cas.login-url=https://sso.example.edu/login", - "platform-integration.cas.validate-url=https://sso.example.edu/p3/serviceValidate", - "platform-integration.cas.callback-url=https://rmb.example.edu/api/v1/auth/cas/callback", - "platform-integration.frontend.callback-url=https://rmb.example.edu/sso-callback"); - - @Test - void createsNamedReadOnlyDatasourceAndJdbcTemplate() { - contextRunner.run(context -> { - HikariDataSource dataSource = context.getBean("platformReadOnlyDataSource", HikariDataSource.class); - - assertThat(dataSource.isReadOnly()).isTrue(); - assertThat(context.getBean("platformNamedParameterJdbcTemplate", NamedParameterJdbcTemplate.class)).isNotNull(); - }); - } - - @Configuration(proxyBeanMethods = false) - @EnableConfigurationProperties(PlatformIntegrationProperties.class) - static class PropertiesConfiguration { - } -} diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java index 635c24d..39790d3 100644 --- a/src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java @@ -1,6 +1,7 @@ package com.yau.digitalrmb.platformintegration.interfaces; import com.yau.digitalrmb.identity.application.LocalSsoAccountService; +import com.yau.digitalrmb.identity.infrastructure.persistence.entity.UserEntity; import com.yau.digitalrmb.platformintegration.application.PlatformTokenVerifier; import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken; import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; @@ -9,8 +10,6 @@ import org.junit.jupiter.api.Test; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import java.util.Collections; - import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -20,6 +19,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; class PlatformSsoControllerTest { + private static final String USER_ID = "00000000-0000-0000-0000-000000000101"; + @Test void ssoRedirectIssuesOnlyLocalToken() throws Exception { PlatformTokenVerifier verifier = mock(PlatformTokenVerifier.class); @@ -27,9 +28,14 @@ class PlatformSsoControllerTest { JwtTokenService jwtTokenService = mock(JwtTokenService.class); PlatformIntegrationProperties properties = new PlatformIntegrationProperties(); properties.getFrontend().setCallbackUrl("https://rmb.example.edu/sso-callback"); - VerifiedPlatformToken verified = new VerifiedPlatformToken(101L, "t001", "Teacher", "password", "TEACHER"); + VerifiedPlatformToken verified = new VerifiedPlatformToken(USER_ID, "t001", "Teacher", "password", 3); + UserEntity user = new UserEntity(); + user.setUserId(USER_ID); + user.setStudentId("t001"); + user.setRoleId(3); when(verifier.verify(anyString())).thenReturn(verified); - when(jwtTokenService.issueFor(101L, "t001", Collections.singleton("TEACHER"))) + when(localAccounts.synchronize(verified)).thenReturn(user); + when(jwtTokenService.issueFor(user)) .thenReturn(new JwtTokenService.Token("local-system-jwt", 1800L)); MockMvc mvc = MockMvcBuilders.standaloneSetup( new PlatformSsoController(verifier, localAccounts, jwtTokenService, properties)).build(); diff --git a/src/test/java/com/yau/digitalrmb/security/AuthContextHolderTest.java b/src/test/java/com/yau/digitalrmb/security/AuthContextHolderTest.java new file mode 100644 index 0000000..942b5d4 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/security/AuthContextHolderTest.java @@ -0,0 +1,130 @@ +package com.yau.digitalrmb.security; + +import com.yau.digitalrmb.security.context.AuthContextHolder; +import com.yau.digitalrmb.security.context.JwtUser; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.time.Instant; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AuthContextHolderTest { + private static final String USER_ID = "00000000-0000-0000-0000-000000000701"; + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void readsJwtUserFromCurrentSecurityContext() { + authenticate(Jwt.withTokenValue("test-token") + .header("alg", "none") + .subject(USER_ID) + .claim("userId", USER_ID) + .claim("preferred_username", "sso701") + .claim("studentId", "sso701") + .claim("userName", "Test Student") + .claim("className", "Class 1") + .claim("phone", "13800000000") + .claim("schoolName", "Yan'an University") + .claim("schoolId", "610000") + .claim("classId", "202401") + .claim("authorizeTime", "2026-08-04") + .claim("authorizeEndTime", "2027-08-04") + .claim("roleId", 4) + .claim("createTime", "2026-08-04T08:00:00") + .claim("isDeleted", false) + .claim("zyUserId", "701") + .claim("roles", Arrays.asList("ROLE_STUDENT", "ROLE_USER")) + .issuedAt(Instant.parse("2026-08-04T00:00:00Z")) + .expiresAt(Instant.parse("2026-08-04T01:00:00Z")) + .build()); + + JwtUser jwtUser = AuthContextHolder.get(); + + assertThat(jwtUser.getUserId()).isEqualTo(USER_ID); + assertThat(jwtUser.getUsername()).isEqualTo("sso701"); + assertThat(jwtUser.getUserName()).isEqualTo("Test Student"); + assertThat(jwtUser.getSchoolId()).isEqualTo("610000"); + assertThat(jwtUser.getSchoolName()).isEqualTo("Yan'an University"); + assertThat(jwtUser.getClassId()).isEqualTo("202401"); + assertThat(jwtUser.getClassName()).isEqualTo("Class 1"); + assertThat(jwtUser.getPhone()).isEqualTo("13800000000"); + assertThat(jwtUser.getAuthorizeTime()).isEqualTo("2026-08-04"); + assertThat(jwtUser.getAuthorizeEndTime()).isEqualTo("2027-08-04"); + assertThat(jwtUser.getRoleId()).isEqualTo(4); + assertThat(jwtUser.getCreateTime()).isEqualTo("2026-08-04T08:00:00"); + assertThat(jwtUser.getIsDeleted()).isFalse(); + assertThat(jwtUser.getZyUserId()).isEqualTo("701"); + assertThat(jwtUser.getRoles()).containsExactly("ROLE_STUDENT", "ROLE_USER"); + } + + @Test + void fallsBackToSubjectWhenUserIdClaimIsAbsent() { + authenticate(Jwt.withTokenValue("test-token") + .header("alg", "none") + .subject("00000000-0000-0000-0000-000000000702") + .issuedAt(Instant.parse("2026-08-04T00:00:00Z")) + .expiresAt(Instant.parse("2026-08-04T01:00:00Z")) + .build()); + + JwtUser jwtUser = AuthContextHolder.get(); + + assertThat(jwtUser.getUserId()).isEqualTo("00000000-0000-0000-0000-000000000702"); + assertThat(jwtUser.getUsername()).isEqualTo("00000000-0000-0000-0000-000000000702"); + assertThat(jwtUser.getSchoolId()).isNull(); + assertThat(jwtUser.getRoles()).isEmpty(); + } + + @Test + void fallsBackToLocalUuidSubjectWhenUserIdClaimContainsPlatformId() { + authenticate(Jwt.withTokenValue("test-token") + .header("alg", "none") + .subject("00000000-0000-0000-0000-000000000703") + .claim("userId", "701") + .claim("zyUserId", "701") + .issuedAt(Instant.parse("2026-08-04T00:00:00Z")) + .expiresAt(Instant.parse("2026-08-04T01:00:00Z")) + .build()); + + JwtUser jwtUser = AuthContextHolder.get(); + + assertThat(jwtUser.getUserId()).isEqualTo("00000000-0000-0000-0000-000000000703"); + assertThat(jwtUser.getZyUserId()).isEqualTo("701"); + } + + @Test + void rejectsUnauthenticatedAccess() { + assertThatThrownBy(AuthContextHolder::get) + .isInstanceOfSatisfying(BusinessException.class, + exception -> assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED)); + } + + @Test + void rejectsMalformedRoleIdClaim() { + authenticate(Jwt.withTokenValue("test-token") + .header("alg", "none") + .subject(USER_ID) + .claim("roleId", "not-a-number") + .issuedAt(Instant.parse("2026-08-04T00:00:00Z")) + .expiresAt(Instant.parse("2026-08-04T01:00:00Z")) + .build()); + + assertThatThrownBy(AuthContextHolder::get) + .isInstanceOfSatisfying(BusinessException.class, + exception -> assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED)) + .hasMessage("Token中的roleId格式不正确"); + } + + private void authenticate(Jwt jwt) { + SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(jwt)); + } +} diff --git a/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java b/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java index ab26f91..391ee5f 100644 --- a/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java @@ -23,7 +23,7 @@ class AuthControllerTest { @Test void localPasswordLoginIssuesTokenAndCanReadCurrentUser() throws Exception { - String body = "{\"username\":\"tzs001\",\"password\":\"123qwe\"}"; + String body = "{\"studentId\":\"tzs001\",\"password\":\"123qwe\"}"; String response = mvc.perform(post("/api/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(body)) @@ -35,10 +35,28 @@ class AuthControllerTest { mvc.perform(get("/api/v1/auth/me") .header("Authorization", "Bearer " + token)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.userId").value(487)) + .andExpect(jsonPath("$.data.userId").value("00000000-0000-0000-0000-000000000487")) .andExpect(jsonPath("$.data.username").value("tzs001")); } + @Test + void localPasswordLoginAcceptsLegacyUsernameAlias() throws Exception { + mvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"tzs001\",\"password\":\"123qwe\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()); + } + + @Test + void localPasswordLoginReturnsStudentIdCredentialMessage() throws Exception { + mvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"studentId\":\"missing\",\"password\":\"wrong\"}")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("学号或密码错误")); + } + @Test void protectedEndpointRejectsMissingToken() throws Exception { mvc.perform(get("/api/v1/diagnostics/validation").param("value", "ok")) diff --git a/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java b/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java index 52eed24..eaf7a17 100644 --- a/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java +++ b/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java @@ -1,10 +1,10 @@ package com.yau.digitalrmb.security; import com.yau.digitalrmb.identity.application.LocalSsoAccountService; +import com.yau.digitalrmb.identity.infrastructure.persistence.entity.UserEntity; import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken; import com.yau.digitalrmb.security.application.JwtTokenService; import com.yau.digitalrmb.security.application.RefreshTokenService; -import java.util.Collections; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -23,6 +23,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @AutoConfigureMockMvc @ActiveProfiles("test") class CurrentUserAndLogoutTest { + private static final String USER_ID = "00000000-0000-0000-0000-000000000101"; @Autowired private MockMvc mvc; @Autowired private LocalSsoAccountService localSsoAccountService; @Autowired private JwtTokenService jwtTokenService; @@ -33,19 +34,20 @@ class CurrentUserAndLogoutTest { @BeforeEach void setUp() { - localSsoAccountService.synchronize(new VerifiedPlatformToken(101L, "t001", "Teacher User", "password", 3L, - "TEACHER", "610000", "Yan'an University", "100", "Computer College", "101", - "Software Engineering", "202401", "Class 1", "20240001")); - teacherJwt = jwtTokenService.issueFor(101L, "t001", Collections.singleton("TEACHER")).accessToken(); - refreshToken = refreshTokenService.issue(101L); + UserEntity user = localSsoAccountService.synchronize( + new VerifiedPlatformToken(USER_ID, "t001", "Teacher User", "password", 3L, + "610000", "Yan'an University", "100", "Computer College", "101", + "Software Engineering", "202401", "Class 1", "20240101")); + teacherJwt = jwtTokenService.issueFor(user).accessToken(); + refreshToken = refreshTokenService.issue(USER_ID); } @Test void currentUserIsReadonlyTeacherAndLogoutRevokesOwnRefreshToken() throws Exception { mvc.perform(get("/api/v1/auth/me").header("Authorization", "Bearer " + teacherJwt)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.userId").value(101)) - .andExpect(jsonPath("$.data.username").value("t001")) + .andExpect(jsonPath("$.data.userId").value(USER_ID)) + .andExpect(jsonPath("$.data.username").value("20240101")) .andExpect(jsonPath("$.data.name").value("Teacher User")) .andExpect(jsonPath("$.data.schoolId").value("610000")) .andExpect(jsonPath("$.data.schoolName").value("Yan'an University")) @@ -56,7 +58,7 @@ class CurrentUserAndLogoutTest { .andExpect(jsonPath("$.data.roleid").value(3)) .andExpect(jsonPath("$.data.classId").value("202401")) .andExpect(jsonPath("$.data.className").value("Class 1")) - .andExpect(jsonPath("$.data.studentid").value("20240001")); + .andExpect(jsonPath("$.data.studentid").value("20240101")); mvc.perform(post("/api/v1/auth/logout").header("Authorization", "Bearer " + teacherJwt) .contentType(MediaType.APPLICATION_JSON).content("{\"refreshToken\":\"" + refreshToken + "\"}")) .andExpect(status().isOk()); diff --git a/src/test/java/com/yau/digitalrmb/security/CurrentUserServiceTest.java b/src/test/java/com/yau/digitalrmb/security/CurrentUserServiceTest.java index d9acd2a..ac4a45d 100644 --- a/src/test/java/com/yau/digitalrmb/security/CurrentUserServiceTest.java +++ b/src/test/java/com/yau/digitalrmb/security/CurrentUserServiceTest.java @@ -23,6 +23,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; @SpringBootTest @ActiveProfiles("test") class CurrentUserServiceTest { + private static final String USER_701 = "00000000-0000-0000-0000-000000000701"; + private static final String USER_702 = "00000000-0000-0000-0000-000000000702"; @Autowired private CurrentUserService currentUserService; @Autowired private LocalSsoAccountService localSsoAccountService; @@ -33,35 +35,35 @@ class CurrentUserServiceTest { @Test void readsCompleteProfileForCurrentSecurityContextUser() { - localSsoAccountService.synchronize(completeToken(701L, "sso701", 2L)); - authenticateAs(701L); + localSsoAccountService.synchronize(completeToken(USER_701, "sso701", 4L)); + authenticateAs(USER_701); CurrentUser user = currentUserService.getCurrentUser(); - assertThat(user.getUserId()).isEqualTo(701L); - assertThat(user.getUsername()).isEqualTo("sso701"); + assertThat(user.getUserId()).isEqualTo(USER_701); + assertThat(user.getUsername()).isEqualTo("20240701"); assertThat(user.getName()).isEqualTo("Test Student"); assertThat(user.getSchoolId()).isEqualTo("610000"); assertThat(user.getCollegeName()).isEqualTo("Computer College"); assertThat(user.getMajorName()).isEqualTo("Software Engineering"); - assertThat(user.getRoleid()).isEqualTo(2L); + assertThat(user.getRoleid()).isEqualTo(4L); assertThat(user.getClassId()).isEqualTo("202401"); - assertThat(user.getStudentid()).isEqualTo("20240001"); + assertThat(user.getStudentid()).isEqualTo("20240701"); } @Test void rejectsCurrentSecurityContextUserWithoutSnapshot() { - authenticateAs(702L); + authenticateAs(USER_702); assertThatThrownBy(() -> currentUserService.getCurrentUser()) .isInstanceOfSatisfying(BusinessException.class, exception -> assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED)); } - private void authenticateAs(long userId) { + private void authenticateAs(String userId) { Jwt jwt = Jwt.withTokenValue("test-token") .header("alg", "none") - .subject(String.valueOf(userId)) + .subject(userId) .issuedAt(Instant.parse("2026-08-04T00:00:00Z")) .expiresAt(Instant.parse("2026-08-04T01:00:00Z")) .build(); @@ -69,9 +71,9 @@ class CurrentUserServiceTest { SecurityContextHolder.getContext().setAuthentication(authentication); } - private VerifiedPlatformToken completeToken(long userId, String username, long roleId) { - return new VerifiedPlatformToken(userId, username, "Test Student", "password", roleId, "STUDENT", + private VerifiedPlatformToken completeToken(String userId, String username, long roleId) { + return new VerifiedPlatformToken(userId, username, "Test Student", "password", roleId, "610000", "Yan'an University", "100", "Computer College", "101", "Software Engineering", - "202401", "Class 1", "20240001"); + "202401", "Class 1", "20240701"); } } diff --git a/src/test/java/com/yau/digitalrmb/security/UserControllerTest.java b/src/test/java/com/yau/digitalrmb/security/UserControllerTest.java new file mode 100644 index 0000000..3cbc7ab --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/security/UserControllerTest.java @@ -0,0 +1,152 @@ +package com.yau.digitalrmb.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +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; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class UserControllerTest { + private static final String SECRET = "local-token-sso-test-secret-key-123456"; + + @Autowired + private MockMvc mvc; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void tokenLoginSynchronizesCrossPlatformUserAndReturnsLocalToken() throws Exception { + String response = mvc.perform(post("/api/user/login") + .param("TOKEN", crossPlatformToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) + .andExpect(jsonPath("$.data.tokenType").value("Bearer")) + .andExpect(jsonPath("$.data.username").value("cross001")) + .andExpect(jsonPath("$.data.studentId").value("cross001")) + .andExpect(jsonPath("$.data.name").value("Cross Student")) + .andExpect(jsonPath("$.data.roleId").value(4)) + .andExpect(jsonPath("$.data.schoolId").value("610000")) + .andExpect(jsonPath("$.data.schoolName").value("Yan'an University")) + .andExpect(jsonPath("$.data.classId").value("202401")) + .andExpect(jsonPath("$.data.className").value("Class 1")) + .andExpect(jsonPath("$.data.collegeId").value("100")) + .andExpect(jsonPath("$.data.majorId").value("101")) + .andReturn().getResponse().getContentAsString(); + + String localUserId = JsonPath.read(response, "$.data.userId"); + assertThat(UUID.fromString(localUserId).toString()).isEqualTo(localUserId); + assertThat(jdbcTemplate.queryForObject( + "SELECT zy_user_id FROM sys_user WHERE user_id = ?", String.class, localUserId)) + .isEqualTo("9001"); + } + + @Test + void parameterLoginMatchesCrossTrainingPlatformEndpointShape() throws Exception { + mvc.perform(post("/api/user/login") + .param("username", "tzs001") + .param("passwordEncode", "123qwe")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.userId") + .value("00000000-0000-0000-0000-000000000487")) + .andExpect(jsonPath("$.data.username").value("tzs001")) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()); + } + + @Test + void legacyPlaintextPasswordLoginUpgradesPasswordToBcrypt() throws Exception { + String userId = "00000000-0000-0000-0000-000000000221"; + jdbcTemplate.update("INSERT INTO sys_user " + + "(user_id, student_id, password, user_name, role_id, is_deleted) " + + "VALUES (?, ?, ?, ?, ?, ?)", + userId, "legacy001", "123qwe", "Legacy Student", 4, 0); + + mvc.perform(post("/api/user/login") + .param("username", "legacy001") + .param("passwordEncode", "123qwe")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.userId").value(userId)) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()); + + String upgradedPassword = jdbcTemplate.queryForObject( + "SELECT password FROM sys_user WHERE user_id = ?", String.class, userId); + assertThat(upgradedPassword).startsWith("$2").isNotEqualTo("123qwe"); + } + + @Test + void tokenLoginAcceptsLegacyTokenWithoutExpirationOrPassword() throws Exception { + Map claims = baseClaims(); + claims.remove("password"); + claims.remove("exp"); + + mvc.perform(post("/api/user/login").param("TOKEN", signedToken(claims))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.username").value("cross001")) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()); + } + + private String crossPlatformToken() throws Exception { + return signedToken(baseClaims()); + } + + private Map baseClaims() { + Map claims = new HashMap(); + claims.put("userId", 9001); + claims.put("username", "cross001"); + claims.put("password", "token-password"); + claims.put("name", "Cross Student"); + claims.put("roleid", 4); + claims.put("schoolId", 610000); + claims.put("school", "Yan'an University"); + claims.put("collegeId", 100); + claims.put("collegeName", "Computer College"); + claims.put("majorId", 101); + claims.put("major", "Software Engineering"); + claims.put("classId", 202401); + claims.put("class", "Class 1"); + claims.put("studentNo", "cross001"); + claims.put("exp", Instant.now().plusSeconds(300).getEpochSecond()); + return claims; + } + + private String signedToken(Map claims) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + Map header = new HashMap(); + header.put("alg", "HS256"); + header.put("typ", "JWT"); + String unsigned = encode(mapper.writeValueAsBytes(header)) + + "." + encode(mapper.writeValueAsBytes(claims)); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return unsigned + "." + encode(mac.doFinal(unsigned.getBytes(StandardCharsets.US_ASCII))); + } + + private String encode(byte[] value) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(value); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..5d7cbc5 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,31 @@ +spring: + datasource: + url: jdbc:h2:mem:digital_rmb_workflow;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE + username: sa + password: "" + driver-class-name: org.h2.Driver + sql: + init: + mode: always + schema-locations: classpath:schema.sql,classpath:db/manual/institution_identifier_training.sql +security: + jwt: + secret: 0123456789012345678901234567890123456789012345678901234567890123 + access-token-ttl: PT30M +platform-integration: + enabled: true + datasource: + url: jdbc:h2:mem:platform_workflow;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE + username: sa + password: test-password + token: + link-secret-key: local-token-sso-test-secret-key-123456 + max-age: PT2M + teacher-claim-value: teacher + student-claim-value: student + cas: + login-url: https://sso.example.edu/login + validate-url: https://sso.example.edu/p3/serviceValidate + callback-url: https://rmb.example.edu/api/v1/auth/cas/callback + frontend: + callback-url: https://rmb.example.edu/sso-callback diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql new file mode 100644 index 0000000..4a632da --- /dev/null +++ b/src/test/resources/schema.sql @@ -0,0 +1,117 @@ +CREATE TABLE IF NOT EXISTS sys_user ( + user_id VARCHAR(36) PRIMARY KEY, + student_id VARCHAR(255) NULL, + password VARCHAR(255) NOT NULL, + user_name VARCHAR(100) NULL, + class_id VARCHAR(36) NULL, + class_name VARCHAR(100) NULL, + phone VARCHAR(100) NULL, + school_name VARCHAR(100) NULL, + school_id VARCHAR(36) NULL, + authorize_time DATE NULL, + authorize_end_time DATE NULL, + role_id TINYINT NULL, + create_time DATETIME NULL, + is_deleted TINYINT NOT NULL DEFAULT 0, + zy_user_id VARCHAR(50) NULL, + UNIQUE KEY uk_sys_user_student_id (student_id) +); + +CREATE TABLE IF NOT EXISTS platform_user_snapshot ( + platform_user_id VARCHAR(36) PRIMARY KEY, + account VARCHAR(64) NOT NULL, + display_name VARCHAR(64) NOT NULL, + source_updated_at TIMESTAMP NOT NULL, + synced_at TIMESTAMP NOT NULL, + school_id VARCHAR(64) NULL, + school_name VARCHAR(128) NULL, + college_id VARCHAR(64) NULL, + college_name VARCHAR(128) NULL, + major_id VARCHAR(64) NULL, + major_name VARCHAR(128) NULL, + class_id VARCHAR(64) NULL, + class_name VARCHAR(128) NULL, + student_id VARCHAR(64) NULL +); + +CREATE TABLE IF NOT EXISTS auth_login_exchange_code ( + code_hash CHAR(64) PRIMARY KEY, + platform_user_id VARCHAR(36) NOT NULL, + expires_at TIMESTAMP NOT NULL, + consumed_at TIMESTAMP NULL +); + +CREATE TABLE IF NOT EXISTS auth_refresh_token ( + token_hash CHAR(64) PRIMARY KEY, + platform_user_id VARCHAR(36) NOT NULL, + expires_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP NULL +); + +CREATE TABLE IF NOT EXISTS institution_sm2_key ( + id BIGINT PRIMARY KEY, + key_id VARCHAR(64) NOT NULL, + key_name VARCHAR(64) NOT NULL, + key_owner VARCHAR(32) NOT NULL, + key_purpose VARCHAR(32) NOT NULL, + user_id VARCHAR(36) NOT NULL, + school_id BIGINT NOT NULL, + class_id BIGINT NOT NULL, + public_key VARCHAR(256) NOT NULL, + encrypted_private_key VARCHAR(512) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + created_by VARCHAR(64) NOT NULL, + updated_by VARCHAR(64) NOT NULL, + deleted BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE IF NOT EXISTS institution_sm2_key_audit ( + id BIGINT PRIMARY KEY, + key_record_id BIGINT NOT NULL, + key_id VARCHAR(64) NOT NULL, + operation VARCHAR(32) NOT NULL, + user_id VARCHAR(36) NOT NULL, + school_id BIGINT NOT NULL, + class_id BIGINT NOT NULL, + operation_detail VARCHAR(512) NOT NULL, + created_at TIMESTAMP NOT NULL, + created_by VARCHAR(64) NOT NULL, + CONSTRAINT fk_institution_key_audit_key + FOREIGN KEY (key_record_id) REFERENCES institution_sm2_key(id) +); + +INSERT INTO sys_user ( + user_id, student_id, password, user_name, role_id, is_deleted, zy_user_id +) +VALUES ( + '00000000-0000-0000-0000-000000000487', + 'tzs001', + '$2a$10$ufcw5KFHtOmLzxAsV4C.MuIDOErMlw0iw5J5hc8OMzaTx0u9QYxG6', + 'tzs001', + 4, + 0, + '487' +) +ON DUPLICATE KEY UPDATE + student_id = VALUES(student_id), + password = VALUES(password), + user_name = VALUES(user_name), + role_id = VALUES(role_id), + is_deleted = 0; + +INSERT INTO platform_user_snapshot ( + platform_user_id, account, display_name, source_updated_at, synced_at +) +VALUES ( + '00000000-0000-0000-0000-000000000487', + 'tzs001', + 'tzs001', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +) +ON DUPLICATE KEY UPDATE + account = VALUES(account), + display_name = VALUES(display_name), + source_updated_at = CURRENT_TIMESTAMP, + synced_at = CURRENT_TIMESTAMP;