Compare commits

..

No commits in common. 'master' and 'chore/issuance-comments-zh' have entirely different histories.

@ -3,7 +3,6 @@ package com.yau.digitalrmb.exchange.application.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import com.yau.digitalrmb.training.application.TrainingTaskProgressService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
@ -25,9 +24,6 @@ public class ExchangeScoreService {
@Resource
private StuModuleScoreDetailsMapper scoreDetailsMapper;
@Resource
private TrainingTaskProgressService taskProgress;
@Value("${exchange.score.wrong-deduct:0.50}")
private BigDecimal wrongDeduct = new BigDecimal("0.50");
@ -57,18 +53,6 @@ public class ExchangeScoreService {
detail.setSchedule(schedule);
detail.setScoreProject(score(errors, schedule));
scoreDetailsMapper.updateById(detail);
syncTaskProgress(userId, completedStep);
}
/**
* 同步 /api/v1/training-tasks 读取的任务步骤进度。
* 旧业务接口由拦截器写入,动作链则不经过拦截器,统一在步骤完成的这一处写入才不会漏;
* completeThrough 幂等,且与成绩更新在同一事务内。
*/
private void syncTaskProgress(String userId, int completedStep) {
if (taskProgress == null || completedStep < 1) return;
taskProgress.completeThrough(userId, TrainingTaskProgressService.EXCHANGE,
String.format("%02d", completedStep));
}
@Transactional(readOnly = true)

@ -1,7 +1,9 @@
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.security.crypto.password.PasswordEncoder;
@ -11,17 +13,16 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* 智云单点登录的本地账户落地:把智云 token 里的用户信息同步到本系统 {@code sys_user} 主表。
* 首次登录新增一条本地用户,之后每次登录更新;学校/学院/专业/班级等平台字段一并存在 sys_user 上。
*/
@Service
public class LocalSsoAccountService {
private final UserMapper userMapper;
private final PlatformUserSnapshotMapper snapshotMapper;
private final PasswordEncoder passwordEncoder;
public LocalSsoAccountService(UserMapper userMapper, PasswordEncoder passwordEncoder) {
public LocalSsoAccountService(UserMapper userMapper, PlatformUserSnapshotMapper snapshotMapper,
PasswordEncoder passwordEncoder) {
this.userMapper = userMapper;
this.snapshotMapper = snapshotMapper;
this.passwordEncoder = passwordEncoder;
}
@ -47,10 +48,6 @@ public class LocalSsoAccountService {
user.setClassName(token.getClassName());
user.setSchoolId(token.getSchoolId());
user.setSchoolName(token.getSchoolName());
user.setCollegeId(token.getCollegeId());
user.setCollegeName(token.getCollegeName());
user.setMajorId(token.getMajorId());
user.setMajorName(token.getMajorName());
user.setRoleId(token.getRoleId());
user.setIsDeleted(false);
user.setZyUserId(token.getUserId());
@ -60,6 +57,7 @@ public class LocalSsoAccountService {
} else {
userMapper.updateById(user);
}
upsertSnapshot(user.getUserId(), token);
return user;
}
@ -71,6 +69,33 @@ public class LocalSsoAccountService {
.last("LIMIT 1"));
}
private void upsertSnapshot(String localUserId, VerifiedPlatformToken token) {
PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(localUserId);
boolean newSnapshot = snapshot == null;
if (snapshot == null) {
snapshot = new PlatformUserSnapshotEntity();
snapshot.setPlatformUserId(localUserId);
}
snapshot.setAccount(token.getUsername());
snapshot.setDisplayName(token.getDisplayName());
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.setClassId(token.getClassId());
snapshot.setClassName(token.getClassName());
snapshot.setStudentId(token.getStudentId());
snapshot.setSourceUpdatedAt(LocalDateTime.now());
snapshot.setSyncedAt(LocalDateTime.now());
if (newSnapshot) {
snapshotMapper.insert(snapshot);
} else {
snapshotMapper.updateById(snapshot);
}
}
private boolean isUuid(String value) {
try {
UUID.fromString(value);

@ -0,0 +1,54 @@
package com.yau.digitalrmb.identity.infrastructure.persistence.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("platform_user_snapshot")
public class PlatformUserSnapshotEntity {
@TableId(value = "platform_user_id", type = IdType.INPUT)
private String platformUserId;
private String account;
private String displayName;
@TableField("school_id")
private String schoolId;
@TableField("school_name")
private String schoolName;
@TableField("college_id")
private String collegeId;
@TableField("college_name")
private String collegeName;
@TableField("major_id")
private String majorId;
@TableField("major_name")
private String majorName;
@TableField("class_id")
private String classId;
@TableField("class_name")
private String className;
@TableField("student_id")
private String studentId;
private LocalDateTime sourceUpdatedAt;
@TableField("synced_at")
private LocalDateTime syncedAt;
}

@ -42,18 +42,6 @@ public class UserEntity {
@TableField("school_id")
private String schoolId;
@TableField("college_id")
private String collegeId;
@TableField("college_name")
private String collegeName;
@TableField("major_id")
private String majorId;
@TableField("major_name")
private String majorName;
@TableField("authorize_time")
private LocalDate authorizeTime;

@ -0,0 +1,9 @@
package com.yau.digitalrmb.identity.infrastructure.persistence.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PlatformUserSnapshotMapper extends BaseMapper<PlatformUserSnapshotEntity> {
}

@ -10,8 +10,7 @@ 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, college_id, college_name, major_id, major_name, "
+ "authorize_time, authorize_end_time, role_id, "
+ "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);

@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import com.yau.digitalrmb.training.application.TrainingTaskProgressService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -27,9 +26,6 @@ public class InstitutionTrainingScoreService {
@Resource
private StuModuleScoreDetailsMapper scoreDetailsMapper;
@Resource
private TrainingTaskProgressService taskProgress;
@Value("${institution-training.score.wrong-deduct:0.50}")
private BigDecimal wrongDeduct = new BigDecimal("0.50");
@ -63,20 +59,6 @@ public class InstitutionTrainingScoreService {
detail.setSchedule(schedule);
detail.setScoreProject(score(errorCount, schedule));
scoreDetailsMapper.updateById(detail);
syncTaskProgress(userId, completedStep);
}
/**
* 同步 /api/v1/training-tasks 读取的任务步骤进度。
* 旧业务接口由拦截器写入,动作链则不经过拦截器,统一在步骤完成的这一处写入才不会漏;
* completeThrough 幂等,且与成绩更新在同一事务内。
*/
private void syncTaskProgress(String userId, int completedStep) {
if (taskProgress == null || completedStep < 1) {
return;
}
taskProgress.completeThrough(userId, TrainingTaskProgressService.GENERATION,
String.format("%02d", completedStep));
}
@Transactional(readOnly = true)

@ -3,7 +3,6 @@ package com.yau.digitalrmb.issuance.application.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import com.yau.digitalrmb.training.application.TrainingTaskProgressService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
@ -25,9 +24,6 @@ public class IssuanceScoreService {
@Resource
private StuModuleScoreDetailsMapper scoreDetailsMapper;
@Resource
private TrainingTaskProgressService taskProgress;
@Value("${issuance.score.wrong-deduct:0.50}")
private BigDecimal wrongDeduct = new BigDecimal("0.50");
@ -57,18 +53,6 @@ public class IssuanceScoreService {
detail.setSchedule(schedule);
detail.setScoreProject(score(errors, schedule));
scoreDetailsMapper.updateById(detail);
syncTaskProgress(userId, completedStep);
}
/**
* 同步 /api/v1/training-tasks 读取的任务步骤进度。
* 旧业务接口由拦截器写入,动作链则不经过拦截器,统一在步骤完成的这一处写入才不会漏;
* completeThrough 幂等,且与成绩更新在同一事务内。
*/
private void syncTaskProgress(String userId, int completedStep) {
if (taskProgress == null || completedStep < 1) return;
taskProgress.completeThrough(userId, TrainingTaskProgressService.ISSUANCE,
String.format("%02d", completedStep));
}
@Transactional(readOnly = true)

@ -3,7 +3,6 @@ package com.yau.digitalrmb.payment.application.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import com.yau.digitalrmb.training.application.TrainingTaskProgressService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
@ -25,9 +24,6 @@ public class PaymentScoreService {
@Resource
private StuModuleScoreDetailsMapper scoreDetailsMapper;
@Resource
private TrainingTaskProgressService taskProgress;
@Value("${payment.score.wrong-deduct:0.50}")
private BigDecimal wrongDeduct = new BigDecimal("0.50");
@ -57,18 +53,6 @@ public class PaymentScoreService {
detail.setSchedule(schedule);
detail.setScoreProject(score(errors, schedule));
scoreDetailsMapper.updateById(detail);
syncTaskProgress(userId, completedStep);
}
/**
* 同步 /api/v1/training-tasks 读取的任务步骤进度。
* 旧业务接口由拦截器写入,动作链则不经过拦截器,统一在步骤完成的这一处写入才不会漏;
* completeThrough 幂等,且与成绩更新在同一事务内。
*/
private void syncTaskProgress(String userId, int completedStep) {
if (taskProgress == null || completedStep < 1) return;
taskProgress.completeThrough(userId, TrainingTaskProgressService.PAYMENT,
String.format("%02d", completedStep));
}
@Transactional(readOnly = true)

@ -2,6 +2,8 @@ package com.yau.digitalrmb.platformintegration.application;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
@ -13,26 +15,20 @@ import java.time.Clock;
import java.time.Instant;
import java.util.Base64;
/**
* 校验智云(ZhiYun)下发的 SSO JWT。
* 逻辑与 cross_training_platform 的 TokenProvider.getJWTUserByZhiYun 保持一致:
* HS256 验签、共享密钥写死为本系统对接智云的同一把密钥、解析失败直接抛异常
* (不做子系统 token 兜底)。
*/
@Component
@ConditionalOnProperty(prefix = "platform-integration", name = "enabled", havingValue = "true")
public class PlatformTokenVerifier {
/** 智云 SSO 共享密钥,与 cross_training_platform 的 TokenProvider.SECRET_ZHIYUN 为同一把 */
private static final String ZHIYUN_SECRET = "zy_kzy_mnjy_fp76ckwuczzmb67w0a8x0";
private final PlatformIntegrationProperties.Token properties;
private final ObjectMapper objectMapper;
private final Clock clock;
public PlatformTokenVerifier() {
this(new ObjectMapper(), Clock.systemUTC());
@Autowired
public PlatformTokenVerifier(PlatformIntegrationProperties properties) {
this(properties.getToken(), new ObjectMapper(), Clock.systemUTC());
}
PlatformTokenVerifier(ObjectMapper objectMapper, Clock clock) {
PlatformTokenVerifier(PlatformIntegrationProperties.Token properties, ObjectMapper objectMapper, Clock clock) {
this.properties = properties;
this.objectMapper = objectMapper;
this.clock = clock;
}
@ -53,44 +49,30 @@ public class PlatformTokenVerifier {
}
verifySignature(parts);
JsonNode payload = readJson(parts[1]);
// exp 校验(智云 token 可能不带 exp,不带则跳过)
JsonNode expiration = payload.path("exp");
if (!expiration.isMissingNode()
&& (!expiration.canConvertToLong()
|| Instant.ofEpochSecond(expiration.asLong()).compareTo(now) <= 0)) {
throw invalid();
}
String userId = requiredText(payload, "userId");
String username = requiredText(payload, "username");
String password = optionalText(payload, "password");
if (password == null || password.trim().isEmpty()) {
if (password == null) {
password = "123qwe";
}
int roleId;
JsonNode roleNode = payload.path("roleid");
if (roleNode.isMissingNode() || !roleNode.canConvertToLong()) {
roleId = 4;
} else {
roleId = (int) roleNode.asLong();
}
int roleId = requiredRoleId(payload);
String displayName = optionalText(payload, "name");
if (displayName == null || displayName.trim().isEmpty()) {
displayName = username;
}
String schoolId = optionalText(payload, "schoolId", "0");
String schoolName = optionalText(payload, "school", "schoolName");
String classId = optionalText(payload, "classId");
String className = optionalText(payload, "class");
String studentId = optionalText(payload, "studentNo", "studentId", "studentid");
String collegeId = optionalText(payload, "collegeId");
String collegeName = optionalText(payload, "collegeName", "college");
String majorId = optionalText(payload, "majorId");
String majorName = optionalText(payload, "majorName", "major");
return new VerifiedPlatformToken(userId, username, displayName, password, roleId,
schoolId, schoolName, collegeId, collegeName, majorId, majorName, classId, className, studentId);
optionalText(payload, "schoolId"),
optionalText(payload, "schoolName", "school"), optionalText(payload, "collegeId"),
optionalText(payload, "collegeName"), optionalText(payload, "majorId"),
optionalText(payload, "majorName", "major"), optionalText(payload, "classId"),
optionalText(payload, "className", "class"),
optionalText(payload, "studentId", "studentid", "studentNo"));
} catch (PlatformTokenException exception) {
throw exception;
} catch (Exception exception) {
@ -100,25 +82,32 @@ public class PlatformTokenVerifier {
private void verifySignature(String[] parts) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(ZHIYUN_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.init(new SecretKeySpec(properties.getLinkSecretKey().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] expected = mac.doFinal((parts[0] + "." + parts[1]).getBytes(StandardCharsets.US_ASCII));
if (!MessageDigest.isEqual(expected, Base64.getUrlDecoder().decode(parts[2]))) {
throw invalid();
}
}
private JsonNode readJson(String encoded) throws Exception {
return objectMapper.readTree(Base64.getUrlDecoder().decode(encoded));
}
private String requiredText(JsonNode payload, String name) {
String value = optionalText(payload, name);
if (value == null) {
private int requiredRoleId(JsonNode payload) {
long roleId = requiredLong(payload, "roleid", "roleId");
if (roleId != 1L && roleId != 3L && roleId != 4L) {
throw invalid();
}
return value;
return (int) roleId;
}
private JsonNode readJson(String encoded) throws Exception { return objectMapper.readTree(Base64.getUrlDecoder().decode(encoded)); }
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... names) {
for (String name : names) {
JsonNode node = payload.path(name);
@ -131,8 +120,5 @@ public class PlatformTokenVerifier {
}
return null;
}
private PlatformTokenException invalid() {
return new PlatformTokenException("平台登录凭据无效");
}
private PlatformTokenException invalid() { return new PlatformTokenException("平台登录凭据无效"); }
}

@ -13,9 +13,18 @@ import javax.validation.constraints.NotBlank;
@Validated
@ConfigurationProperties("platform-integration")
public class PlatformIntegrationProperties {
@Valid
private Token token = new Token();
@Valid
private Frontend frontend = new Frontend();
@Getter
@Setter
public static class Token {
@NotBlank
private String linkSecretKey;
}
@Getter
@Setter
public static class Frontend {

@ -53,7 +53,7 @@ public class ModuleCompletionService {
schedule = detail != null && detail.getSchedule() != null ? detail.getSchedule() : 0.0;
} else {
List<String> completedStepCodes = jdbcTemplate.queryForList(
"SELECT step_code FROM training_task_step_progress WHERE user_id = ? AND module_code = ? AND completed_at IS NOT NULL",
"SELECT step_code FROM training_task_step_progress WHERE user_id = ? AND module_code = ?",
String.class, userId, config.trainingModuleCode);
int completedSteps = completedStepCodes.size();
schedule = config.totalSteps > 0
@ -133,7 +133,7 @@ public class ModuleCompletionService {
private ModuleCompletionResult getFromTrainingTaskTable(String userId, String moduleCode, ModuleConfig config) {
List<String> completedStepCodes = jdbcTemplate.queryForList(
"SELECT step_code FROM training_task_step_progress WHERE user_id = ? AND module_code = ? AND completed_at IS NOT NULL",
"SELECT step_code FROM training_task_step_progress WHERE user_id = ? AND module_code = ?",
String.class, userId, config.trainingModuleCode);
int completedSteps = completedStepCodes.size();

@ -1,6 +1,8 @@
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;
@ -9,21 +11,26 @@ import org.springframework.stereotype.Service;
@Service
public class CurrentUserService {
private final PlatformUserSnapshotMapper snapshotMapper;
private final UserMapper userMapper;
public CurrentUserService(UserMapper userMapper) {
public CurrentUserService(PlatformUserSnapshotMapper snapshotMapper, UserMapper userMapper) {
this.snapshotMapper = snapshotMapper;
this.userMapper = userMapper;
}
public CurrentUser getCurrentUser() {
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(user.getSchoolId(), user.getSchoolName(),
user.getCollegeId(), user.getCollegeName(),
user.getMajorId(), user.getMajorName(),
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());
}

@ -2,7 +2,9 @@ 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;
@ -42,6 +44,9 @@ public class UserController {
@Resource
private UserMapper userMapper;
@Resource
private PlatformUserSnapshotMapper snapshotMapper;
@PostMapping("/login")
@Operation(summary = "用户登录", description = "支持账号密码登录和智云TOKEN单点登录")
public UserLoginResult<UserLoginResponse> login(
@ -77,11 +82,15 @@ public class UserController {
}
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(),
user.getCollegeId(), user.getCollegeName(), user.getMajorId(), user.getMajorName(),
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);
}

@ -6,8 +6,6 @@ public enum ErrorCode {
UNAUTHORIZED(401),
FORBIDDEN(403),
RESOURCE_NOT_FOUND(404),
/** 请求与资源当前状态冲突,例如已锁定的答案不允许再被覆盖。 */
CONFLICT(409),
INTERNAL_ERROR(500);
private final int code;

@ -65,8 +65,6 @@ public class GlobalExceptionHandler {
return HttpStatus.FORBIDDEN;
case RESOURCE_NOT_FOUND:
return HttpStatus.NOT_FOUND;
case CONFLICT:
return HttpStatus.CONFLICT;
default:
return HttpStatus.INTERNAL_SERVER_ERROR;
}

@ -1,57 +1,25 @@
package com.yau.digitalrmb.training.application;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowCallbackHandler;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 五个实验模块统一的任务步骤进度。
*
* <p>每个步骤有两类信息:
* <ul>
* <li>完成记录(completed_at):真实业务动作成功后由各模块写入,决定 COMPLETED;</li>
* <li>答案草稿(step_data + saved_at):学员操作过程中随时保存的一段 JSON,决定 SAVED。</li>
* </ul>
* 步骤状态:COMPLETED 已完成(已提交)、CURRENT 下一个可操作的步骤、SAVED 有未提交的草稿、
* LOCKED 前面还有步骤未完成。草稿不计入完成数。
*
* <p>防止学员事后改答案:步骤完成之前答案可以随时覆盖保存;步骤完成之后只再接受<b>一次</b>保存
* (页面在最后一个业务动作完成后会自动存一次最终结果),随后答案被锁定(locked=true),
* 不同内容的保存一律返回 409,原答案保留;重新实训会清空整个模块的进度和锁定状态。
*/
@Service
public class TrainingTaskProgressService {
// 模块编码:供各模块评分服务在步骤完成时统一写入进度
public static final String GENERATION = "PBOC_CURRENCY_GENERATION";
public static final String ISSUANCE = "PBOC_CURRENCY_ISSUANCE";
public static final String WALLET = "WALLET_OPENING";
public static final String EXCHANGE = "CURRENCY_EXCHANGE";
public static final String PAYMENT = "CURRENCY_PAYMENT";
/** 单个步骤答案 JSON 的最大字符数(对应 MEDIUMTEXT 列,留足中文按 4 字节计的余量)。 */
public static final int MAX_STEP_DATA_LENGTH = 100_000;
// 只用于校验草稿是否为合法 JSON;开启尾部多余内容检查,避免 `{"a":1} x` 这种串被存进去
private static final ObjectMapper JSON = new ObjectMapper()
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
private static final String GENERATION = "PBOC_CURRENCY_GENERATION";
private static final String ISSUANCE = "PBOC_CURRENCY_ISSUANCE";
private static final String WALLET = "WALLET_OPENING";
private static final String EXCHANGE = "CURRENCY_EXCHANGE";
private static final String PAYMENT = "CURRENCY_PAYMENT";
private final JdbcTemplate jdbc;
private final Map<String, ModuleDefinition> definitions;
@ -68,76 +36,24 @@ public class TrainingTaskProgressService {
}
public TrainingTaskView one(String userId, String moduleCode) {
ModuleDefinition module = requireModule(moduleCode);
return view(module, rows(userId, moduleCode));
}
/** 单个步骤的详情,含已保存的答案 JSON(没有保存过则 stepData 为空)。 */
public TrainingStepDetailView stepDetail(String userId, String moduleCode, String stepCode) {
ModuleDefinition module = requireModule(moduleCode);
requireStep(module, stepCode);
TrainingTaskView task = view(module, rows(userId, moduleCode));
TrainingStepView step = task.getSteps().get(module.indexOf(stepCode));
List<String> data = jdbc.queryForList(
"SELECT step_data FROM training_task_step_progress WHERE user_id = ? AND module_code = ? AND step_code = ?",
String.class, userId, moduleCode, stepCode);
return new TrainingStepDetailView(moduleCode, step.getStepCode(), step.getStepName(), step.getStatus(),
step.isActionAvailable(), step.isHasData(), step.getSavedAt(), step.isLocked(),
data.isEmpty() ? null : data.get(0));
}
/**
* 随时保存某一步的答案,重复保存覆盖上一次。
* 只写答案,不改变完成状态:已完成的步骤保存后仍是 COMPLETED;
* 允许保存还未解锁的步骤(页面可自由浏览各步),它只显示为 SAVED,不会提前解锁后续步骤。
*
* <p>锁定规则:步骤未完成时可反复覆盖;步骤完成后接受一次保存并随即锁定,
* 锁定后再提交<b>不同</b>的内容返回 409(CONFLICT),提交完全相同的内容视为成功(幂等,便于页面重试)。
*/
@Transactional
public TrainingTaskView saveStep(String userId, String moduleCode, String stepCode, String stepData) {
ModuleDefinition module = requireModule(moduleCode);
requireStep(module, stepCode);
requireValidStepData(stepData);
Timestamp now = Timestamp.from(Instant.now());
// 行锁串行化同一步骤的并发保存,避免两个请求同时越过锁定检查
List<Map<String, Object>> existing = jdbc.queryForList(
"SELECT completed_at, sealed_at, step_data FROM training_task_step_progress "
+ "WHERE user_id = ? AND module_code = ? AND step_code = ? FOR UPDATE",
userId, moduleCode, stepCode);
if (existing.isEmpty()) {
jdbc.update("INSERT INTO training_task_step_progress (user_id, module_code, step_code, step_data, saved_at) "
+ "VALUES (?, ?, ?, ?, ?) "
+ "ON DUPLICATE KEY UPDATE step_data = VALUES(step_data), saved_at = VALUES(saved_at)",
userId, moduleCode, stepCode, stepData, now);
return one(userId, moduleCode);
}
Map<String, Object> row = existing.get(0);
boolean completed = row.get("completed_at") != null;
if (row.get("sealed_at") != null) {
if (sameJson(stepData, (String) row.get("step_data"))) return one(userId, moduleCode);
throw new BusinessException(ErrorCode.CONFLICT, "该步骤已完成,答案已锁定,不能再修改;如需重新作答请点击「重新实训」");
}
// 已完成的步骤:这是最后一次允许的保存,写入同时锁定
jdbc.update("UPDATE training_task_step_progress SET step_data = ?, saved_at = ?, sealed_at = ? "
+ "WHERE user_id = ? AND module_code = ? AND step_code = ?",
stepData, now, completed ? now : null, userId, moduleCode, stepCode);
return one(userId, moduleCode);
ModuleDefinition module = definitions.get(moduleCode);
if (module == null) throw new IllegalArgumentException("不支持的实训模块:" + moduleCode);
List<String> completed = jdbc.queryForList("SELECT step_code FROM training_task_step_progress WHERE user_id = ? AND module_code = ?",
String.class, userId, moduleCode);
return view(module, completed);
}
/** 真实业务步骤成功后调用;同步补齐此前步骤,避免历史业务数据导致进度断层。已有草稿的步骤保留答案并变为完成。 */
/** 真实业务步骤成功后调用;同步补齐此前步骤,避免历史业务数据导致进度断层。 */
public void completeThrough(String userId, String moduleCode, String stepCode) {
ModuleDefinition module = definitions.get(moduleCode);
if (module == null || module.unavailable) return;
int index = module.indexOf(stepCode);
if (index < 0) return;
Timestamp now = Timestamp.from(Instant.now());
Instant now = Instant.now();
for (int i = 0; i <= index; i++) {
String currentStep = module.steps.get(i).code;
// 已有完成时间的保持原值;只有草稿的行补上完成时间
jdbc.update("INSERT INTO training_task_step_progress (user_id, module_code, step_code, completed_at) VALUES (?, ?, ?, ?) "
+ "ON DUPLICATE KEY UPDATE completed_at = COALESCE(completed_at, VALUES(completed_at))",
+ "ON DUPLICATE KEY UPDATE completed_at = completed_at",
userId, moduleCode, currentStep, now);
}
}
@ -146,37 +62,13 @@ public class TrainingTaskProgressService {
jdbc.update("DELETE FROM training_task_step_progress WHERE user_id = ? AND module_code = ?", userId, moduleCode);
}
/** 对外的重置入口:先校验模块编码,非法编码返回 400 而不是静默成功。 */
@Transactional
public void resetModule(String userId, String moduleCode) {
requireModule(moduleCode);
reset(userId, moduleCode);
}
private Map<String, Row> rows(String userId, String moduleCode) {
Map<String, Row> result = new HashMap<>();
jdbc.query("SELECT step_code, completed_at, saved_at, sealed_at, CASE WHEN step_data IS NULL THEN 0 ELSE 1 END AS has_data "
+ "FROM training_task_step_progress WHERE user_id = ? AND module_code = ?",
(RowCallbackHandler) rs -> {
Timestamp savedAt = rs.getTimestamp("saved_at");
result.put(rs.getString("step_code"), new Row(rs.getTimestamp("completed_at") != null,
rs.getInt("has_data") == 1, savedAt == null ? null : savedAt.toInstant(),
rs.getTimestamp("sealed_at") != null));
}, userId, moduleCode);
return result;
}
private TrainingTaskView view(ModuleDefinition module, Map<String, Row> rows) {
private TrainingTaskView view(ModuleDefinition module, List<String> completed) {
List<TrainingStepView> steps = new ArrayList<>();
boolean prefixCompleted = true;
int completedCount = 0;
int savedCount = 0;
String currentStepCode = null;
for (StepDefinition step : module.steps) {
Row row = rows.get(step.code);
boolean done = prefixCompleted && row != null && row.completed;
// 草稿 = 有答案数据但还没完成;已完成步骤重新保存不会变回草稿
boolean draft = !done && row != null && !row.completed && row.hasData;
boolean done = prefixCompleted && completed.contains(step.code);
String status;
boolean actionAvailable = false;
if (done) {
@ -186,58 +78,18 @@ public class TrainingTaskProgressService {
status = "NOT_STARTED";
prefixCompleted = false;
} else if (prefixCompleted) {
status = draft ? "SAVED" : "CURRENT";
status = "CURRENT";
actionAvailable = true;
currentStepCode = step.code;
prefixCompleted = false;
} else {
status = draft ? "SAVED" : "LOCKED";
status = "LOCKED";
}
if ("SAVED".equals(status)) savedCount++;
steps.add(new TrainingStepView(step.code, step.name, step.sortNo, status, actionAvailable,
row != null && row.hasData, row == null ? null : row.savedAt, row != null && row.sealed));
}
String status = completedCount == module.steps.size() ? "COMPLETED"
: completedCount == 0 && savedCount == 0 ? "NOT_STARTED" : "IN_PROGRESS";
return new TrainingTaskView(module.code, module.name, status, module.steps.size(), completedCount, savedCount,
currentStepCode, module.unavailable, module.unavailableReason, steps);
}
private ModuleDefinition requireModule(String moduleCode) {
ModuleDefinition module = definitions.get(moduleCode);
if (module == null) throw validation("不支持的实训模块:" + moduleCode);
return module;
}
private void requireStep(ModuleDefinition module, String stepCode) {
if (module.indexOf(stepCode) < 0) throw validation("不支持的实训步骤:" + module.code + " 第 " + stepCode + " 步");
}
private void requireValidStepData(String stepData) {
if (stepData == null || stepData.trim().isEmpty()) throw validation("答案数据不能为空");
if (stepData.length() > MAX_STEP_DATA_LENGTH) throw validation("答案数据过大,最多 " + MAX_STEP_DATA_LENGTH + " 个字符");
JsonNode node;
try {
node = JSON.readTree(stepData);
} catch (JsonProcessingException exception) {
throw validation("答案数据不是合法的 JSON");
}
if (node == null || !(node.isObject() || node.isArray())) throw validation("答案数据必须是 JSON 对象或数组");
}
/** 按 JSON 语义比较两份答案:忽略空白和对象键顺序,数组顺序与数值仍必须一致。 */
private boolean sameJson(String incoming, String stored) {
if (stored == null) return false;
if (incoming.equals(stored)) return true;
try {
return JSON.readTree(incoming).equals(JSON.readTree(stored));
} catch (JsonProcessingException exception) {
return false;
steps.add(new TrainingStepView(step.code, step.name, step.sortNo, status, actionAvailable));
}
}
private BusinessException validation(String message) {
return new BusinessException(ErrorCode.VALIDATION_ERROR, message);
String status = completedCount == module.steps.size() ? "COMPLETED" : completedCount == 0 ? "NOT_STARTED" : "IN_PROGRESS";
return new TrainingTaskView(module.code, module.name, status, module.steps.size(), completedCount, currentStepCode,
module.unavailable, module.unavailableReason, steps);
}
private Map<String, ModuleDefinition> definitions() {
@ -246,9 +98,8 @@ public class TrainingTaskProgressService {
"机构身份信息与密钥准备", "生成数字货币请求", "验证货币生成请求", "生成交易信息标识", "生成额度控制位请求", "验证额度控制位请求", "控制系统签名", "生成额度控制位", "生成标准面额币串"));
result.put(ISSUANCE, module(ISSUANCE, "货币发行", false, null,
"查看货币需求", "生成发行申请计划", "发行计划数字签名", "发送标准报文", "中央银行验证与业务核查", "准备金通知与ACS扣减", "发行并确权数字货币"));
// 步骤与 ModuleCompletionService / 前端 WalletOpen 的 5 个步骤一致
result.put(WALLET, module(WALLET, "开通数字货币钱包", false, null,
"商业银行钱包开立", "钱包标识生成", "钱包注册", "智能合约签订", "央行钱包激活"));
result.put(WALLET, module(WALLET, "开立数字钱包", true, "钱包开立真实业务接口尚未实现",
"开立数字钱包"));
result.put(EXCHANGE, module(EXCHANGE, "货币兑换", false, null,
"确认银行卡、钱包和商业银行库存", "生成取币请求报文", "钱包私钥签名", "商业银行验签并扣款", "央行确认币串权属"));
result.put(PAYMENT, module(PAYMENT, "数字货币支付", false, null,
@ -262,33 +113,15 @@ public class TrainingTaskProgressService {
return new ModuleDefinition(code, name, unavailable, reason, steps);
}
/** 模块进度。status:NOT_STARTED 未开始 / IN_PROGRESS 进行中(有完成或草稿)/ COMPLETED 全部完成。 */
@Getter @AllArgsConstructor
public static class TrainingTaskView {
private final String moduleCode; private final String moduleName; private final String status;
private final int totalSteps; private final int completedSteps; private final int savedSteps;
private final String currentStepCode;
private final int totalSteps; private final int completedSteps; private final String currentStepCode;
private final boolean actionUnavailable; private final String unavailableReason; private final List<TrainingStepView> steps;
}
/** 步骤进度。status:COMPLETED 已完成 / CURRENT 可操作 / SAVED 有草稿未完成 / LOCKED 锁定 / NOT_STARTED 模块不可用。 */
@Getter @AllArgsConstructor
public static class TrainingStepView {
private final String stepCode; private final String stepName; private final int sortNo; private final String status;
private final boolean actionAvailable; private final boolean hasData; private final Instant savedAt;
/** 答案已锁定:步骤完成后的最后一次保存已落库,之后不可再修改。 */
private final boolean locked;
}
@Getter @AllArgsConstructor
public static class TrainingStepDetailView {
private final String moduleCode; private final String stepCode; private final String stepName; private final String status;
private final boolean actionAvailable; private final boolean hasData; private final Instant savedAt; private final boolean locked;
private final String stepData;
}
private static class Row {
private final boolean completed; private final boolean hasData; private final Instant savedAt; private final boolean sealed;
private Row(boolean completed, boolean hasData, Instant savedAt, boolean sealed) {
this.completed = completed; this.hasData = hasData; this.savedAt = savedAt; this.sealed = sealed;
}
private final String stepCode; private final String stepName; private final int sortNo; private final String status; private final boolean actionAvailable;
}
private static class ModuleDefinition {
private final String code, name; private final boolean unavailable; private final String unavailableReason; private final List<StepDefinition> steps;

@ -9,11 +9,7 @@ import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 将已成功返回的真实业务操作映射为对应模块任务步骤;不暴露独立的人工完成接口。
* 只认会改变业务数据的写操作:仅仅打开页面的读取(查询上下文、查看库存等)不能算步骤完成,
* 否则学员一进页面步骤就被当成已完成,答案也会被提前锁定。
*/
/** 将已成功返回的真实业务操作映射为对应模块任务步骤;不暴露独立的人工完成接口。 */
@Component
public class TrainingTaskProgressInterceptor implements HandlerInterceptor {
private final TrainingTaskProgressService progressService;
@ -52,6 +48,7 @@ public class TrainingTaskProgressInterceptor implements HandlerInterceptor {
if ("POST".equals(method) && "/api/v1/institution-identifiers/quota-control-bits/steps/send".equals(path)) return pair("PBOC_CURRENCY_GENERATION", "08");
if ("POST".equals(method) && "/api/v1/institution-identifiers/standard-currencies/steps/generate".equals(path)) return pair("PBOC_CURRENCY_GENERATION", "09");
if ("GET".equals(method) && "/api/v1/commercial-banks/issuance/inventory".equals(path)) return pair("PBOC_CURRENCY_ISSUANCE", "01");
if ("POST".equals(method) && path.matches("/api/v1/commercial-banks/issuance/requests/[^/]+/prepare-message")) return pair("PBOC_CURRENCY_ISSUANCE", "02");
if ("POST".equals(method) && path.matches("/api/v1/commercial-banks/issuance/requests/[^/]+/sign")) return pair("PBOC_CURRENCY_ISSUANCE", "03");
if ("POST".equals(method) && path.matches("/api/v1/commercial-banks/issuance/requests/[^/]+/send")) return pair("PBOC_CURRENCY_ISSUANCE", "04");
@ -59,19 +56,13 @@ public class TrainingTaskProgressInterceptor implements HandlerInterceptor {
if ("POST".equals(method) && (path.matches("/api/v1/central-banks/issuance/reserve-deductions/[^/]+/execute") || "/api/v1/central-banks/issuance/reserve-deductions".equals(path))) return pair("PBOC_CURRENCY_ISSUANCE", "06");
if ("POST".equals(method) && path.matches("/api/v1/central-banks/issuance/requests/[^/]+/digital-currency-ownership")) return pair("PBOC_CURRENCY_ISSUANCE", "07");
// 模块三(开通数字货币钱包):每步取前端该步「业务完成判据」对应的最后一个写操作,只认成功的 POST
if ("POST".equals(method) && "/api/v1/wallet-opening/training/reset".equals(path)) return pair("WALLET_OPENING", "RESET");
if ("POST".equals(method) && "/api/v1/wallet-opening/send".equals(path)) return pair("WALLET_OPENING", "01");
if ("POST".equals(method) && "/api/v1/commercial-banks/wallet-opening/step2/confirm-collection".equals(path)) return pair("WALLET_OPENING", "02");
if ("POST".equals(method) && "/api/v1/central-banks/wallet-opening/step3/confirm".equals(path)) return pair("WALLET_OPENING", "03");
if ("POST".equals(method) && "/api/v1/commercial-banks/wallet-opening/step4/send".equals(path)) return pair("WALLET_OPENING", "04");
if ("POST".equals(method) && "/api/v1/central-banks/wallet-opening/step5/send".equals(path)) return pair("WALLET_OPENING", "05");
if ("GET".equals(method) && "/api/v1/exchanges/context".equals(path)) return pair("CURRENCY_EXCHANGE", "01");
if ("POST".equals(method) && "/api/v1/exchanges".equals(path)) return pair("CURRENCY_EXCHANGE", "02");
if ("POST".equals(method) && path.matches("/api/v1/exchanges/[^/]+/sign")) return pair("CURRENCY_EXCHANGE", "03");
if ("POST".equals(method) && path.matches("/api/v1/exchanges/[^/]+/bank-process")) return pair("CURRENCY_EXCHANGE", "04");
if ("POST".equals(method) && path.matches("/api/v1/exchanges/[^/]+/confirm-ownership")) return pair("CURRENCY_EXCHANGE", "05");
if ("GET".equals(method) && "/api/v1/payments/context".equals(path)) return pair("CURRENCY_PAYMENT", "01");
if ("POST".equals(method) && path.matches("/api/v1/payments/[^/]+/sign")) return pair("CURRENCY_PAYMENT", "02");
if ("POST".equals(method) && path.matches("/api/v1/payments/[^/]+/payer-bank-process")) return pair("CURRENCY_PAYMENT", "03");
if ("POST".equals(method) && path.matches("/api/v1/payments/[^/]+/central-bank-settle")) return pair("CURRENCY_PAYMENT", "04");

@ -7,23 +7,16 @@ import com.yau.digitalrmb.training.application.TrainingTaskProgressService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.MDC;
import com.yau.digitalrmb.training.interfaces.rest.dto.TrainingStepSaveRequest;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.List;
@RestController
@RequestMapping("/api/v1/training-tasks")
@Tag(name = "实训任务进度",
description = "按当前用户返回五个实验模块的步骤进度。步骤状态:COMPLETED 已完成(已提交)、CURRENT 可操作、"
+ "SAVED 已保存草稿未完成、LOCKED 前序步骤未完成。草稿不计入完成数。")
@Tag(name = "实训任务进度", description = "按当前用户分别返回五个实验模块的子步骤完成进度")
public class TrainingTaskProgressController {
private final TrainingTaskProgressService service;
private final CurrentUserService currentUserService;
@ -37,33 +30,6 @@ public class TrainingTaskProgressController {
@Operation(summary = "查询当前用户单个实训任务进度")
public ApiResponse<TrainingTaskProgressService.TrainingTaskView> one(@PathVariable String moduleCode) { return ok(service.one(userId(), moduleCode)); }
@GetMapping("/{moduleCode}/steps/{stepCode}")
@Operation(summary = "查询单个步骤的进度和已保存的答案",
description = "stepData 为最近一次保存的答案 JSON 字符串;没有保存过则为 null。")
public ApiResponse<TrainingTaskProgressService.TrainingStepDetailView> step(
@PathVariable String moduleCode, @PathVariable String stepCode) {
return ok(service.stepDetail(userId(), moduleCode, stepCode));
}
@PutMapping("/{moduleCode}/steps/{stepCode}")
@Operation(summary = "保存单个步骤的答案(随时保存)",
description = "重复保存覆盖上一次。只保存草稿,不会把步骤标记为完成;步骤完成仍由真实业务操作成功后自动记录,"
+ "已完成的步骤重新保存后依然是 COMPLETED。返回保存后该模块的最新进度。")
public ApiResponse<TrainingTaskProgressService.TrainingTaskView> saveStep(
@PathVariable String moduleCode, @PathVariable String stepCode,
@Valid @RequestBody TrainingStepSaveRequest request) {
return ok(service.saveStep(userId(), moduleCode, stepCode, request.getStepData()));
}
@DeleteMapping("/{moduleCode}")
@Operation(summary = "重新实训:清空某个模块的步骤进度",
description = "删除当前用户在该模块下的全部步骤进度,包括完成记录和已保存的答案。幂等,没有数据时同样成功。"
+ "只清进度,不会回滚各模块已产生的业务数据。")
public ApiResponse<Void> reset(@PathVariable String moduleCode) {
service.resetModule(userId(), moduleCode);
return ok(null);
}
private String userId() { return currentUserService.getCurrentUser().getUserId(); }
private <T> ApiResponse<T> ok(T data) { return ApiResponse.success(data, MDC.get(TraceIdFilter.MDC_KEY)); }
}

@ -1,19 +0,0 @@
package com.yau.digitalrmb.training.interfaces.rest.dto;
import com.yau.digitalrmb.training.application.TrainingTaskProgressService;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
@Data
@Schema(description = "保存实训步骤答案(草稿)")
public class TrainingStepSaveRequest {
@NotBlank(message = "答案数据不能为空")
@Size(max = TrainingTaskProgressService.MAX_STEP_DATA_LENGTH, message = "答案数据过大")
@Schema(description = "答案数据,必须是 JSON 对象或数组序列化后的字符串,后端原样存储",
example = "{\"payerWalletId\":\"WALLET_A\",\"amount\":\"1000.00\",\"note\":\"房租\"}")
private String stepData;
}

@ -14,6 +14,8 @@ knife4j:
enable: true
platform-integration:
enabled: true
token:
link-secret-key: ${DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY:zy_kzy_mnjy_fp76ckwuczzmb67w0a8x0}
security:
jwt:
secret: 0123456789012345678901234567890123456789012345678901234567890123

@ -9,5 +9,7 @@ security:
secret: 0123456789012345678901234567890123456789012345678901234567890123
access-token-ttl: PT4H
platform-integration:
token:
link-secret-key: local-token-sso-test-secret-key-123456
frontend:
callback-url: https://rmb.example.edu/sso-callback

@ -40,6 +40,8 @@ springdoc:
knife4j:
enable: false
platform-integration:
token:
link-secret-key: ${DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY}
frontend:
callback-url: ${DIGITAL_RMB_FRONTEND_CALLBACK_URL:http://localhost:5173/sso-callback}
institution-training:

@ -1,72 +0,0 @@
-- 补录历史数据中漏记的「步骤已完成」记录(training_task_step_progress.completed_at)。
-- 背景:模块二、四、五的动作链、模块三的各接口原先不会写这张表,页面与 /api/v1/training-tasks 因此少算已做完的步骤。
-- 新代码上线后这些步骤会在完成时自动记录;本脚本只补上线前已经做完、但没有记录的部分。
--
-- 判定依据(都来自真实业务数据,不凭空标记):
-- 模块二/四/五:某步骤的「最后一个动作」已 COMPLETED,且所属实验未被取消,
-- 该步骤内没有被回滚或失败的动作(如兑换步骤四的扣款回滚)。完成时间取该动作的完成时间。
-- 模块三:央行钱包激活记录已走完最终确权并发送(与「重置兑换/支付」判断模块三已完成的口径一致),
-- 则 01~05 五个步骤都视为完成,完成时间取该记录的最后更新时间。
-- 只补缺失:已有完成时间的不改;已有草稿答案的保留;可重复执行。
-- 模块一的记录一直由接口拦截器写入,无需补录。
-- 第三部分把「已完成且已有答案」的步骤封存(锁定),让上线前就已经做完的答案也不能再被覆盖。
-- 请在发布时执行一次:它会让这些步骤放弃「完成后再接受一次保存」的机会,
-- 所以不要在业务高峰期、学员正好做完某一步的瞬间重复执行。
-- 一、模块二(发行)、四(兑换)、五(支付):按动作链补录
INSERT INTO training_task_step_progress (user_id, module_code, step_code, completed_at)
SELECT a.user_id,
CASE a.module_code WHEN 'ISSUANCE' THEN 'PBOC_CURRENCY_ISSUANCE'
WHEN 'EXCHANGE' THEN 'CURRENCY_EXCHANGE'
WHEN 'PAYMENT' THEN 'CURRENCY_PAYMENT' END,
x.step_code, MIN(x.completed_at)
FROM training_experiment_attempt a
JOIN training_experiment_action x ON x.attempt_id = a.id
WHERE a.status IN ('IN_PROGRESS', 'SUBMITTED', 'COMPLETED')
AND x.status = 'COMPLETED' AND x.completed_at IS NOT NULL
AND ((a.module_code = 'ISSUANCE' AND
((x.step_code = '01' AND x.action_code = 'send-application')
OR (x.step_code = '02' AND x.action_code = 'compare-digest')
OR (x.step_code = '03' AND x.action_code = 'query-application-message')
OR (x.step_code = '04' AND x.action_code = 'generate-reserve-deduction-request')
OR (x.step_code = '05' AND x.action_code = 'send-balance-notice')
OR (x.step_code = '06' AND x.action_code = 'generate-production-digest')
OR (x.step_code = '07' AND x.action_code = 'load-ownership-result')))
OR (a.module_code = 'EXCHANGE' AND
((x.step_code = '01' AND x.action_code = 'refresh-institution')
OR (x.step_code = '02' AND x.action_code = 'output-withdrawal-request')
OR (x.step_code = '03' AND x.action_code = 'send-request')
OR (x.step_code = '04' AND x.action_code = 'output-debit-voucher')
OR (x.step_code = '05' AND x.action_code = 'user-confirm')))
OR (a.module_code = 'PAYMENT' AND
((x.step_code = '01' AND x.action_code = 'refresh-payee')
OR (x.step_code = '02' AND x.action_code = 'send-payment')
OR (x.step_code = '03' AND x.action_code = 'send-to-central-bank')
OR (x.step_code = '04' AND x.action_code = 'return-settlement')
OR (x.step_code = '05' AND x.action_code = 'execute-credit')
OR (x.step_code = '06' AND x.action_code = 'notify-payee'))))
AND NOT EXISTS (SELECT 1 FROM training_experiment_action bad
WHERE bad.attempt_id = a.id AND bad.step_code = x.step_code
AND bad.status IN ('ROLLED_BACK', 'FAILED'))
GROUP BY a.user_id, a.module_code, x.step_code
ON DUPLICATE KEY UPDATE completed_at = COALESCE(completed_at, VALUES(completed_at));
-- 二、模块三(开通钱包):央行已完成最终确权并发送的用户,五个步骤都补为完成
-- 注意:查询必须包进派生表。INSERT ... SELECT ... JOIN 后面直接接 ON DUPLICATE KEY 时,
-- MySQL 会把 ON 当成连接条件而报语法错误;派生表的时间列也不能叫 completed_at,否则与目标表同名、ON DUPLICATE 里会有歧义。
INSERT INTO training_task_step_progress (user_id, module_code, step_code, completed_at)
SELECT t.user_id, t.module_code, t.step_code, t.done_at
FROM (SELECT w.user_id AS user_id, 'WALLET_OPENING' AS module_code, s.step_code AS step_code, w.updated_at AS done_at
FROM (SELECT user_id, MAX(updated_at) AS updated_at
FROM central_wallet_activation
WHERE wallet_activated = TRUE AND final_sent = TRUE
AND cb_final_signature IS NOT NULL AND status = 'FINAL_SENT' AND deleted = FALSE
GROUP BY user_id) w
CROSS JOIN (SELECT '01' AS step_code UNION ALL SELECT '02' UNION ALL SELECT '03'
UNION ALL SELECT '04' UNION ALL SELECT '05') s) t
ON DUPLICATE KEY UPDATE completed_at = COALESCE(training_task_step_progress.completed_at, VALUES(completed_at));
-- 三、封存历史答案:已完成且已有答案的步骤,锁定时间取答案的保存时间
UPDATE training_task_step_progress
SET sealed_at = COALESCE(saved_at, completed_at)
WHERE completed_at IS NOT NULL AND step_data IS NOT NULL AND sealed_at IS NULL;

@ -1,51 +0,0 @@
-- 把旧「步骤进度」表(user_step_progress_new)里已保存的答案迁移到 training_task_step_progress.step_data。
-- 前置:先执行 training_task_progress_draft_upgrade.sql(新增 step_data / saved_at 列)。
--
-- 规则:
-- 1. 只迁移模块 1~5;旧表 moudule_code 的取值与前端约定一致:
-- 中国人民银行生成数字货币 -> PBOC_CURRENCY_GENERATION
-- 发行货币 -> PBOC_CURRENCY_ISSUANCE
-- wallet-open -> WALLET_OPENING
-- currency-exchange -> CURRENCY_EXCHANGE
-- currency-payment -> CURRENCY_PAYMENT
-- 模块六(innovation-scene 等)不在迁移范围,仍使用旧接口。
-- 2. 只迁移未被逻辑删除(deleted=0)、内容是合法 JSON 对象/数组的记录。
-- 3. 不覆盖新表里已有的答案。旧表主键只有 id(不是 用户+模块+步骤 唯一),同一步骤可能有多条
-- 未删除记录,这里只取其中最新的一条(update_time 相同则取 id 大的)。
-- 4. 只写 step_data / saved_at,不动 completed_at:旧表里「已保存」不代表业务已完成,
-- 完成状态仍以真实业务操作为准。没有完成记录的步骤迁移后显示为「已保存」草稿。
-- 本脚本可重复执行;请用 utf8mb4 连接执行(模块名含中文,连接字符集不对会匹配不到任何行)。
INSERT INTO training_task_step_progress (user_id, module_code, step_code, step_data, saved_at)
SELECT s.user_id,
CASE s.moudule_code
WHEN '中国人民银行生成数字货币' THEN 'PBOC_CURRENCY_GENERATION'
WHEN '发行货币' THEN 'PBOC_CURRENCY_ISSUANCE'
WHEN 'wallet-open' THEN 'WALLET_OPENING'
WHEN 'currency-exchange' THEN 'CURRENCY_EXCHANGE'
WHEN 'currency-payment' THEN 'CURRENCY_PAYMENT'
END,
s.step_code, s.data, s.update_time
FROM user_step_progress_new s
WHERE s.deleted = 0
AND s.moudule_code IN ('中国人民银行生成数字货币', '发行货币', 'wallet-open', 'currency-exchange', 'currency-payment')
AND s.data IS NOT NULL
AND CHAR_LENGTH(s.data) <= 100000
AND JSON_VALID(s.data) = 1
AND LEFT(LTRIM(s.data), 1) IN ('{', '[')
AND s.step_code REGEXP '^[0-9]{2}$'
AND ((s.moudule_code = '中国人民银行生成数字货币' AND s.step_code BETWEEN '01' AND '09')
OR (s.moudule_code = '发行货币' AND s.step_code BETWEEN '01' AND '07')
OR (s.moudule_code = 'wallet-open' AND s.step_code BETWEEN '01' AND '05')
OR (s.moudule_code = 'currency-exchange' AND s.step_code BETWEEN '01' AND '05')
OR (s.moudule_code = 'currency-payment' AND s.step_code BETWEEN '01' AND '06'))
AND NOT EXISTS (SELECT 1 FROM user_step_progress_new n
WHERE n.user_id = s.user_id AND n.moudule_code = s.moudule_code
AND n.step_code = s.step_code AND n.deleted = 0
AND (n.update_time > s.update_time
OR (n.update_time = s.update_time AND n.id > s.id)))
-- 注意赋值顺序:MySQL 按书写顺序依次赋值,saved_at 必须写在 step_data 前面,
-- 否则判断 step_data IS NULL 时它已经被上一句改成非空,saved_at 就永远写不进去。
ON DUPLICATE KEY UPDATE
saved_at = IF(step_data IS NULL, VALUES(saved_at), saved_at),
step_data = COALESCE(step_data, VALUES(step_data));

@ -1,32 +0,0 @@
-- 实训任务步骤进度支持「随时保存」:除完成记录外,还能保存步骤答案草稿(一段 JSON)。
-- completed_at 为空 = 仅保存了草稿、该步骤尚未完成
-- step_data = 前端保存的答案 JSON 字符串
-- saved_at = 最近一次保存时间
-- sealed_at = 答案被锁定的时间:步骤完成后只再接受一次保存,之后答案不可再被覆盖(为空表示未锁定)
-- 旧记录不受影响:它们的 completed_at 都有值,step_data / saved_at 为空。
-- 兼容滚动发布:旧版本后端仍按「只写 completed_at」的方式插入,新列均可为空,不会报错。
-- 本脚本可重复执行(先查 information_schema,缺什么补什么)。
SET @db = DATABASE();
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'training_task_step_progress' AND COLUMN_NAME = 'step_data') = 0,
'ALTER TABLE training_task_step_progress ADD COLUMN step_data MEDIUMTEXT NULL AFTER completed_at',
'SELECT ''step_data 已存在''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'training_task_step_progress' AND COLUMN_NAME = 'saved_at') = 0,
'ALTER TABLE training_task_step_progress ADD COLUMN saved_at TIMESTAMP NULL DEFAULT NULL AFTER step_data',
'SELECT ''saved_at 已存在''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'training_task_step_progress' AND COLUMN_NAME = 'sealed_at') = 0,
'ALTER TABLE training_task_step_progress ADD COLUMN sealed_at TIMESTAMP NULL DEFAULT NULL AFTER saved_at',
'SELECT ''sealed_at 已存在''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 草稿行没有完成时间,completed_at 需要允许为空(重复执行同样安全)
ALTER TABLE training_task_step_progress
MODIFY COLUMN completed_at TIMESTAMP NULL DEFAULT NULL;

@ -11,10 +11,6 @@ CREATE TABLE IF NOT EXISTS sys_user (
phone VARCHAR(100) NULL,
school_name VARCHAR(100) NULL,
school_id VARCHAR(36) NULL,
college_id VARCHAR(64) NULL,
college_name VARCHAR(128) NULL,
major_id VARCHAR(64) NULL,
major_name VARCHAR(128) NULL,
authorize_time DATE NULL,
authorize_end_time DATE NULL,
role_id TINYINT NULL,
@ -24,6 +20,23 @@ CREATE TABLE IF NOT EXISTS sys_user (
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,
@ -603,6 +616,22 @@ INSERT INTO central_bank_currency_vault (currency, available_balance)
VALUES ('DC', 300000000.00)
ON DUPLICATE KEY UPDATE currency = VALUES(currency);
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 business_knowledge_category (
code VARCHAR(64) PRIMARY KEY,
name VARCHAR(128) NOT NULL,
@ -765,10 +794,7 @@ CREATE TABLE IF NOT EXISTS training_task_step_progress (
user_id VARCHAR(36) NOT NULL,
module_code VARCHAR(64) NOT NULL,
step_code VARCHAR(32) NOT NULL,
completed_at TIMESTAMP NULL DEFAULT NULL,
step_data MEDIUMTEXT NULL,
saved_at TIMESTAMP NULL DEFAULT NULL,
sealed_at TIMESTAMP NULL DEFAULT NULL,
completed_at TIMESTAMP NOT NULL,
PRIMARY KEY (user_id, module_code, step_code),
KEY idx_training_task_progress_user_module (user_id, module_code)
);

@ -2,7 +2,6 @@ package com.yau.digitalrmb.exchange.application.service;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import com.yau.digitalrmb.training.application.TrainingTaskProgressService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@ -19,27 +18,8 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ExchangeScoreServiceTest {
@Mock private StuModuleScoreDetailsMapper mapper;
@Mock private TrainingTaskProgressService taskProgress;
@InjectMocks private ExchangeScoreService service;
@Test
void markStepCompletedSyncsTrainingTaskProgressForTheSameStep() {
when(mapper.selectOne(any())).thenReturn(null);
service.markStepCompleted("user-1", 4);
verify(taskProgress).completeThrough("user-1", "CURRENCY_EXCHANGE", "04");
}
@Test
void markStepCompletedSkipsTrainingTaskProgressWithoutUserOrStep() {
service.markStepCompleted(" ", 2);
when(mapper.selectOne(any())).thenReturn(null);
service.markStepCompleted("user-1", 0);
verify(taskProgress, never()).completeThrough(any(), any(), any());
}
@Test
void firstErrorCreatesExchangeScoreDetail() {
when(mapper.selectOne(any())).thenReturn(null);

@ -53,8 +53,6 @@ class ExchangeControllerTest {
jdbc.update("DELETE FROM training_experiment_action WHERE attempt_id IN " +
"(SELECT id FROM training_experiment_attempt WHERE user_id=?)", USER_ID);
jdbc.update("DELETE FROM training_experiment_attempt WHERE user_id=?", USER_ID);
jdbc.update("DELETE FROM training_task_step_progress WHERE user_id=? AND module_code='CURRENCY_EXCHANGE'",
USER_ID);
jdbc.update("DELETE FROM stu_module_score_details WHERE user_id=? AND serial_number=4", USER_ID);
jdbc.update("INSERT INTO sys_user (user_id,student_id,password,user_name,class_id,class_name,school_id,school_name,"
+ "role_id,is_deleted,zy_user_id) VALUES (?,?,?,?,?,?,?,?,4,0,?)",
@ -121,8 +119,6 @@ class ExchangeControllerTest {
jdbc.update("DELETE FROM training_experiment_action WHERE attempt_id IN " +
"(SELECT id FROM training_experiment_attempt WHERE user_id=?)", USER_ID);
jdbc.update("DELETE FROM training_experiment_attempt WHERE user_id=?", USER_ID);
jdbc.update("DELETE FROM training_task_step_progress WHERE user_id=? AND module_code='CURRENCY_EXCHANGE'",
USER_ID);
jdbc.update("DELETE FROM exchange_step_log");
jdbc.update("DELETE FROM wallet_balance_ledger");
jdbc.update("DELETE FROM central_bank_currency_ownership");
@ -342,55 +338,6 @@ class ExchangeControllerTest {
Integer.class, exchangeId)).isEqualTo(1);
}
@Test
void actionChainAdvancesTrainingTaskProgressAsSoonAsAStepCompletes() throws Exception {
String created = mockMvc.perform(post("/api/v1/exchange/attempts").with(user()))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
String attemptId = JsonPath.read(created, "$.data.attemptId");
mockMvc.perform(get("/api/v1/training-tasks/CURRENCY_EXCHANGE").with(user()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("NOT_STARTED"))
.andExpect(jsonPath("$.data.completedSteps").value(0));
// 步骤一做了一半:进度不能提前推进。这里刻意不调用 GET /exchanges/context,
// 那是旧接口,拦截器会把它记成步骤一,掩盖动作链没写进度的问题。
executeAction(attemptId, "01", "refresh-wallet", "COMPLETED");
executeAction(attemptId, "01", "confirm-binding", "COMPLETED");
mockMvc.perform(get("/api/v1/training-tasks/CURRENCY_EXCHANGE").with(user()))
.andExpect(jsonPath("$.data.completedSteps").value(0));
// 步骤一最后一个动作完成:同一时刻再查必须已更新
executeAction(attemptId, "01", "refresh-institution", "COMPLETED");
mockMvc.perform(get("/api/v1/training-tasks/CURRENCY_EXCHANGE").with(user()))
.andExpect(jsonPath("$.data.status").value("IN_PROGRESS"))
.andExpect(jsonPath("$.data.completedSteps").value(1))
.andExpect(jsonPath("$.data.currentStepCode").value("02"))
.andExpect(jsonPath("$.data.steps[0].status").value("COMPLETED"))
.andExpect(jsonPath("$.data.steps[1].status").value("CURRENT"));
// 步骤二:取到钱包/账户后只走动作链(POST /exchanges 不会被调用)
String context = mockMvc.perform(get("/api/v1/exchanges/context").with(user()))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
String walletId = JsonPath.read(context, "$.data.walletId");
String accountId = JsonPath.read(context, "$.data.bankAccountId");
executeAction(attemptId, "02", "generate-timestamp", "COMPLETED");
executeAction(attemptId, "02", "concatenate-withdrawal", "COMPLETED",
"{\"walletId\":\"" + walletId + "\",\"bankAccountId\":\"" + accountId
+ "\",\"amount\":200.00}");
mockMvc.perform(get("/api/v1/training-tasks/CURRENCY_EXCHANGE").with(user()))
.andExpect(jsonPath("$.data.completedSteps").value(1));
executeAction(attemptId, "02", "output-withdrawal-request", "COMPLETED");
mockMvc.perform(get("/api/v1/training-tasks/CURRENCY_EXCHANGE").with(user()))
.andExpect(jsonPath("$.data.completedSteps").value(2))
.andExpect(jsonPath("$.data.currentStepCode").value("03"));
// 重复提交已完成的动作:幂等,不产生重复记录
executeAction(attemptId, "02", "output-withdrawal-request", "COMPLETED");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM training_task_step_progress " +
"WHERE user_id=? AND module_code='CURRENCY_EXCHANGE'", Integer.class, USER_ID)).isEqualTo(2);
}
@Test
void rejectsWrongAlgorithmAndWalletPrivateKeyWithoutAdvancingTheExchangeAction() throws Exception {
String context = mockMvc.perform(get("/api/v1/exchanges/context").with(user()))

@ -53,13 +53,12 @@ class LocalSsoAccountServiceTest {
}
@Test
void synchronizesCompleteProfileIntoLocalUser() {
void synchronizesCompleteProfileIntoLocalSnapshot() {
service.synchronize(completeToken(USER_603, "sso603", 4L));
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, "
+ "college_id, college_name, major_id, major_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")
@ -68,15 +67,20 @@ class LocalSsoAccountServiceTest {
.containsEntry("class_name", "Class 1")
.containsEntry("school_id", "610000")
.containsEntry("school_name", "Yan'an University")
.containsEntry("college_id", "100")
.containsEntry("college_name", "Computer College")
.containsEntry("major_id", "101")
.containsEntry("major_name", "Software Engineering")
.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("class_name", "Class 1")
.containsEntry("student_id", "20240603");
}
private VerifiedPlatformToken completeToken(String userId, String username, long roleId) {

@ -1,39 +0,0 @@
package com.yau.digitalrmb.institutionidentity.application;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import com.yau.digitalrmb.training.application.TrainingTaskProgressService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InstitutionTrainingScoreServiceTest {
@Mock private StuModuleScoreDetailsMapper mapper;
@Mock private TrainingTaskProgressService taskProgress;
@InjectMocks private InstitutionTrainingScoreService service;
@Test
void markStepCompletedSyncsTrainingTaskProgressForTheSameStep() {
when(mapper.selectOne(any())).thenReturn(null);
service.markStepCompleted("user-1", 9);
verify(taskProgress).completeThrough("user-1", "PBOC_CURRENCY_GENERATION", "09");
}
@Test
void markStepCompletedSkipsTrainingTaskProgressWithoutUserOrStep() {
service.markStepCompleted(" ", 2);
when(mapper.selectOne(any())).thenReturn(null);
service.markStepCompleted("user-1", 0);
verify(taskProgress, never()).completeThrough(any(), any(), any());
}
}

@ -2,7 +2,6 @@ package com.yau.digitalrmb.issuance.application.service;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import com.yau.digitalrmb.training.application.TrainingTaskProgressService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@ -21,30 +20,9 @@ class IssuanceScoreServiceTest {
@Mock
private StuModuleScoreDetailsMapper mapper;
@Mock
private TrainingTaskProgressService taskProgress;
@InjectMocks
private IssuanceScoreService service;
@Test
void markStepCompletedSyncsTrainingTaskProgressForTheSameStep() {
when(mapper.selectOne(any())).thenReturn(null);
service.markStepCompleted("user-1", 7);
verify(taskProgress).completeThrough("user-1", "PBOC_CURRENCY_ISSUANCE", "07");
}
@Test
void markStepCompletedSkipsTrainingTaskProgressWithoutUserOrStep() {
service.markStepCompleted(" ", 2);
when(mapper.selectOne(any())).thenReturn(null);
service.markStepCompleted("user-1", 0);
verify(taskProgress, never()).completeThrough(any(), any(), any());
}
@Test
void firstErrorCreatesScoreDetailAndDeductsAtCurrentProgress() {
when(mapper.selectOne(any())).thenReturn(null);

@ -2,7 +2,6 @@ package com.yau.digitalrmb.payment.application.service;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import com.yau.digitalrmb.training.application.TrainingTaskProgressService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@ -12,34 +11,14 @@ import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PaymentScoreServiceTest {
@Mock private StuModuleScoreDetailsMapper mapper;
@Mock private TrainingTaskProgressService taskProgress;
@InjectMocks private PaymentScoreService service;
@Test
void markStepCompletedSyncsTrainingTaskProgressForTheSameStep() {
when(mapper.selectOne(any())).thenReturn(null);
service.markStepCompleted("user-1", 2);
verify(taskProgress).completeThrough("user-1", "CURRENCY_PAYMENT", "02");
}
@Test
void markStepCompletedSkipsTrainingTaskProgressWithoutUserOrStep() {
service.markStepCompleted(" ", 2);
when(mapper.selectOne(any())).thenReturn(null);
service.markStepCompleted("user-1", 0);
verify(taskProgress, never()).completeThrough(any(), any(), any());
}
@Test
void firstErrorCreatesPaymentScoreDetail() {
when(mapper.selectOne(any())).thenReturn(null);

@ -186,8 +186,7 @@ class PaymentControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(5))
.andExpect(jsonPath("$.data[2].moduleCode").value("WALLET_OPENING"))
.andExpect(jsonPath("$.data[2].totalSteps").value(5))
.andExpect(jsonPath("$.data[2].actionUnavailable").value(false));
.andExpect(jsonPath("$.data[2].actionUnavailable").value(true));
assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE wallet_id=?", BigDecimal.class, PAYER_STORAGE_WALLET))
.isEqualByComparingTo("9800.00");
@ -308,42 +307,6 @@ class PaymentControllerTest {
"WALLET_B_7D8E9F0A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4A5B6"));
}
@Test
void actionChainAdvancesTrainingTaskProgressAsSoonAsAStepCompletes() throws Exception {
String created = mockMvc.perform(post("/api/v1/payment/attempts").with(payer()))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
String attemptId = JsonPath.read(created, "$.data.attemptId");
mockMvc.perform(get("/api/v1/training-tasks/CURRENCY_PAYMENT").with(payer()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("NOT_STARTED"))
.andExpect(jsonPath("$.data.completedSteps").value(0))
.andExpect(jsonPath("$.data.steps[0].status").value("CURRENT"));
// 步骤一只做了一半:进度不能提前推进
executeAction(attemptId, "01", "refresh-payer", payer());
mockMvc.perform(get("/api/v1/training-tasks/CURRENCY_PAYMENT").with(payer()))
.andExpect(jsonPath("$.data.completedSteps").value(0));
// 步骤一最后一个动作完成后,同一时刻再查进度必须已经更新
executeAction(attemptId, "01", "refresh-payee", payer());
mockMvc.perform(get("/api/v1/training-tasks/CURRENCY_PAYMENT").with(payer()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("IN_PROGRESS"))
.andExpect(jsonPath("$.data.completedSteps").value(1))
.andExpect(jsonPath("$.data.currentStepCode").value("02"))
.andExpect(jsonPath("$.data.steps[0].status").value("COMPLETED"))
.andExpect(jsonPath("$.data.steps[1].status").value("CURRENT"));
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM training_task_step_progress " +
"WHERE user_id=? AND module_code='CURRENCY_PAYMENT'", Integer.class, PAYER_USER)).isEqualTo(1);
// 重复提交已完成的动作:幂等,不产生重复记录
executeAction(attemptId, "01", "refresh-payee", payer());
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM training_task_step_progress " +
"WHERE user_id=? AND module_code='CURRENCY_PAYMENT'", Integer.class, PAYER_USER)).isEqualTo(1);
}
@Test
void isolatesPrototypeWalletBalancesCoinsAndResetByLearner() throws Exception {
mockMvc.perform(get("/api/v1/payments/context").with(payer()))

@ -1,6 +1,7 @@
package com.yau.digitalrmb.platformintegration.application;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -19,14 +20,16 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PlatformTokenVerifierTest {
private static final String SECRET = "zy_kzy_mnjy_fp76ckwuczzmb67w0a8x0";
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;
@BeforeEach
void setUp() {
verifier = new PlatformTokenVerifier(new ObjectMapper(), Clock.fixed(now, ZoneOffset.UTC));
PlatformIntegrationProperties.Token properties = new PlatformIntegrationProperties.Token();
properties.setLinkSecretKey(SECRET);
verifier = new PlatformTokenVerifier(properties, new ObjectMapper(), Clock.fixed(now, ZoneOffset.UTC));
}
@Test

@ -28,7 +28,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@AutoConfigureMockMvc
@ActiveProfiles("test")
class UserControllerTest {
private static final String SECRET = "zy_kzy_mnjy_fp76ckwuczzmb67w0a8x0";
private static final String SECRET = "local-token-sso-test-secret-key-123456";
@Autowired
private MockMvc mvc;

@ -1,337 +0,0 @@
package com.yau.digitalrmb.training.application;
import com.yau.digitalrmb.progress.application.ModuleCompletionService;
import com.yau.digitalrmb.shared.api.ErrorCode;
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.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class TrainingTaskProgressServiceTest {
private static final String USER = "progress-user-1";
private static final String OTHER = "progress-user-2";
private static final String PAYMENT = "CURRENCY_PAYMENT";
@Autowired private TrainingTaskProgressService service;
@Autowired private ModuleCompletionService moduleCompletion;
@Autowired private JdbcTemplate jdbc;
/** 每步一个字母:D=已完成 C=当前可操作 S=已保存草稿 L=锁定 N=未开始。 */
private String statuses(TrainingTaskProgressService.TrainingTaskView view) {
return view.getSteps().stream().map(TrainingTaskProgressService.TrainingStepView::getStatus)
.map(s -> {
switch (s) {
case "COMPLETED": return "D";
case "CURRENT": return "C";
case "SAVED": return "S";
case "LOCKED": return "L";
case "NOT_STARTED": return "N";
default: return "?";
}
}).collect(Collectors.joining());
}
@Test
void untouchedModuleStartsAtTheFirstStep() {
TrainingTaskProgressService.TrainingTaskView view = service.one(USER, PAYMENT);
assertThat(view.getStatus()).isEqualTo("NOT_STARTED");
assertThat(view.getCompletedSteps()).isZero();
assertThat(view.getSavedSteps()).isZero();
assertThat(view.getCurrentStepCode()).isEqualTo("01");
assertThat(statuses(view)).isEqualTo("CLLLLL");
}
@Test
void savingADraftMarksTheStepSavedAndTheModuleInProgressWithoutCompletingIt() {
TrainingTaskProgressService.TrainingTaskView view =
service.saveStep(USER, PAYMENT, "01", "{\"payer\":\"A\",\"amount\":12.5}");
assertThat(view.getStatus()).isEqualTo("IN_PROGRESS");
assertThat(view.getCompletedSteps()).isZero();
assertThat(view.getSavedSteps()).isEqualTo(1);
assertThat(view.getCurrentStepCode()).isEqualTo("01");
assertThat(view.getSteps().get(0).getStatus()).isEqualTo("SAVED");
assertThat(view.getSteps().get(0).isActionAvailable()).isTrue();
assertThat(view.getSteps().get(0).isHasData()).isTrue();
assertThat(view.getSteps().get(0).getSavedAt()).isNotNull();
assertThat(view.getSteps().get(1).getStatus()).isEqualTo("LOCKED");
}
@Test
void draftDataRoundTripsAndEachSaveReplacesThePreviousOne() {
service.saveStep(USER, PAYMENT, "02", "{\"v\":1}");
service.saveStep(USER, PAYMENT, "02", "{\"v\":2,\"note\":\"房租\"}");
TrainingTaskProgressService.TrainingStepDetailView detail = service.stepDetail(USER, PAYMENT, "02");
assertThat(detail.getStepData()).isEqualTo("{\"v\":2,\"note\":\"房租\"}");
assertThat(detail.isHasData()).isTrue();
assertThat(detail.getStepName()).isEqualTo("签名支付请求");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM training_task_step_progress WHERE user_id=?",
Integer.class, USER)).isEqualTo(1);
}
@Test
void stepWithoutADraftReturnsNoData() {
TrainingTaskProgressService.TrainingStepDetailView detail = service.stepDetail(USER, PAYMENT, "01");
assertThat(detail.getStepData()).isNull();
assertThat(detail.isHasData()).isFalse();
assertThat(detail.getStatus()).isEqualTo("CURRENT");
assertThat(detail.getSavedAt()).isNull();
}
@Test
void draftsCanBeSavedBeyondTheFrontierWithoutUnlockingAnything() {
TrainingTaskProgressService.TrainingTaskView view =
service.saveStep(USER, PAYMENT, "03", "{\"x\":1}");
assertThat(statuses(view)).isEqualTo("CLSLLL");
assertThat(view.getCurrentStepCode()).isEqualTo("01");
assertThat(view.getSteps().get(2).isActionAvailable()).isFalse();
assertThat(view.getCompletedSteps()).isZero();
assertThat(view.getStatus()).isEqualTo("IN_PROGRESS");
}
@Test
void completingAStepKeepsItsDraftDataAndCountsItExactlyOnce() {
service.saveStep(USER, PAYMENT, "01", "{\"keep\":true}");
service.completeThrough(USER, PAYMENT, "01");
service.completeThrough(USER, PAYMENT, "01");
TrainingTaskProgressService.TrainingTaskView view = service.one(USER, PAYMENT);
assertThat(view.getCompletedSteps()).isEqualTo(1);
assertThat(view.getSavedSteps()).isZero();
assertThat(view.getCurrentStepCode()).isEqualTo("02");
assertThat(view.getSteps().get(0).getStatus()).isEqualTo("COMPLETED");
assertThat(service.stepDetail(USER, PAYMENT, "01").getStepData()).isEqualTo("{\"keep\":true}");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM training_task_step_progress WHERE user_id=?",
Integer.class, USER)).isEqualTo(1);
}
@Test
void completingALaterStepAlsoCompletesTheEarlierOnesThatOnlyHadDrafts() {
service.saveStep(USER, PAYMENT, "01", "{\"a\":1}");
service.saveStep(USER, PAYMENT, "02", "{\"b\":2}");
service.completeThrough(USER, PAYMENT, "02");
TrainingTaskProgressService.TrainingTaskView view = service.one(USER, PAYMENT);
// 前两步已完成,第 3 步是下一个可操作步骤,其余锁定
assertThat(statuses(view)).isEqualTo("DDCLLL");
assertThat(view.getCompletedSteps()).isEqualTo(2);
assertThat(view.getCurrentStepCode()).isEqualTo("03");
}
@Test
void completedStepAcceptsOnlyOneFinalizingSaveThenLocksTheOriginalAnswer() {
service.saveStep(USER, PAYMENT, "01", "{\"v\":1}");
service.completeThrough(USER, PAYMENT, "01");
// 完成之后紧接着的那一次保存(页面做完最后一个动作会自动存一次)被接受
TrainingTaskProgressService.TrainingTaskView view = service.saveStep(USER, PAYMENT, "01", "{\"v\":2}");
assertThat(view.getSteps().get(0).getStatus()).isEqualTo("COMPLETED");
assertThat(view.getSteps().get(0).isLocked()).isTrue();
assertThat(view.getCompletedSteps()).isEqualTo(1);
// 之后任何不同的内容都被拒绝,原答案保持不变
assertThatThrownBy(() -> service.saveStep(USER, PAYMENT, "01", "{\"v\":3,\"cheat\":true}"))
.isInstanceOf(BusinessException.class)
.satisfies(e -> assertThat(((BusinessException) e).getErrorCode()).isEqualTo(ErrorCode.CONFLICT));
TrainingTaskProgressService.TrainingStepDetailView detail = service.stepDetail(USER, PAYMENT, "01");
assertThat(detail.getStepData()).isEqualTo("{\"v\":2}");
assertThat(detail.isLocked()).isTrue();
}
@Test
void savingTheSameAnswerAgainAfterLockingIsAHarmlessNoOp() {
service.completeThrough(USER, PAYMENT, "01");
service.saveStep(USER, PAYMENT, "01", "{\"a\":1}");
TrainingTaskProgressService.TrainingTaskView view = service.saveStep(USER, PAYMENT, "01", "{\"a\":1}");
assertThat(view.getSteps().get(0).isLocked()).isTrue();
assertThat(service.stepDetail(USER, PAYMENT, "01").getStepData()).isEqualTo("{\"a\":1}");
}
@Test
void lockedAnswerComparisonIgnoresWhitespaceAndKeyOrderButNotContent() {
service.completeThrough(USER, PAYMENT, "01");
service.saveStep(USER, PAYMENT, "01", "{\"a\":1,\"b\":[1,2],\"c\":{\"x\":\"房租\"}}");
// 只是排版、键顺序不同:同一份答案,视为幂等成功
service.saveStep(USER, PAYMENT, "01", "{ \"c\": {\"x\": \"房租\"}, \"b\": [1, 2], \"a\": 1 }");
// 内容有任何不同(值、数组顺序、多一个字段)都必须拒绝
for (String changed : new String[]{
"{\"a\":2,\"b\":[1,2],\"c\":{\"x\":\"房租\"}}",
"{\"a\":1,\"b\":[2,1],\"c\":{\"x\":\"房租\"}}",
"{\"a\":1,\"b\":[1,2],\"c\":{\"x\":\"房租\"},\"d\":0}"}) {
assertThatThrownBy(() -> service.saveStep(USER, PAYMENT, "01", changed))
.as(changed).isInstanceOf(BusinessException.class);
}
assertThat(service.stepDetail(USER, PAYMENT, "01").getStepData())
.isEqualTo("{\"a\":1,\"b\":[1,2],\"c\":{\"x\":\"房租\"}}");
}
@Test
void draftsStayEditableUntilTheStepIsCompleted() {
service.saveStep(USER, PAYMENT, "02", "{\"v\":1}");
service.saveStep(USER, PAYMENT, "02", "{\"v\":2}");
service.saveStep(USER, PAYMENT, "02", "{\"v\":3}");
TrainingTaskProgressService.TrainingStepDetailView detail = service.stepDetail(USER, PAYMENT, "02");
assertThat(detail.getStepData()).isEqualTo("{\"v\":3}");
assertThat(detail.isLocked()).isFalse();
}
@Test
void completingALaterStepGivesEarlierDraftStepsTheirOneFinalizingSave() {
service.saveStep(USER, PAYMENT, "01", "{\"s\":1}");
service.completeThrough(USER, PAYMENT, "02");
service.saveStep(USER, PAYMENT, "01", "{\"s\":2}");
assertThatThrownBy(() -> service.saveStep(USER, PAYMENT, "01", "{\"s\":3}"))
.isInstanceOf(BusinessException.class);
assertThat(service.stepDetail(USER, PAYMENT, "01").getStepData()).isEqualTo("{\"s\":2}");
}
@Test
void completedStepWithoutAnyAnswerCanStillBeSavedOnceAndLocksAfterwards() {
service.completeThrough(USER, PAYMENT, "01");
assertThat(service.stepDetail(USER, PAYMENT, "01").isLocked()).isFalse();
service.saveStep(USER, PAYMENT, "01", "{\"first\":1}");
assertThatThrownBy(() -> service.saveStep(USER, PAYMENT, "01", "{\"second\":2}"))
.isInstanceOf(BusinessException.class);
}
@Test
void lockingIsPerUserAndResetUnlocksTheModule() {
service.completeThrough(USER, PAYMENT, "01");
service.saveStep(USER, PAYMENT, "01", "{\"u\":1}");
service.saveStep(OTHER, PAYMENT, "01", "{\"o\":1}");
service.saveStep(OTHER, PAYMENT, "01", "{\"o\":2}");
assertThat(service.stepDetail(OTHER, PAYMENT, "01").getStepData()).isEqualTo("{\"o\":2}");
service.resetModule(USER, PAYMENT);
service.saveStep(USER, PAYMENT, "01", "{\"again\":1}");
assertThat(service.stepDetail(USER, PAYMENT, "01").getStepData()).isEqualTo("{\"again\":1}");
assertThat(service.stepDetail(USER, PAYMENT, "01").isLocked()).isFalse();
}
@Test
void draftsAreNeverCountedAsCompletedByTheModuleCompletionProgress() {
service.saveStep(USER, PAYMENT, "01", "{\"a\":1}");
service.saveStep(USER, PAYMENT, "02", "{\"b\":2}");
assertThat(moduleCompletion.getModuleCompletion(USER, "5").getSchedule()).isZero();
service.completeThrough(USER, PAYMENT, "01");
assertThat(moduleCompletion.getModuleCompletion(USER, "5").getSchedule())
.isEqualTo(16.67);
}
@Test
void usersNeverSeeEachOthersDrafts() {
service.saveStep(USER, PAYMENT, "01", "{\"secret\":1}");
assertThat(service.one(OTHER, PAYMENT).getSavedSteps()).isZero();
assertThat(service.stepDetail(OTHER, PAYMENT, "01").getStepData()).isNull();
}
@Test
void resetRemovesDraftsTogetherWithCompletedSteps() {
service.saveStep(USER, PAYMENT, "01", "{\"a\":1}");
service.completeThrough(USER, PAYMENT, "01");
service.saveStep(USER, PAYMENT, "02", "{\"b\":2}");
service.reset(USER, PAYMENT);
TrainingTaskProgressService.TrainingTaskView view = service.one(USER, PAYMENT);
assertThat(view.getStatus()).isEqualTo("NOT_STARTED");
assertThat(service.stepDetail(USER, PAYMENT, "02").getStepData()).isNull();
}
@Test
void resetModuleOnlyClearsTheRequestedModuleOfTheRequestedUser() {
service.saveStep(USER, PAYMENT, "01", "{\"a\":1}");
service.saveStep(USER, "CURRENCY_EXCHANGE", "01", "{\"b\":1}");
service.saveStep(OTHER, PAYMENT, "01", "{\"c\":1}");
service.resetModule(USER, PAYMENT);
assertThat(service.one(USER, PAYMENT).getSavedSteps()).isZero();
assertThat(service.one(USER, "CURRENCY_EXCHANGE").getSavedSteps()).isEqualTo(1);
assertThat(service.one(OTHER, PAYMENT).getSavedSteps()).isEqualTo(1);
// 没有数据时重复重置同样成功
service.resetModule(USER, PAYMENT);
assertThatThrownBy(() -> service.resetModule(USER, "NOPE")).isInstanceOf(BusinessException.class);
}
@Test
void everyModuleFromOneToFiveSupportsDrafts() {
String[] modules = {"PBOC_CURRENCY_GENERATION", "PBOC_CURRENCY_ISSUANCE", "WALLET_OPENING",
"CURRENCY_EXCHANGE", "CURRENCY_PAYMENT"};
int[] steps = {9, 7, 5, 5, 6};
for (int i = 0; i < modules.length; i++) {
TrainingTaskProgressService.TrainingTaskView view =
service.saveStep(USER, modules[i], "01", "{\"m\":" + i + "}");
assertThat(view.getTotalSteps()).as(modules[i]).isEqualTo(steps[i]);
assertThat(view.isActionUnavailable()).as(modules[i]).isFalse();
assertThat(view.getSteps().get(0).getStatus()).as(modules[i]).isEqualTo("SAVED");
}
}
@Test
void rejectsDataThatIsNotAJsonObjectOrArray() {
for (String bad : new String[]{"", " ", "not json", "{\"a\":", "{\"a\":1} x", "123", "\"text\"", "null"}) {
assertThatThrownBy(() -> service.saveStep(USER, PAYMENT, "01", bad))
.as("input=[" + bad + "]")
.isInstanceOf(BusinessException.class);
}
assertThatThrownBy(() -> service.saveStep(USER, PAYMENT, "01", null))
.isInstanceOf(BusinessException.class);
assertThat(service.one(USER, PAYMENT).getSavedSteps()).isZero();
}
@Test
void acceptsJsonArraysAndRejectsOversizedPayloads() {
assertThat(service.saveStep(USER, PAYMENT, "01", "[1,2,3]").getSavedSteps()).isEqualTo(1);
StringBuilder huge = new StringBuilder("{\"d\":\"");
for (int i = 0; i < TrainingTaskProgressService.MAX_STEP_DATA_LENGTH; i++) huge.append('x');
huge.append("\"}");
assertThatThrownBy(() -> service.saveStep(USER, PAYMENT, "02", huge.toString()))
.isInstanceOf(BusinessException.class);
}
@Test
void rejectsUnknownModulesAndStepsInsteadOfFailingWithAServerError() {
assertThatThrownBy(() -> service.saveStep(USER, "NOPE", "01", "{}"))
.isInstanceOf(BusinessException.class);
assertThatThrownBy(() -> service.saveStep(USER, PAYMENT, "99", "{}"))
.isInstanceOf(BusinessException.class);
assertThatThrownBy(() -> service.stepDetail(USER, PAYMENT, "99"))
.isInstanceOf(BusinessException.class);
assertThatThrownBy(() -> service.one(USER, "NOPE"))
.isInstanceOf(BusinessException.class);
}
}

@ -1,93 +0,0 @@
package com.yau.digitalrmb.training.infrastructure;
import com.yau.digitalrmb.security.application.CurrentUser;
import com.yau.digitalrmb.security.application.CurrentUserService;
import com.yau.digitalrmb.training.application.TrainingTaskProgressService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TrainingTaskProgressInterceptorTest {
private static final String USER = "interceptor-user";
private TrainingTaskProgressService progress;
private TrainingTaskProgressInterceptor interceptor;
@BeforeEach
void setUp() {
progress = mock(TrainingTaskProgressService.class);
CurrentUserService users = mock(CurrentUserService.class);
when(users.getCurrentUser()).thenReturn(new CurrentUser("1001", "延安大学", null, null, null, null, 4L,
USER, "u", "用户", "2001", "测试班", "u"));
interceptor = new TrainingTaskProgressInterceptor(progress, users);
}
private void call(String method, String path, int status) {
MockHttpServletResponse response = new MockHttpServletResponse();
response.setStatus(status);
interceptor.postHandle(new MockHttpServletRequest(method, path), response, new Object(), null);
}
@Test
void everyWalletOpeningStepIsCompletedByItsLastWriteOperation() {
call("POST", "/api/v1/wallet-opening/send", 200);
call("POST", "/api/v1/commercial-banks/wallet-opening/step2/confirm-collection", 200);
call("POST", "/api/v1/central-banks/wallet-opening/step3/confirm", 200);
call("POST", "/api/v1/commercial-banks/wallet-opening/step4/send", 200);
call("POST", "/api/v1/central-banks/wallet-opening/step5/send", 200);
for (String step : new String[]{"01", "02", "03", "04", "05"}) {
verify(progress).completeThrough(USER, "WALLET_OPENING", step);
}
}
@Test
void walletOpeningResetClearsTheModuleProgress() {
call("POST", "/api/v1/wallet-opening/training/reset", 200);
verify(progress).reset(USER, "WALLET_OPENING");
}
@Test
void failedWalletOperationsAndPlainReadsDoNotCompleteAnything() {
call("POST", "/api/v1/wallet-opening/send", 400);
call("POST", "/api/v1/central-banks/wallet-opening/step5/send", 500);
// 读操作、步骤中间动作:不是步骤收尾,不能算完成
call("GET", "/api/v1/wallet-opening/page", 200);
call("GET", "/api/v1/wallet-opening/step5/page", 200);
call("POST", "/api/v1/wallet-opening/step5/receive", 200);
call("POST", "/api/v1/central-banks/wallet-opening/step3/send", 200);
call("POST", "/api/v1/commercial-banks/wallet-opening/step4/output-confirm", 200);
verify(progress, never()).completeThrough(any(), any(), any());
verify(progress, never()).reset(any(), any());
}
@Test
void existingWriteMappingsStillWork() {
call("POST", "/api/v1/payments/abc/notifications", 200);
call("POST", "/api/v1/central-banks/issuance/requests/r1/digital-currency-ownership", 200);
call("POST", "/api/v1/exchanges/abc/confirm-ownership", 200);
verify(progress).completeThrough(USER, "CURRENCY_PAYMENT", "06");
verify(progress).completeThrough(USER, "PBOC_CURRENCY_ISSUANCE", "07");
verify(progress).completeThrough(USER, "CURRENCY_EXCHANGE", "05");
}
@Test
void merelyOpeningAPageNeverMarksAStepCompleted() {
// 打开页面只是读取数据,不能算步骤完成;否则步骤一进入就被当成已完成,答案会被提前锁定
call("GET", "/api/v1/exchanges/context", 200);
call("GET", "/api/v1/payments/context", 200);
call("GET", "/api/v1/commercial-banks/issuance/inventory", 200);
verify(progress, never()).completeThrough(any(), any(), any());
}
}

@ -1,192 +0,0 @@
package com.yau.digitalrmb.training.interfaces.rest;
import com.yau.digitalrmb.security.application.CurrentUser;
import com.yau.digitalrmb.security.application.CurrentUserService;
import org.junit.jupiter.api.AfterEach;
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.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
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.put;
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 TrainingTaskProgressControllerTest {
private static final String USER_A = "00000000-0000-0000-0000-0000000a0001";
private static final String USER_B = "00000000-0000-0000-0000-0000000b0002";
private static final String BASE = "/api/v1/training-tasks/CURRENCY_PAYMENT";
@Autowired private org.springframework.test.web.servlet.MockMvc mockMvc;
@Autowired private JdbcTemplate jdbc;
@MockBean private CurrentUserService currentUserService;
@BeforeEach
void clean() {
jdbc.update("DELETE FROM training_task_step_progress WHERE user_id IN (?, ?)", USER_A, USER_B);
}
@AfterEach
void cleanAfter() {
clean();
}
private void actAs(String userId) {
when(currentUserService.getCurrentUser()).thenReturn(new CurrentUser("1001", "延安大学", null, null, null,
null, 4L, userId, "progress", "进度用户", "2001", "测试班", "progress"));
}
private RequestPostProcessor token(String userId) {
return jwt().jwt(token -> token.subject(userId).claim("userId", userId));
}
@Test
void saveThenReadBackTheSameJsonAndTheUpdatedProgress() throws Exception {
actAs(USER_A);
String body = "{\"stepData\":\"{\\\"amount\\\":\\\"1000.00\\\",\\\"note\\\":\\\"房租\\\"}\"}";
mockMvc.perform(put(BASE + "/steps/02").with(token(USER_A))
.contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.status").value("IN_PROGRESS"))
.andExpect(jsonPath("$.data.savedSteps").value(1))
.andExpect(jsonPath("$.data.completedSteps").value(0))
.andExpect(jsonPath("$.data.steps[1].status").value("SAVED"))
.andExpect(jsonPath("$.data.steps[1].hasData").value(true))
.andExpect(jsonPath("$.data.steps[1].savedAt").isNotEmpty());
mockMvc.perform(get(BASE + "/steps/02").with(token(USER_A)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.stepCode").value("02"))
.andExpect(jsonPath("$.data.status").value("SAVED"))
.andExpect(jsonPath("$.data.stepData").value("{\"amount\":\"1000.00\",\"note\":\"房租\"}"));
mockMvc.perform(get(BASE).with(token(USER_A)))
.andExpect(jsonPath("$.data.status").value("IN_PROGRESS"))
.andExpect(jsonPath("$.data.steps[1].status").value("SAVED"));
}
@Test
void anotherUserNeverSeesTheDraft() throws Exception {
actAs(USER_A);
mockMvc.perform(put(BASE + "/steps/01").with(token(USER_A))
.contentType(MediaType.APPLICATION_JSON).content("{\"stepData\":\"{\\\"a\\\":1}\"}"))
.andExpect(status().isOk());
actAs(USER_B);
mockMvc.perform(get(BASE + "/steps/01").with(token(USER_B)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.stepData").doesNotExist())
.andExpect(jsonPath("$.data.hasData").value(false));
mockMvc.perform(get(BASE).with(token(USER_B)))
.andExpect(jsonPath("$.data.status").value("NOT_STARTED"))
.andExpect(jsonPath("$.data.savedSteps").value(0));
}
@Test
void invalidRequestsAreRejectedWith400NotA500() throws Exception {
actAs(USER_A);
String[] badBodies = {
"{}",
"{\"stepData\":\"\"}",
"{\"stepData\":\"not json\"}",
"{\"stepData\":\"123\"}",
"{\"stepData\":\"{\\\"a\\\":\"}",
"not a body at all",
};
for (String body : badBodies) {
mockMvc.perform(put(BASE + "/steps/01").with(token(USER_A))
.contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
}
mockMvc.perform(put(BASE + "/steps/99").with(token(USER_A))
.contentType(MediaType.APPLICATION_JSON).content("{\"stepData\":\"{}\"}"))
.andExpect(status().isBadRequest());
mockMvc.perform(put("/api/v1/training-tasks/NOPE/steps/01").with(token(USER_A))
.contentType(MediaType.APPLICATION_JSON).content("{\"stepData\":\"{}\"}"))
.andExpect(status().isBadRequest());
mockMvc.perform(get("/api/v1/training-tasks/NOPE").with(token(USER_A)))
.andExpect(status().isBadRequest());
mockMvc.perform(get(BASE + "/steps/99").with(token(USER_A)))
.andExpect(status().isBadRequest());
mockMvc.perform(get(BASE).with(token(USER_A)))
.andExpect(jsonPath("$.data.savedSteps").value(0));
}
@Test
void lockedStepRejectsADifferentAnswerWith409AndKeepsTheOriginal() throws Exception {
actAs(USER_A);
jdbc.update("INSERT INTO training_task_step_progress (user_id, module_code, step_code, completed_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)",
USER_A, "CURRENCY_PAYMENT", "01");
String first = "{\"stepData\":\"{\\\"answer\\\":\\\"original\\\"}\"}";
String changed = "{\"stepData\":\"{\\\"answer\\\":\\\"changed\\\"}\"}";
mockMvc.perform(put(BASE + "/steps/01").with(token(USER_A))
.contentType(MediaType.APPLICATION_JSON).content(first))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.steps[0].locked").value(true));
mockMvc.perform(put(BASE + "/steps/01").with(token(USER_A))
.contentType(MediaType.APPLICATION_JSON).content(changed))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(409));
// 重复提交同样的内容不报错
mockMvc.perform(put(BASE + "/steps/01").with(token(USER_A))
.contentType(MediaType.APPLICATION_JSON).content(first))
.andExpect(status().isOk());
mockMvc.perform(get(BASE + "/steps/01").with(token(USER_A)))
.andExpect(jsonPath("$.data.stepData").value("{\"answer\":\"original\"}"))
.andExpect(jsonPath("$.data.locked").value(true));
}
@Test
void deleteClearsDraftsAndRejectsUnknownModules() throws Exception {
actAs(USER_A);
mockMvc.perform(put(BASE + "/steps/01").with(token(USER_A))
.contentType(MediaType.APPLICATION_JSON).content("{\"stepData\":\"{\\\"a\\\":1}\"}"))
.andExpect(status().isOk());
mockMvc.perform(delete(BASE).with(token(USER_A)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
mockMvc.perform(delete(BASE).with(token(USER_A))).andExpect(status().isOk());
mockMvc.perform(get(BASE).with(token(USER_A)))
.andExpect(jsonPath("$.data.status").value("NOT_STARTED"))
.andExpect(jsonPath("$.data.savedSteps").value(0));
mockMvc.perform(delete("/api/v1/training-tasks/NOPE").with(token(USER_A)))
.andExpect(status().isBadRequest());
}
@Test
void listEndpointCoversAllFiveModulesAndNoneIsMarkedUnavailable() throws Exception {
actAs(USER_A);
mockMvc.perform(get("/api/v1/training-tasks").with(token(USER_A)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(5))
.andExpect(jsonPath("$.data[0].moduleCode").value("PBOC_CURRENCY_GENERATION"))
.andExpect(jsonPath("$.data[1].moduleCode").value("PBOC_CURRENCY_ISSUANCE"))
.andExpect(jsonPath("$.data[2].moduleCode").value("WALLET_OPENING"))
.andExpect(jsonPath("$.data[2].totalSteps").value(5))
.andExpect(jsonPath("$.data[2].actionUnavailable").value(false))
.andExpect(jsonPath("$.data[3].moduleCode").value("CURRENCY_EXCHANGE"))
.andExpect(jsonPath("$.data[4].moduleCode").value("CURRENCY_PAYMENT"));
}
}

@ -21,6 +21,11 @@ platform-integration:
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

@ -8,10 +8,6 @@ CREATE TABLE IF NOT EXISTS sys_user (
phone VARCHAR(100) NULL,
school_name VARCHAR(100) NULL,
school_id VARCHAR(36) NULL,
college_id VARCHAR(64) NULL,
college_name VARCHAR(128) NULL,
major_id VARCHAR(64) NULL,
major_name VARCHAR(128) NULL,
authorize_time DATE NULL,
authorize_end_time DATE NULL,
role_id TINYINT NULL,
@ -21,6 +17,23 @@ CREATE TABLE IF NOT EXISTS sys_user (
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,
@ -250,6 +263,22 @@ ON DUPLICATE KEY UPDATE
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,
@ -876,10 +905,7 @@ CREATE TABLE IF NOT EXISTS training_task_step_progress (
user_id VARCHAR(36) NOT NULL,
module_code VARCHAR(64) NOT NULL,
step_code VARCHAR(32) NOT NULL,
completed_at TIMESTAMP NULL DEFAULT NULL,
step_data MEDIUMTEXT NULL,
saved_at TIMESTAMP NULL DEFAULT NULL,
sealed_at TIMESTAMP NULL DEFAULT NULL,
completed_at TIMESTAMP NOT NULL,
PRIMARY KEY (user_id, module_code, step_code)
);

Loading…
Cancel
Save