# Conflicts:
#	src/main/java/com/yau/digitalrmb/DigitalRmbApplication.java
#	src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java
#	src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java
#	src/main/resources/schema.sql
master
chenyuan 3 weeks ago
commit 07b41797fe

@ -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<http://localhost:8081/swagger-ui/index.html>
应用启动时会执行幂等的 `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 哈希,不能保存密码明文。

@ -3,11 +3,14 @@ package com.yau.digitalrmb;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties;
@SpringBootApplication
@ConfigurationPropertiesScan
@EnableScheduling
@EnableConfigurationProperties(PlatformIntegrationProperties.class)
public class DigitalRmbApplication {
public static void main(String[] args) {

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

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

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

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

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

@ -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<RoleEntity> {
}

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

@ -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> T invokeDomainWithResult(DomainSupplier<T> supplier) {
try {
return supplier.get();
} catch (IllegalArgumentException | IllegalStateException exception) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, exception.getMessage());
}
}
private interface DomainSupplier<T> {
T get();
}
}

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

@ -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> T invokeResult(Supplier<T> supplier) {
try { return supplier.get(); } catch (IllegalArgumentException | IllegalStateException e) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, e.getMessage());
}
}
private interface Supplier<T> { T get(); }
}

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

@ -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,62 @@ public class InstitutionApplicationVerificationService {
signingKeyId, centralBankSignature, identifier, digest);
}
private InstitutionIdentifierApplication requireApplication(long applicationId) {
return repository.findById(applicationId).orElseThrow(() ->
new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "机构标识申请不存在"));
@Transactional
public InstitutionApplicationVerificationResult verify(long applicationId, String verificationKeyId,
InstitutionKeySubject subject, String operator) {
InstitutionIdentifierApplication application = requireApplication(applicationId, subject);
if (!InstitutionKeyService.BANK_SECOND_KEY.equals(verificationKeyId)) {
recordAndThrow(application, "WRONG_KEY", "VERIFY", verificationKeyId,
"验证必须选择商业银行第二密钥的公钥", operator);
}
requireStatus(application, InstitutionIdentifierApplication.Status.PACKAGED,
"当前申请不是待验证状态,不能操作", operator);
InstitutionIdentifierApplication.Status expectedStatus = application.getStatus();
String originalText = application.getBankCode() + "|" + application.getTimestamp();
String recalculatedDigest = cryptography.sm3(originalText);
boolean signatureValid = keyService.verifyCommercialBank(subject, application.getDigest(),
application.getBankSignature());
boolean digestMatches = recalculatedDigest.equalsIgnoreCase(application.getDigest());
invokeDomain(() -> application.recordVerification(verificationKeyId, signatureValid, digestMatches));
repository.update(application, expectedStatus, "VERIFY",
signatureValid && digestMatches ? "申请验证通过" : "申请验证失败", operator);
return new InstitutionApplicationVerificationResult(applicationId, application.getStatus().name(),
"SM2_WITH_SM3_VERIFY_AND_SM3_RECALCULATE", verificationKeyId, originalText,
application.getDigest(), recalculatedDigest, signatureValid, digestMatches);
}
@Transactional
public InstitutionIdentifierConfirmationResult confirm(long applicationId, String signingKeyId,
InstitutionKeySubject subject, String operator) {
InstitutionIdentifierApplication application = requireApplication(applicationId, subject);
if (!InstitutionKeyService.CENTRAL_FIRST_KEY.equals(signingKeyId)) {
recordAndThrow(application, "WRONG_KEY", "CONFIRM", signingKeyId,
"签名确权必须选择中央银行第一密钥", operator);
}
requireStatus(application, InstitutionIdentifierApplication.Status.VERIFIED,
"申请尚未验证通过,不能进行中央银行签名确权", operator);
InstitutionIdentifierApplication.Status expectedStatus = application.getStatus();
String digest = application.getDigest();
String identifier = "ORG_" + digest.substring(0, 12);
String centralBankSignature = keyService.signCentralBank(subject, digest);
invokeDomain(() -> application.issue(signingKeyId, identifier, centralBankSignature));
repository.update(application, expectedStatus, "CONFIRM", "中央银行完成签名确权", operator);
return new InstitutionIdentifierConfirmationResult(applicationId, application.getStatus().name(),
signingKeyId, centralBankSignature, identifier, digest);
}
private InstitutionIdentifierApplication requireApplication(long applicationId, InstitutionKeySubject subject) {
return repository.findByIdAndSubject(applicationId, subject.getUserId(), subject.getSchoolId(),
subject.getClassId()).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,

@ -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,63 @@ public class InstitutionIdentifierStepService {
application.getBankSignature());
}
private InstitutionIdentifierApplication requireApplication(long applicationId) {
return repository.findById(applicationId).orElseThrow(() ->
new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "机构标识申请不存在"));
@Transactional
public InstitutionDigestResult digest(long applicationId, InstitutionKeySubject subject, String operator) {
InstitutionIdentifierApplication application = requireApplication(applicationId, subject);
requireStatus(application, InstitutionIdentifierApplication.Status.PREPARED,
"当前申请不是待计算摘要状态,不能操作", operator);
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", "计算机构标识申请摘要", 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, subject);
if (!InstitutionKeyService.BANK_SECOND_KEY.equals(keyId)) {
recordAndThrow(application, "WRONG_KEY", "SIGN", keyId,
"签名必须选择商业银行第二密钥", operator);
}
requireStatus(application, InstitutionIdentifierApplication.Status.DIGESTED,
"当前步骤不是商业银行签名,不能操作", operator);
InstitutionIdentifierApplication.Status expectedStatus = application.getStatus();
String signature = keyService.signCommercialBank(subject, application.getDigest());
invokeDomain(() -> application.recordBankSignature(signature));
repository.update(application, expectedStatus, "SIGN", "完成商业银行SM2签名", operator);
return new InstitutionSignatureResult(applicationId, application.getStatus().name(), "SM2", keyId,
"COMMERCIAL_BANK_SECOND_PRIVATE_KEY", signature);
}
@Transactional
public InstitutionApplicationPackageResult packageApplication(long applicationId, InstitutionKeySubject subject,
String operator) {
InstitutionIdentifierApplication application = requireApplication(applicationId, subject);
requireStatus(application, InstitutionIdentifierApplication.Status.SIGNED,
"当前申请不是待组装状态,不能操作", operator);
InstitutionIdentifierApplication.Status expectedStatus = application.getStatus();
invokeDomain(application::packageApplication);
repository.update(application, expectedStatus, "PACKAGE", "组装机构标识申请材料", operator);
return new InstitutionApplicationPackageResult(applicationId, application.getStatus().name(),
application.getBankCode(), application.getTimestamp(), application.getDigest(),
application.getBankSignature());
}
private InstitutionIdentifierApplication requireApplication(long applicationId, InstitutionKeySubject subject) {
return repository.findByIdAndSubject(applicationId, subject.getUserId(), subject.getSchoolId(),
subject.getClassId()).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,

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

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

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

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

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

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

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

@ -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<CurrencyGenerationRequest> findByIdAndSubject(long requestId, String userId, long schoolId, long classId);
void update(CurrencyGenerationRequest request, CurrencyGenerationRequest.Status expectedStatus,
String operation, String operationDetail, String operator);
}

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

@ -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<CurrencyGenerationVerification> findByIdAndSubject(long verificationId, String userId,
long schoolId, long classId);
void update(CurrencyGenerationVerification verification,
CurrencyGenerationVerification.Status expectedStatus,
String operation, String detail, String operator);
}

@ -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<InstitutionIdentifierApplication> findById(long applicationId);
Optional<InstitutionIdentifierApplication> findByIdAndSubject(long applicationId,
String userId,
long schoolId,
long classId);
Optional<InstitutionIdentifierApplication> findLatestBySubjectAndStatus(String userId,
long schoolId,
long classId,
InstitutionIdentifierApplication.Status status);
void update(InstitutionIdentifierApplication application,
InstitutionIdentifierApplication.Status expectedStatus,
String operation,

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

@ -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<CurrencyGenerationRequestEntity> {
}

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

@ -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<CurrencyGenerationRequestOperationLogEntity> {
}

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

@ -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<CurrencyGenerationVerificationEntity> {
}

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

@ -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<CurrencyGenerationVerificationOperationLogEntity> {
}

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

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

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

@ -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<CurrencyGenerationRequest> findByIdAndSubject(long requestId, String userId,
long schoolId, long classId) {
CurrencyGenerationRequestEntity entity = mapper.selectOne(
new LambdaQueryWrapper<CurrencyGenerationRequestEntity>()
.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<CurrencyGenerationRequestEntity> update =
new LambdaUpdateWrapper<CurrencyGenerationRequestEntity>()
.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);
}
}

@ -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<CurrencyGenerationVerification> findByIdAndSubject(long id, String userId,
long schoolId, long classId) {
CurrencyGenerationVerificationEntity entity = mapper.selectOne(
new LambdaQueryWrapper<CurrencyGenerationVerificationEntity>()
.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<CurrencyGenerationVerificationEntity> update =
new LambdaUpdateWrapper<CurrencyGenerationVerificationEntity>()
.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);
}
}

@ -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<InstitutionIdentifierApplication> findByIdAndSubject(long applicationId,
String userId,
long schoolId,
long classId) {
InstitutionIdentifierApplicationEntity entity = mapper.selectOne(
new LambdaQueryWrapper<InstitutionIdentifierApplicationEntity>()
.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<InstitutionIdentifierApplication> findLatestBySubjectAndStatus(
String userId, long schoolId, long classId, InstitutionIdentifierApplication.Status status) {
InstitutionIdentifierApplicationEntity entity = mapper.selectOne(
new LambdaQueryWrapper<InstitutionIdentifierApplicationEntity>()
.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,

@ -1,28 +1,15 @@
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 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; }
}

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

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

@ -1,26 +1,15 @@
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 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; }
}

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

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

@ -1,28 +1,15 @@
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 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; }
}

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

@ -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<CurrencyGenerationVerificationResult> 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<CurrencyGenerationVerificationResult> 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<CurrencyGenerationVerificationResult> 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<CurrencyGenerationVerificationResult> 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<CurrencyGenerationVerificationResult> 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<CurrencyGenerationVerificationResult> 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<CurrencyGenerationVerificationResult> detail(@RequestParam long verificationId) {
return ok(service.detail(verificationId, InstitutionKeySubject.from(AuthContextHolder.get())));
}
private ApiResponse<CurrencyGenerationVerificationResult> ok(CurrencyGenerationVerificationResult result) {
return ApiResponse.success(result, MDC.get(TraceIdFilter.MDC_KEY));
}
}

@ -15,14 +15,16 @@ 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.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import com.fasterxml.jackson.databind.JsonNode;
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 +50,134 @@ public class InstitutionIdentifierController {
@PostMapping("/keys/commercial-bank")
@Operation(summary = "获取商业银行SM2密钥对",
description = "按Token中的userId、schoolId、classId生成或读取商业银行密钥对私钥加密保存。")
public ApiResponse<InstitutionKeyPairResult> commercialBankKey(
@AuthenticationPrincipal Jwt jwt, Authentication authentication) {
return ApiResponse.success(keyService.commercialBankKey(InstitutionKeySubject.from(jwt),
authentication.getName()), traceId());
public ApiResponse<InstitutionKeyPairResult> 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<Void> deleteCommercialBankKey() {
// JwtUser jwtUser = AuthContextHolder.get();
// keyService.logicalDelete(InstitutionKeySubject.from(jwtUser), InstitutionKeyService.BANK_SECOND_KEY,
// jwtUser.getUsername());
// return ApiResponse.success(null, traceId());
// }
@DeleteMapping("/keys/commercial-bank")
@Operation(summary = "删除商业银行SM2密钥对",
description = "仅逻辑删除当前Token中的userId、schoolId、classId所对应的商业银行密钥。")
public ApiResponse<Void> deleteCommercialBankKey(
@AuthenticationPrincipal Jwt jwt, Authentication authentication) {
keyService.logicalDelete(InstitutionKeySubject.from(jwt), InstitutionKeyService.BANK_SECOND_KEY,
authentication.getName());
@Operation(summary = "删除商业银行SM2密钥对")
public ApiResponse<Void> 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<InstitutionKeyPairResult> centralBankKey(
@AuthenticationPrincipal Jwt jwt, Authentication authentication) {
return ApiResponse.success(keyService.centralBankKey(InstitutionKeySubject.from(jwt),
authentication.getName()), traceId());
public ApiResponse<InstitutionKeyPairResult> centralBankKey() {
JwtUser jwtUser = AuthContextHolder.get();
return ApiResponse.success(keyService.centralBankKey(InstitutionKeySubject.from(jwtUser),
jwtUser.getUsername()), traceId());
}
// @DeleteMapping("/keys/central-bank")
// @Operation(summary = "删除中央银行SM2密钥对",
// description = "仅逻辑删除当前Token中的userId、schoolId、classId所对应的中央银行密钥。")
// public ApiResponse<Void> deleteCentralBankKey() {
// JwtUser jwtUser = AuthContextHolder.get();
// keyService.logicalDelete(InstitutionKeySubject.from(jwtUser), InstitutionKeyService.CENTRAL_FIRST_KEY,
// jwtUser.getUsername());
// return ApiResponse.success(null, traceId());
// }
@DeleteMapping("/keys/central-bank")
@Operation(summary = "删除中央银行SM2密钥对",
description = "仅逻辑删除当前Token中的userId、schoolId、classId所对应的中央银行密钥。")
public ApiResponse<Void> deleteCentralBankKey(
@AuthenticationPrincipal Jwt jwt, Authentication authentication) {
keyService.logicalDelete(InstitutionKeySubject.from(jwt), InstitutionKeyService.CENTRAL_FIRST_KEY,
authentication.getName());
@Operation(summary = "删除中央银行SM2密钥对")
public ApiResponse<Void> deleteCentralBankKey() {
JwtUser jwtUser = AuthContextHolder.get();
keyService.logicalDelete(InstitutionKeySubject.from(jwtUser), InstitutionKeyService.CENTRAL_FIRST_KEY,
jwtUser.getUsername());
return ApiResponse.success(null, traceId());
}
@PostMapping("/steps/prepare")
@Operation(summary = "创建申请并获取业务信息", description = "创建PREPARED状态申请并保存数据库返回后续步骤使用的applicationId。")
@Operation(summary = "生成机构标识申请")
public ApiResponse<InstitutionInformationPreparationResult> prepare(
@Valid @RequestBody GenerateInstitutionIdentifierRequest request, Authentication authentication) {
return ApiResponse.success(stepService.prepare(request.getBankCode(), authentication.getName()), traceId());
@Valid @RequestBody GenerateInstitutionIdentifierRequest request) {
JwtUser jwtUser = AuthContextHolder.get();
return ApiResponse.success(stepService.prepare(request.getBankCode(), InstitutionKeySubject.from(jwtUser),
jwtUser.getUsername()), traceId());
}
@PostMapping("/steps/digest")
@Operation(summary = "使用SM3计算摘要", description = "仅PREPARED状态可操作完成后保存摘要并进入DIGESTED状态。")
public ApiResponse<InstitutionDigestResult> digest(
@Valid @RequestBody InstitutionIdentifierStepRequest request, Authentication authentication) {
return ApiResponse.success(stepService.digest(request.getApplicationId(), authentication.getName()), traceId());
@Operation(summary = "生成本轮申请并使用SM3计算摘要",
description = "只提交弹窗中的机构代码;后端生成时间戳、创建本轮记录并计算摘要,不需要申请编号。")
public ApiResponse<InstitutionDigestResult> digest(@RequestBody JsonNode request) {
JwtUser jwtUser = AuthContextHolder.get();
InstitutionKeySubject subject = InstitutionKeySubject.from(jwtUser);
JsonNode applicationId = request.get("applicationId");
if (applicationId != null && applicationId.canConvertToLong()) {
return ApiResponse.success(stepService.digest(applicationId.asLong(), subject, jwtUser.getUsername()),
traceId());
}
JsonNode bankCode = request.get("bankCode");
if (bankCode == null || bankCode.asText().trim().isEmpty()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "银行代码不能为空");
}
return ApiResponse.success(stepService.digest(bankCode.asText(), subject, jwtUser.getUsername()), traceId());
}
@PostMapping("/steps/sign")
@Operation(summary = "使用商业银行第二私钥进行SM2签名", description = "仅DIGESTED状态可操作完成后保存签名并进入SIGNED状态。")
public ApiResponse<InstitutionSignatureResult> 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();
InstitutionKeySubject subject = InstitutionKeySubject.from(jwtUser);
InstitutionSignatureResult result = request.getApplicationId() == null
? stepService.sign(request.getKeyId(), subject, jwtUser.getUsername())
: stepService.sign(request.getApplicationId(), request.getKeyId(), subject, jwtUser.getUsername());
return ApiResponse.success(result, traceId());
}
@PostMapping("/steps/package")
@Operation(summary = "组装机构标识申请", description = "仅SIGNED状态可操作完成后进入PACKAGED待央行验证状态。")
public ApiResponse<InstitutionApplicationPackageResult> packageApplication(
@Valid @RequestBody InstitutionIdentifierStepRequest request, Authentication authentication) {
return ApiResponse.success(stepService.packageApplication(request.getApplicationId(), authentication.getName()),
traceId());
@RequestBody(required = false) InstitutionIdentifierStepRequest request) {
JwtUser jwtUser = AuthContextHolder.get();
InstitutionKeySubject subject = InstitutionKeySubject.from(jwtUser);
InstitutionApplicationPackageResult result = request != null && request.getApplicationId() != null
? stepService.packageApplication(request.getApplicationId(), subject, jwtUser.getUsername())
: stepService.packageApplication(subject, jwtUser.getUsername());
return ApiResponse.success(result, traceId());
}
@PostMapping("/applications/verification")
@Operation(summary = "查看并保存机构标识申请验证过程", description = "仅PACKAGED状态可操作保存SM2验签和SM3完整性验证结果。")
public ApiResponse<InstitutionApplicationVerificationResult> 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();
InstitutionKeySubject subject = InstitutionKeySubject.from(jwtUser);
InstitutionApplicationVerificationResult result = request.getApplicationId() == null
? verificationService.verify(request.getVerificationKeyId(), subject, jwtUser.getUsername())
: verificationService.verify(request.getApplicationId(), request.getVerificationKeyId(), subject,
jwtUser.getUsername());
return ApiResponse.success(result, traceId());
}
@PostMapping("/applications/confirmation")
@Operation(summary = "央行签名确权并组装机构标识", description = "仅VERIFIED状态可操作保存央行签名和机构标识并进入ISSUED状态。")
public ApiResponse<InstitutionIdentifierConfirmationResult> 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();
InstitutionKeySubject subject = InstitutionKeySubject.from(jwtUser);
InstitutionIdentifierConfirmationResult result = request.getApplicationId() == null
? verificationService.confirm(request.getSigningKeyId(), subject, jwtUser.getUsername())
: verificationService.confirm(request.getApplicationId(), request.getSigningKeyId(), subject,
jwtUser.getUsername());
return ApiResponse.success(result, traceId());
}
private String traceId() { return MDC.get(TraceIdFilter.MDC_KEY); }

@ -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("平台登录凭据无效"); }
}

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

@ -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.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpHeaders;
@ -15,8 +17,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")
@ -38,9 +38,8 @@ public class PlatformSsoController {
@Operation(description = "单点登录鉴权")
public ResponseEntity<Void> 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)

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

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

@ -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<String> roles) {
return issueFor(platformUserId, null, null, account, roles);
public Token issueFor(String userId, String account, Set<String> roles) {
return issueFor(userId, null, null, account, roles);
}
public Token issueFor(long userId, Long schoolId, Long classId, String account, Set<String> 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<String> 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<String> roles) {
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(properties.getJwt().getAccessTokenTtl());
List<String> 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;

@ -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<UserEntity>()
.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, "学号或密码错误");
}
}

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

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

@ -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<String> roles(Object value) {
if (value == null) {
return Collections.emptySet();
}
Set<String> 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);
}
}

@ -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<String> 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<String> 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<String> getRoles() {
return roles;
}
}

@ -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<LoginResponse> 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<Void> logout(@AuthenticationPrincipal Jwt jwt, @Valid @RequestBody LogoutRequest request) {
refreshTokenService.revokeForUser(request.refreshToken(), userId(jwt));
public ApiResponse<Void> 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, "用户身份信息无效");
}
}
}

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

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

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

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

@ -0,0 +1,20 @@
package com.yau.digitalrmb.security.interfaces;
import lombok.Getter;
@Getter
public class UserLoginResult<T> {
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 <T> UserLoginResult<T> success(T data) {
return new UserLoginResult<T>(200, null, data);
}
}

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

@ -1,13 +1,6 @@
spring:
profiles:
active: dev
server:
servlet:
encoding:
charset: UTF-8
enabled: true
force: true
application:
name: digital-rmb-backend
datasource:
@ -20,6 +13,14 @@ server:
sql:
init:
mode: always
server:
port: 8787
servlet:
encoding:
charset: UTF-8
enabled: true
force: true
security:
jwt:
secret: ${DIGITAL_RMB_JWT_SECRET}

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

@ -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,22 +84,24 @@ 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 IGNORE INTO sys_user_role (user_id, role_id) VALUES (487, 1002);
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;
CREATE TABLE IF NOT EXISTS issuance_bank_inventory (
bank_code VARCHAR(32) PRIMARY KEY,
@ -161,43 +153,19 @@ VALUES ('BKCHCNBJ00001', 49950000.00, 50000000.00, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE current_balance = VALUES(current_balance),
warning_threshold = VALUES(warning_threshold),
updated_at = CURRENT_TIMESTAMP;
--
-- 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;

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

@ -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<String, Object> 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<String, Object> 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<String, Object> 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");
}
}

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

@ -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<InstitutionSm2KeyEntity> 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<InstitutionIdentifierTrainingErrorEntity> 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<InstitutionIdentifierOperationLogEntity> 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<InstitutionIdentifierOperationLogEntity> 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 + "\"}";
}
}

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

@ -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<String, Object> profile = new HashMap<String, Object>();
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<String, Object> incomplete = new HashMap<String, Object>();
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<String, Object>());
}
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<String, Object> profile) throws Exception {
Map<String, Object> claims = new HashMap<String, Object>();
claims.put("userId", userId); claims.put("username", username); claims.put("password", password); claims.put("roleid", roleId);

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

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

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

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

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

@ -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<String, Object> 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<String, Object> baseClaims() {
Map<String, Object> claims = new HashMap<String, Object>();
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<String, Object> claims) throws Exception {
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> header = new HashMap<String, Object>();
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);
}
}

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

@ -0,0 +1,168 @@
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;
CREATE TABLE IF NOT EXISTS issuance_bank_inventory (
bank_code VARCHAR(32) PRIMARY KEY,
current_balance DECIMAL(20, 2) NOT NULL,
warning_threshold DECIMAL(20, 2) NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS issuance_request (
id CHAR(36) PRIMARY KEY,
request_no VARCHAR(64) NOT NULL UNIQUE,
bank_code VARCHAR(32) NOT NULL,
organization_id VARCHAR(64) NOT NULL,
total_amount DECIMAL(20, 2) NOT NULL,
currency VARCHAR(16) NOT NULL,
request_timestamp VARCHAR(32),
message_text TEXT,
digest CHAR(64),
signature TEXT,
signing_key_ref VARCHAR(64),
payload_json TEXT,
status VARCHAR(32) NOT NULL,
central_receive_status VARCHAR(32) NOT NULL,
central_received_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
created_by VARCHAR(64) NOT NULL,
updated_by VARCHAR(64) NOT NULL,
created_by_user_id BIGINT NOT NULL,
updated_by_user_id BIGINT NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS issuance_request_denomination (
request_id CHAR(36) NOT NULL,
denomination DECIMAL(10, 2) NOT NULL,
quantity INT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
created_by VARCHAR(64) NOT NULL,
updated_by VARCHAR(64) NOT NULL,
created_by_user_id BIGINT NOT NULL,
updated_by_user_id BIGINT NOT NULL,
PRIMARY KEY (request_id, denomination)
);
INSERT INTO issuance_bank_inventory (bank_code, current_balance, warning_threshold, updated_at)
VALUES ('BKCHCNBJ00001', 49950000.00, 50000000.00, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE current_balance = VALUES(current_balance),
warning_threshold = VALUES(warning_threshold),
updated_at = CURRENT_TIMESTAMP;
Loading…
Cancel
Save