feat:文件上传的功能开发

chore/issuance-comments-zh
赵佳正 2 weeks ago
parent b7000539ec
commit 348edf8516

@ -44,6 +44,26 @@
<artifactId>hutool-all</artifactId>
<version>5.8.16</version> <!-- 建议使用最新版本 -->
</dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.27</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>4.1.2</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>4.1.2</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-scratchpad</artifactId>
<version>4.1.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>

@ -7,6 +7,8 @@ import com.yau.digitalrmb.corporatewallet.infrastructure.DonationEntity;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationMapper;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationReceiptEntity;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationReceiptMapper;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationRecordEntity;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationRecordMapper;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationSupervisionEntity;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationSupervisionMapper;
import com.yau.digitalrmb.corporatewallet.infrastructure.RemoteWalletApplicationEntity;
@ -17,19 +19,28 @@ import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import com.yau.digitalrmb.progress.application.UserStepProgressNewService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.sql.SQLException;
import java.util.function.Supplier;
@Service
public class CorporateInnovationTrainingResetService {
private static final Logger log = LoggerFactory.getLogger(CorporateInnovationTrainingResetService.class);
private static final String TRAINING_ID = "innovation-scene";
/** MySQL 表不存在错误码。 */
private static final int MYSQL_TABLE_NOT_FOUND = 1146;
@Resource private CorporateWalletApplicationMapper corporateWalletMapper;
@Resource private RemoteWalletApplicationMapper remoteWalletMapper;
@Resource private SalaryBatchMapper salaryBatchMapper;
@Resource private DonationMapper donationMapper;
@Resource private DonationRecordMapper donationRecordMapper;
@Resource private DonationReceiptMapper donationReceiptMapper;
@Resource private DonationSupervisionMapper donationSupervisionMapper;
@Resource private StuModuleScoreDetailsMapper scoreDetailsMapper;
@ -48,53 +59,91 @@ public class CorporateInnovationTrainingResetService {
}
private int logicalDeleteCorporateWalletApplications(InstitutionKeySubject subject) {
return corporateWalletMapper.update(null, new LambdaUpdateWrapper<CorporateWalletApplicationEntity>()
return logicalDelete("corporate_wallet_application", () -> corporateWalletMapper.update(null,
new LambdaUpdateWrapper<CorporateWalletApplicationEntity>()
.eq(CorporateWalletApplicationEntity::getUserId, subject.getUserId())
.eq(CorporateWalletApplicationEntity::getSchoolId, subject.getSchoolId())
.eq(CorporateWalletApplicationEntity::getClassId, subject.getClassId())
.set(CorporateWalletApplicationEntity::getDeleted, true));
.set(CorporateWalletApplicationEntity::getDeleted, true)));
}
private int logicalDeleteRemoteWalletApplications(InstitutionKeySubject subject) {
return remoteWalletMapper.update(null, new LambdaUpdateWrapper<RemoteWalletApplicationEntity>()
return logicalDelete("remote_wallet_application", () -> remoteWalletMapper.update(null,
new LambdaUpdateWrapper<RemoteWalletApplicationEntity>()
.eq(RemoteWalletApplicationEntity::getUserId, subject.getUserId())
.eq(RemoteWalletApplicationEntity::getSchoolId, subject.getSchoolId())
.eq(RemoteWalletApplicationEntity::getClassId, subject.getClassId())
.set(RemoteWalletApplicationEntity::getDeleted, true));
.set(RemoteWalletApplicationEntity::getDeleted, true)));
}
private int logicalDeleteSalaryBatches(InstitutionKeySubject subject) {
return salaryBatchMapper.update(null, new LambdaUpdateWrapper<SalaryBatchEntity>()
return logicalDelete("salary_batch", () -> salaryBatchMapper.update(null,
new LambdaUpdateWrapper<SalaryBatchEntity>()
.eq(SalaryBatchEntity::getUserId, subject.getUserId())
.eq(SalaryBatchEntity::getSchoolId, subject.getSchoolId())
.eq(SalaryBatchEntity::getClassId, subject.getClassId())
.set(SalaryBatchEntity::getDeleted, true));
.set(SalaryBatchEntity::getDeleted, true)));
}
private int logicalDeleteDonationRecords(InstitutionKeySubject subject) {
int count = 0;
count += donationMapper.update(null, new LambdaUpdateWrapper<DonationEntity>()
count += logicalDelete("donation", () -> donationMapper.update(null, new LambdaUpdateWrapper<DonationEntity>()
.eq(DonationEntity::getUserId, subject.getUserId())
.eq(DonationEntity::getSchoolId, subject.getSchoolId())
.eq(DonationEntity::getClassId, subject.getClassId())
.set(DonationEntity::getDeleted, true));
count += donationReceiptMapper.update(null, new LambdaUpdateWrapper<DonationReceiptEntity>()
.set(DonationEntity::getDeleted, true)));
count += logicalDelete("donation_receipt", () -> donationReceiptMapper.update(null,
new LambdaUpdateWrapper<DonationReceiptEntity>()
.eq(DonationReceiptEntity::getUserId, subject.getUserId())
.eq(DonationReceiptEntity::getSchoolId, subject.getSchoolId())
.eq(DonationReceiptEntity::getClassId, subject.getClassId())
.set(DonationReceiptEntity::getDeleted, true));
count += donationSupervisionMapper.update(null, new LambdaUpdateWrapper<DonationSupervisionEntity>()
.set(DonationReceiptEntity::getDeleted, true)));
// 捐赠全流程记录表:捐赠发起、公益信息发布、监管备案共用的一条主记录,随捐赠一起作废
count += logicalDelete("donation_record", () -> donationRecordMapper.update(null,
new LambdaUpdateWrapper<DonationRecordEntity>()
.eq(DonationRecordEntity::getUserId, subject.getUserId())
.eq(DonationRecordEntity::getSchoolId, subject.getSchoolId())
.eq(DonationRecordEntity::getClassId, subject.getClassId())
.set(DonationRecordEntity::getDeleted, true)));
count += logicalDelete("donation_supervision", () -> donationSupervisionMapper.update(null,
new LambdaUpdateWrapper<DonationSupervisionEntity>()
.eq(DonationSupervisionEntity::getUserId, subject.getUserId())
.eq(DonationSupervisionEntity::getSchoolId, subject.getSchoolId())
.eq(DonationSupervisionEntity::getClassId, subject.getClassId())
.set(DonationSupervisionEntity::getDeleted, true));
.set(DonationSupervisionEntity::getDeleted, true)));
return count;
}
private int logicalDeleteScore(String userId) {
return scoreDetailsMapper.update(null, new LambdaUpdateWrapper<StuModuleScoreDetailsEntity>()
return logicalDelete("stu_module_score_details", () -> scoreDetailsMapper.update(null,
new LambdaUpdateWrapper<StuModuleScoreDetailsEntity>()
.eq(StuModuleScoreDetailsEntity::getUserId, userId)
.eq(StuModuleScoreDetailsEntity::getMoudule, SalaryScoreService.MODULE_NAME)
.set(StuModuleScoreDetailsEntity::getDeleted, true));
.set(StuModuleScoreDetailsEntity::getDeleted, true)));
}
/**
* 执行逻辑删除。库中缺少该表时跳过并告警,避免重置接口因库结构漂移直接失败;
* 其他数据库异常照常抛出,不掩盖真实问题。
*/
private int logicalDelete(String tableName, Supplier<Integer> action) {
try {
return action.get();
} catch (BadSqlGrammarException exception) {
if (isTableMissing(exception)) {
log.warn("实训重置跳过不存在的表 {},请执行 db/manual 下的模块六升级脚本补齐表结构", tableName);
return 0;
}
throw exception;
}
}
private boolean isTableMissing(BadSqlGrammarException exception) {
SQLException sqlException = exception.getSQLException();
if (sqlException != null && sqlException.getErrorCode() == MYSQL_TABLE_NOT_FOUND) {
return true;
}
String message = exception.getMessage();
return message != null && message.contains("doesn't exist");
}
}

@ -21,12 +21,18 @@ public class CorporateWalletService {
private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");
// 摘要与签名算法(前端选择,选错则错误数+1)
private static final String DIGEST_ALGORITHM = "SM3";
private static final String SIGN_ALGORITHM = "SM2";
@Resource
private CorporateWalletApplicationRepository repository;
@Resource
private RemoteWalletApplicationRepository remoteRepository;
@Resource
private SmInstitutionIdentityCryptography cryptography;
@Resource
private SalaryScoreService scoreService;
/**
* 拼接原文
@ -131,11 +137,16 @@ public class CorporateWalletService {
* SM3运算摘要
*/
@Transactional
public CorporateDigestResult computeDigest(InstitutionKeySubject subject) {
public CorporateDigestResult computeDigest(InstitutionKeySubject subject, String algorithm) {
CorporateWalletApplication app = findRequired(subject);
if (app.getExtractMessage() == null) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先提取摘要原文");
}
// 校验摘要算法,选择有误则错误数+1
if (!DIGEST_ALGORITHM.equalsIgnoreCase(algorithm != null ? algorithm.trim() : "")) {
scoreService.recordError(subject.getUserId());
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "算法选择有误,错误次数+1");
}
if (app.getDigestValue() != null) {
return new CorporateDigestResult(app.getDigestAlgorithm(), app.getDigestValue());
}

@ -43,6 +43,10 @@ public class RemoteWalletService {
private static final String LEGAL_PERSON_KEY_TYPE = "企业法人私钥";
// 摘要与签名算法(前端选择,选错则错误数+1)
private static final String DIGEST_ALGORITHM = "SM3";
private static final String SIGN_ALGORITHM = "SM2";
// ==================== 步骤一:企业法人在线填写信息 ====================
@Transactional
@ -120,11 +124,16 @@ public class RemoteWalletService {
}
@Transactional
public RemoteDigestResult computeSigningDigest(InstitutionKeySubject subject) {
public RemoteDigestResult computeSigningDigest(InstitutionKeySubject subject, String algorithm) {
RemoteWalletApplication app = findRequired(subject);
if (app.getSigningExtractMessage() == null) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先提取电子签约摘要原文");
}
// 校验摘要算法,选择有误则错误数+1
if (!DIGEST_ALGORITHM.equalsIgnoreCase(algorithm != null ? algorithm.trim() : "")) {
scoreService.recordError(subject.getUserId());
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "算法选择有误,错误次数+1");
}
if (app.getSigningDigestValue() != null) {
return new RemoteDigestResult(app.getSigningDigestAlgorithm(), app.getSigningDigestValue());
}
@ -136,11 +145,17 @@ public class RemoteWalletService {
}
@Transactional
public RemoteSignatureResult computeSignature(InstitutionKeySubject subject, String providedPrivateKey) {
public RemoteSignatureResult computeSignature(InstitutionKeySubject subject, String providedPrivateKey,
String algorithm) {
RemoteWalletApplication app = findRequired(subject);
if (app.getSigningDigestValue() == null) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先完成SM3摘要运算");
}
// 校验签名算法,选择有误则错误数+1
if (!SIGN_ALGORITHM.equalsIgnoreCase(algorithm != null ? algorithm.trim() : "")) {
scoreService.recordError(subject.getUserId());
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "签名算法选择有误,错误次数+1");
}
if (app.getSignature() != null) {
return new RemoteSignatureResult("SM2", app.getSignature());
}
@ -256,11 +271,16 @@ public class RemoteWalletService {
}
@Transactional
public RemoteDigestResult computeWalletDigest(InstitutionKeySubject subject) {
public RemoteDigestResult computeWalletDigest(InstitutionKeySubject subject, String algorithm) {
RemoteWalletApplication app = findRequired(subject);
if (app.getWalletExtractMessage() == null) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先提取钱包摘要原文");
}
// 校验摘要算法,选择有误则错误数+1
if (!DIGEST_ALGORITHM.equalsIgnoreCase(algorithm != null ? algorithm.trim() : "")) {
scoreService.recordError(subject.getUserId());
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "算法选择有误,错误次数+1");
}
if (app.getWalletDigestValue() != null) {
return new RemoteDigestResult(app.getWalletDigestAlgorithm(), app.getWalletDigestValue());
}

@ -43,6 +43,10 @@ public class SalaryBatchService {
private static final int STATUS_PASSED = 1;
private static final int STATUS_FAILED = 2;
// 摘要与签名算法(前端选择,选错则错误数+1)
private static final String DIGEST_ALGORITHM = "SM3";
private static final String SIGN_ALGORITHM = "SM2";
// 内置工资明细数据
private static final String[][] EMPLOYEE_DATA = {
{"张明", "WALLET_EMP_001", "技术部", "8500.00"},
@ -119,11 +123,16 @@ public class SalaryBatchService {
// ==================== 步骤三:生成摘要 ====================
@Transactional
public SalaryDigestResult computeDigest(InstitutionKeySubject subject) {
public SalaryDigestResult computeDigest(InstitutionKeySubject subject, String algorithm) {
SalaryBatch batch = findRequired(subject);
if (batch.getStatus().ordinal() < SalaryBatch.Status.CONCATENATED.ordinal()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先拼接工资发放申请原文");
}
// 校验摘要算法,选择有误则错误数+1
if (!DIGEST_ALGORITHM.equalsIgnoreCase(algorithm != null ? algorithm.trim() : "")) {
scoreService.recordError(subject.getUserId());
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "算法选择有误,错误次数+1");
}
if (batch.getDigestValue() != null) {
return new SalaryDigestResult(batch.getDigestValue());
}
@ -141,11 +150,16 @@ public class SalaryBatchService {
// ==================== 步骤四:SM2签名 ====================
@Transactional
public SalarySignatureResult sign(InstitutionKeySubject subject, String providedPrivateKey) {
public SalarySignatureResult sign(InstitutionKeySubject subject, String providedPrivateKey, String algorithm) {
SalaryBatch batch = findRequired(subject);
if (batch.getStatus().ordinal() < SalaryBatch.Status.DIGESTED.ordinal()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先生成摘要");
}
// 校验签名算法,选择有误则错误数+1
if (!SIGN_ALGORITHM.equalsIgnoreCase(algorithm != null ? algorithm.trim() : "")) {
scoreService.recordError(subject.getUserId());
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "签名算法选择有误,错误次数+1");
}
if (batch.getSignature() != null) {
return new SalarySignatureResult(batch.getSignature());
}
@ -307,11 +321,16 @@ public class SalaryBatchService {
// ==================== 步骤九:生成审核摘要 ====================
@Transactional
public SalaryReviewDigestResult computeReviewDigest(InstitutionKeySubject subject) {
public SalaryReviewDigestResult computeReviewDigest(InstitutionKeySubject subject, String algorithm) {
SalaryBatch batch = findRequired(subject);
if (batch.getStatus().ordinal() < SalaryBatch.Status.REVIEW_CHECKED.ordinal()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先完成审核与权限校验");
}
// 校验摘要算法,选择有误则错误数+1
if (!DIGEST_ALGORITHM.equalsIgnoreCase(algorithm != null ? algorithm.trim() : "")) {
scoreService.recordError(subject.getUserId());
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "算法选择有误,错误次数+1");
}
if (batch.getReviewDigestValue() != null) {
return new SalaryReviewDigestResult(batch.getReviewDigestValue());
}
@ -326,11 +345,17 @@ public class SalaryBatchService {
// ==================== 步骤十:SM2签名(复核员私钥) ====================
@Transactional
public SalaryReviewSignatureResult signReview(InstitutionKeySubject subject, String providedPrivateKey) {
public SalaryReviewSignatureResult signReview(InstitutionKeySubject subject, String providedPrivateKey,
String algorithm) {
SalaryBatch batch = findRequired(subject);
if (batch.getStatus().ordinal() < SalaryBatch.Status.REVIEW_DIGESTED.ordinal()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先生成审核摘要");
}
// 校验签名算法,选择有误则错误数+1
if (!SIGN_ALGORITHM.equalsIgnoreCase(algorithm != null ? algorithm.trim() : "")) {
scoreService.recordError(subject.getUserId());
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "签名算法选择有误,错误次数+1");
}
if (batch.getReviewSignature() != null) {
return new SalaryReviewSignatureResult(batch.getReviewSignature());
}
@ -445,11 +470,16 @@ public class SalaryBatchService {
// ==================== 步骤十三:生成交易确认摘要 ====================
@Transactional
public SalaryConfirmDigestResult computeConfirmDigest(InstitutionKeySubject subject) {
public SalaryConfirmDigestResult computeConfirmDigest(InstitutionKeySubject subject, String algorithm) {
SalaryBatch batch = findRequired(subject);
if (batch.getStatus().ordinal() < SalaryBatch.Status.CONFIRM_EXTRACTED.ordinal()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先提取交易确认原文");
}
// 校验摘要算法,选择有误则错误数+1
if (!DIGEST_ALGORITHM.equalsIgnoreCase(algorithm != null ? algorithm.trim() : "")) {
scoreService.recordError(subject.getUserId());
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "算法选择有误,错误次数+1");
}
if (batch.getConfirmDigestValue() != null) {
return new SalaryConfirmDigestResult(batch.getConfirmDigestValue());
}
@ -464,11 +494,17 @@ public class SalaryBatchService {
// ==================== 步骤十四:央行第一私钥签名 ====================
@Transactional
public SalaryConfirmSignatureResult signConfirm(InstitutionKeySubject subject, String providedPrivateKey) {
public SalaryConfirmSignatureResult signConfirm(InstitutionKeySubject subject, String providedPrivateKey,
String algorithm) {
SalaryBatch batch = findRequired(subject);
if (batch.getStatus().ordinal() < SalaryBatch.Status.CONFIRM_DIGESTED.ordinal()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先生成交易确认摘要");
}
// 校验签名算法,选择有误则错误数+1
if (!SIGN_ALGORITHM.equalsIgnoreCase(algorithm != null ? algorithm.trim() : "")) {
scoreService.recordError(subject.getUserId());
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "签名算法选择有误,错误次数+1");
}
if (batch.getCbSignature() != null) {
return new SalaryConfirmSignatureResult(batch.getCbSignature());
}

@ -23,7 +23,7 @@ public class CorporateInnovationTrainingResetController {
@PostMapping("/reset")
@Operation(summary = "重新开始数字人民币创新应用场景实验",
description = "逻辑删除当前主体的对公钱包申请、工资批次、模块六成绩和步骤进度;保留模块六密钥,可从模块六第一步重新开始。")
description = "逻辑删除当前主体的对公钱包开立申请、远程开立申请、工资代发批次、公益捐赠及捐赠收据、捐赠记录、监管备案,以及模块六成绩明细与步骤进度;保留模块六密钥,可从模块六第一步重新开始。")
public ApiResponse<CorporateInnovationTrainingResetResult> reset() {
return ApiResponse.success(resetService.reset(
InstitutionKeySubject.from(AuthContextHolder.get())), traceId());

@ -10,6 +10,7 @@ import com.yau.digitalrmb.corporatewallet.application.CorporateWalletKeyService;
import com.yau.digitalrmb.corporatewallet.application.CorporateWalletResult;
import com.yau.digitalrmb.corporatewallet.application.CorporateWalletService;
import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.CorporateWalletApplicationRequest;
import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.CorporateWalletDigestRequest;
import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject;
import com.yau.digitalrmb.security.context.AuthContextHolder;
import com.yau.digitalrmb.security.context.JwtUser;
@ -81,10 +82,12 @@ public class CorporateWalletController {
@PostMapping("/digest")
@Operation(summary = "SM3运算摘要",
description = "对提取的摘要原文进行SM3哈希运算,返回算法名称和摘要值")
public ApiResponse<CorporateDigestResult> computeDigest() {
description = "前端传入摘要算法,校验算法是否为SM3(错误则错误数+1),"
+ "通过后对提取的摘要原文进行SM3哈希运算,返回算法名称和摘要值")
public ApiResponse<CorporateDigestResult> computeDigest(
@Valid @RequestBody CorporateWalletDigestRequest request) {
return ApiResponse.success(corporateWalletService.computeDigest(
InstitutionKeySubject.from(AuthContextHolder.get())), traceId());
InstitutionKeySubject.from(AuthContextHolder.get()), request.getAlgorithm()), traceId());
}
@PostMapping("/wallet-output")

@ -11,6 +11,7 @@ import com.yau.digitalrmb.corporatewallet.application.RemoteWalletKeyResult;
import com.yau.digitalrmb.corporatewallet.application.RemoteWalletResult;
import com.yau.digitalrmb.corporatewallet.application.RemoteWalletService;
import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.RemoteFaceRecognitionRequest;
import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.RemoteWalletDigestRequest;
import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.RemoteWalletApplicationRequest;
import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.RemoteWalletSignRequest;
import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject;
@ -86,19 +87,23 @@ public class RemoteWalletController {
@PostMapping("/signing-digest")
@Operation(summary = "步骤二-SM3运算签约摘要",
description = "对电子签约摘要原文进行SM3哈希运算")
public ApiResponse<RemoteDigestResult> computeSigningDigest() {
description = "前端传入摘要算法,校验算法是否为SM3(错误则错误数+1),"
+ "通过后对电子签约摘要原文进行SM3哈希运算")
public ApiResponse<RemoteDigestResult> computeSigningDigest(
@Valid @RequestBody RemoteWalletDigestRequest request) {
return ApiResponse.success(remoteWalletService.computeSigningDigest(
InstitutionKeySubject.from(AuthContextHolder.get())), traceId());
InstitutionKeySubject.from(AuthContextHolder.get()), request.getAlgorithm()), traceId());
}
@PostMapping("/sign")
@Operation(summary = "步骤二-SM2计算签名",
description = "传入企业法人私钥进行SM2签名,私钥不正确则错误数+1")
description = "前端传入企业法人私钥和签名算法,校验算法是否为SM2(错误则错误数+1),"
+ "校验私钥是否正确(错误则错误数+1),都通过则返回签名值")
public ApiResponse<RemoteSignatureResult> computeSignature(
@Valid @RequestBody RemoteWalletSignRequest request) {
return ApiResponse.success(remoteWalletService.computeSignature(
InstitutionKeySubject.from(AuthContextHolder.get()), request.getPrivateKey()), traceId());
InstitutionKeySubject.from(AuthContextHolder.get()), request.getPrivateKey(),
request.getAlgorithm()), traceId());
}
@PostMapping("/contract-output")
@ -121,10 +126,12 @@ public class RemoteWalletController {
@PostMapping("/wallet-digest")
@Operation(summary = "步骤三-SM3运算钱包摘要",
description = "对钱包摘要原文进行SM3哈希运算,生成钱包ID")
public ApiResponse<RemoteDigestResult> computeWalletDigest() {
description = "前端传入摘要算法,校验算法是否为SM3(错误则错误数+1),"
+ "通过后对钱包摘要原文进行SM3哈希运算,生成钱包ID")
public ApiResponse<RemoteDigestResult> computeWalletDigest(
@Valid @RequestBody RemoteWalletDigestRequest request) {
return ApiResponse.success(remoteWalletService.computeWalletDigest(
InstitutionKeySubject.from(AuthContextHolder.get())), traceId());
InstitutionKeySubject.from(AuthContextHolder.get()), request.getAlgorithm()), traceId());
}
@PostMapping("/wallet-output")

@ -9,6 +9,7 @@ import com.yau.digitalrmb.corporatewallet.application.SalaryOutputResult;
import com.yau.digitalrmb.corporatewallet.application.SalaryPageResult;
import com.yau.digitalrmb.corporatewallet.application.SalarySendResult;
import com.yau.digitalrmb.corporatewallet.application.SalarySignatureResult;
import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.SalaryDigestRequest;
import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.SalarySignRequest;
import com.yau.digitalrmb.security.context.AuthContextHolder;
import com.yau.digitalrmb.security.context.JwtUser;
@ -48,18 +49,21 @@ public class SalaryBatchController {
@PostMapping("/digest")
@Operation(summary = "生成摘要",
description = "对拼接原文进行SM3运算,只返回摘要值")
public ApiResponse<SalaryDigestResult> computeDigest() {
description = "前端传入摘要算法,校验算法是否为SM3(错误则错误数+1),"
+ "通过后对拼接原文进行SM3运算,只返回摘要值")
public ApiResponse<SalaryDigestResult> computeDigest(@Valid @RequestBody SalaryDigestRequest request) {
return ApiResponse.success(salaryBatchService.computeDigest(
InstitutionKeySubject.from(AuthContextHolder.get())), traceId());
InstitutionKeySubject.from(AuthContextHolder.get()), request.getAlgorithm()), traceId());
}
@PostMapping("/sign")
@Operation(summary = "SM2签名",
description = "传入数币操作员私钥进行SM2签名,私钥不正确则错误数+1,只返回签名值")
description = "前端传入数币操作员私钥和签名算法,校验算法是否为SM2(错误则错误数+1),"
+ "校验私钥是否正确(错误则错误数+1),都通过则返回签名值")
public ApiResponse<SalarySignatureResult> sign(@Valid @RequestBody SalarySignRequest request) {
return ApiResponse.success(salaryBatchService.sign(
InstitutionKeySubject.from(AuthContextHolder.get()), request.getPrivateKey()), traceId());
InstitutionKeySubject.from(AuthContextHolder.get()), request.getPrivateKey(),
request.getAlgorithm()), traceId());
}
@PostMapping("/output")

@ -5,6 +5,7 @@ import com.yau.digitalrmb.corporatewallet.application.SalaryConfirmDigestResult;
import com.yau.digitalrmb.corporatewallet.application.SalaryConfirmExtractResult;
import com.yau.digitalrmb.corporatewallet.application.SalaryConfirmOutputResult;
import com.yau.digitalrmb.corporatewallet.application.SalaryConfirmSignatureResult;
import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.SalaryConfirmDigestRequest;
import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.SalaryConfirmSignRequest;
import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject;
import com.yau.digitalrmb.security.context.AuthContextHolder;
@ -35,18 +36,22 @@ public class SalaryConfirmController {
@PostMapping("/digest")
@Operation(summary = "生成交易确认摘要",
description = "对拼接交易确认原文进行SM3运算,只返回摘要值")
public ApiResponse<SalaryConfirmDigestResult> computeConfirmDigest() {
description = "前端传入摘要算法,校验算法是否为SM3(错误则错误数+1),"
+ "通过后对拼接交易确认原文进行SM3运算,只返回摘要值")
public ApiResponse<SalaryConfirmDigestResult> computeConfirmDigest(
@Valid @RequestBody SalaryConfirmDigestRequest request) {
return ApiResponse.success(salaryBatchService.computeConfirmDigest(
InstitutionKeySubject.from(AuthContextHolder.get())), traceId());
InstitutionKeySubject.from(AuthContextHolder.get()), request.getAlgorithm()), traceId());
}
@PostMapping("/sign")
@Operation(summary = "央行第一私钥签名",
description = "传入央行第一私钥进行SM2签名,私钥不正确则错误数+1,返回带CB_FINAL_前缀的签名值")
description = "前端传入央行第一私钥和签名算法,校验算法是否为SM2(错误则错误数+1),"
+ "校验私钥是否正确(错误则错误数+1),都通过则返回带CB_FINAL_前缀的签名值")
public ApiResponse<SalaryConfirmSignatureResult> signConfirm(@Valid @RequestBody SalaryConfirmSignRequest request) {
return ApiResponse.success(salaryBatchService.signConfirm(
InstitutionKeySubject.from(AuthContextHolder.get()), request.getPrivateKey()), traceId());
InstitutionKeySubject.from(AuthContextHolder.get()), request.getPrivateKey(),
request.getAlgorithm()), traceId());
}
@PostMapping("/output")

@ -6,6 +6,7 @@ import com.yau.digitalrmb.corporatewallet.application.SalaryReviewDigestResult;
import com.yau.digitalrmb.corporatewallet.application.SalaryReviewExtractResult;
import com.yau.digitalrmb.corporatewallet.application.SalaryReviewOutputResult;
import com.yau.digitalrmb.corporatewallet.application.SalaryReviewSignatureResult;
import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.SalaryReviewDigestRequest;
import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.SalaryReviewSignRequest;
import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject;
import com.yau.digitalrmb.security.context.AuthContextHolder;
@ -44,18 +45,22 @@ public class SalaryReviewController {
@PostMapping("/digest")
@Operation(summary = "生成审核摘要",
description = "对拼接审核原文进行SM3运算,只返回摘要值")
public ApiResponse<SalaryReviewDigestResult> computeReviewDigest() {
description = "前端传入摘要算法,校验算法是否为SM3(错误则错误数+1),"
+ "通过后对拼接审核原文进行SM3运算,只返回摘要值")
public ApiResponse<SalaryReviewDigestResult> computeReviewDigest(
@Valid @RequestBody SalaryReviewDigestRequest request) {
return ApiResponse.success(salaryBatchService.computeReviewDigest(
InstitutionKeySubject.from(AuthContextHolder.get())), traceId());
InstitutionKeySubject.from(AuthContextHolder.get()), request.getAlgorithm()), traceId());
}
@PostMapping("/sign")
@Operation(summary = "SM2签名(复核员私钥)",
description = "传入数币复核员私钥进行SM2签名,私钥不正确则错误数+1,只返回签名值")
description = "前端传入数币复核员私钥和签名算法,校验算法是否为SM2(错误则错误数+1),"
+ "校验私钥是否正确(错误则错误数+1),都通过则返回签名值")
public ApiResponse<SalaryReviewSignatureResult> signReview(@Valid @RequestBody SalaryReviewSignRequest request) {
return ApiResponse.success(salaryBatchService.signReview(
InstitutionKeySubject.from(AuthContextHolder.get()), request.getPrivateKey()), traceId());
InstitutionKeySubject.from(AuthContextHolder.get()), request.getPrivateKey(),
request.getAlgorithm()), traceId());
}
@PostMapping("/output")

@ -0,0 +1,15 @@
package com.yau.digitalrmb.corporatewallet.interfaces.rest.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
@Schema(description = "对公钱包开立摘要请求")
public class CorporateWalletDigestRequest {
@NotBlank(message = "算法不能为空")
@Schema(description = "选择的摘要算法(如:SM3)", example = "SM3", required = true)
private String algorithm;
}

@ -0,0 +1,15 @@
package com.yau.digitalrmb.corporatewallet.interfaces.rest.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
@Schema(description = "远程开立摘要请求(电子签约摘要/钱包摘要)")
public class RemoteWalletDigestRequest {
@NotBlank(message = "算法不能为空")
@Schema(description = "选择的摘要算法(如:SM3)", example = "SM3", required = true)
private String algorithm;
}

@ -11,4 +11,8 @@ public class RemoteWalletSignRequest {
@NotBlank(message = "企业法人私钥不能为空")
@Schema(description = "企业法人私钥(十六进制)", required = true)
private String privateKey;
@NotBlank(message = "签名算法不能为空")
@Schema(description = "选择的签名算法(如:SM2)", example = "SM2", required = true)
private String algorithm;
}

@ -0,0 +1,15 @@
package com.yau.digitalrmb.corporatewallet.interfaces.rest.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
@Schema(description = "交易确认摘要请求")
public class SalaryConfirmDigestRequest {
@NotBlank(message = "算法不能为空")
@Schema(description = "选择的摘要算法(如:SM3)", example = "SM3", required = true)
private String algorithm;
}

@ -11,4 +11,8 @@ public class SalaryConfirmSignRequest {
@NotBlank(message = "央行第一私钥不能为空")
@Schema(description = "央行第一私钥(十六进制)", required = true)
private String privateKey;
@NotBlank(message = "签名算法不能为空")
@Schema(description = "选择的签名算法(如:SM2)", example = "SM2", required = true)
private String algorithm;
}

@ -0,0 +1,15 @@
package com.yau.digitalrmb.corporatewallet.interfaces.rest.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
@Schema(description = "工资发放申请摘要请求")
public class SalaryDigestRequest {
@NotBlank(message = "算法不能为空")
@Schema(description = "选择的摘要算法(如:SM3)", example = "SM3", required = true)
private String algorithm;
}

@ -0,0 +1,15 @@
package com.yau.digitalrmb.corporatewallet.interfaces.rest.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
@Schema(description = "工资发放审核摘要请求")
public class SalaryReviewDigestRequest {
@NotBlank(message = "算法不能为空")
@Schema(description = "选择的摘要算法(如:SM3)", example = "SM3", required = true)
private String algorithm;
}

@ -11,4 +11,8 @@ public class SalaryReviewSignRequest {
@NotBlank(message = "数币复核员私钥不能为空")
@Schema(description = "数币复核员私钥(十六进制)", required = true)
private String privateKey;
@NotBlank(message = "签名算法不能为空")
@Schema(description = "选择的签名算法(如:SM2)", example = "SM2", required = true)
private String algorithm;
}

@ -11,4 +11,8 @@ public class SalarySignRequest {
@NotBlank(message = "数币操作员私钥不能为空")
@Schema(description = "数币操作员私钥(十六进制)", required = true)
private String privateKey;
@NotBlank(message = "签名算法不能为空")
@Schema(description = "选择的签名算法(如:SM2)", example = "SM2", required = true)
private String algorithm;
}

@ -2,7 +2,30 @@ package com.yau.digitalrmb.institutionidentity.infrastructure;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface StuModuleScoreDetailsMapper extends BaseMapper<StuModuleScoreDetailsEntity> {
/**
* 统计模块操作总次数:包含已逻辑删除(历史轮次)的记录。
*/
@Select("SELECT COUNT(*) FROM stu_module_score_details "
+ "WHERE user_id = #{userId} AND moudule = #{module}")
int countAllIncludingDeleted(@Param("userId") String userId, @Param("module") String module);
/**
* 查询模块全部成绩明细:包含已逻辑删除的记录(用于最高/最低/平均统计)。
* 手写 SQL 绕过 MyBatis-Plus 的 @TableLogic 自动过滤。
*/
@Select("SELECT id, user_id, moudule, learning_projects, assessment_items, scoring_criteria, "
+ "completion_status, score_project, total_score, serial_number, schedule, deleted "
+ "FROM stu_module_score_details "
+ "WHERE user_id = #{userId} AND moudule = #{module} "
+ "ORDER BY serial_number ASC")
List<StuModuleScoreDetailsEntity> selectAllIncludingDeleted(@Param("userId") String userId,
@Param("module") String module);
}

@ -0,0 +1,194 @@
package com.yau.digitalrmb.training.experimentreport.application;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.poi.hwpf.HWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* 实训报告文件转 PDF 工具。
* 支持:PDF 原样存储;图片(png/jpg/jpeg/bmp/gif) 转 PDF;Word(doc/docx) 抽取文本转 PDF。
* Word 转 PDF 仅保留文本内容,并尽量加载系统中文字体以正确渲染中文。
*/
@Component
public class ExperimentReportPdfConverter {
private static final List<String> IMAGE_EXT = Arrays.asList("png", "jpg", "jpeg", "bmp", "gif");
public ConvertedPdf convert(MultipartFile file) {
String originalName = file.getOriginalFilename();
String ext = resolveExt(originalName);
try {
byte[] bytes;
if ("pdf".equals(ext)) {
bytes = file.getBytes();
} else if (IMAGE_EXT.contains(ext)) {
bytes = imageToPdf(file.getBytes());
} else if ("docx".equals(ext) || "doc".equals(ext)) {
bytes = documentToPdf(file.getInputStream(), ext);
} else {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"不支持的文件格式,仅支持 PDF / 图片(png,jpg,jpeg,bmp,gif) / Word(doc,docx),实际后缀:" + ext);
}
if (bytes == null || bytes.length == 0) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "文件内容为空");
}
return new ConvertedPdf(bytes, originalName);
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "文件转 PDF 失败:" + e.getMessage());
}
}
private String resolveExt(String name) {
if (name == null || name.lastIndexOf('.') < 0) {
return "";
}
return name.substring(name.lastIndexOf('.') + 1).toLowerCase();
}
private byte[] imageToPdf(byte[] bytes) throws Exception {
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
if (image == null) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "无法解析的图片文件");
}
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(new PDRectangle(image.getWidth(), image.getHeight()));
doc.addPage(page);
PDImageXObject img = LosslessFactory.createFromImage(doc, image);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.drawImage(img, 0, 0, image.getWidth(), image.getHeight());
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
doc.save(out);
return out.toByteArray();
}
}
private byte[] documentToPdf(InputStream in, String ext) throws Exception {
List<String> lines = new ArrayList<>();
if ("docx".equals(ext)) {
try (XWPFDocument doc = new XWPFDocument(in)) {
for (XWPFParagraph p : doc.getParagraphs()) {
lines.add(p.getText());
}
for (XWPFTable t : doc.getTables()) {
for (XWPFTableRow row : t.getRows()) {
StringBuilder sb = new StringBuilder();
for (XWPFTableCell cell : row.getTableCells()) {
sb.append(cell.getText()).append("\t");
}
lines.add(sb.toString());
}
}
}
} else {
try (HWPFDocument doc = new HWPFDocument(in)) {
lines.add(doc.getDocumentText());
}
}
return textToPdf(lines);
}
private byte[] textToPdf(List<String> lines) throws Exception {
try (PDDocument doc = new PDDocument()) {
PDFont font = loadCjkFont(doc);
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
PDPageContentStream cs = new PDPageContentStream(doc, page);
float margin = 50f;
float y = PDRectangle.A4.getHeight() - margin;
float leading = 16f;
for (String raw : lines) {
String line = raw == null ? "" : raw;
if (y < margin) {
cs.close();
page = new PDPage(PDRectangle.A4);
doc.addPage(page);
cs = new PDPageContentStream(doc, page);
cs.setFont(font, 12);
y = PDRectangle.A4.getHeight() - margin;
}
cs.beginText();
cs.newLineAtOffset(margin, y);
cs.showText(clip(line, 120));
cs.endText();
y -= leading;
}
cs.close();
ByteArrayOutputStream out = new ByteArrayOutputStream();
doc.save(out);
return out.toByteArray();
}
}
private PDFont loadCjkFont(PDDocument doc) {
String[] candidates = {
"C:/Windows/Fonts/msyh.ttc",
"C:/Windows/Fonts/simsun.ttc",
"C:/Windows/Fonts/simhei.ttf",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/System/Library/Fonts/PingFang.ttc"
};
for (String path : candidates) {
File f = new File(path);
if (f.exists()) {
try {
return PDType0Font.load(doc, f);
} catch (Exception ignored) {
// 继续尝试下一个候选字体
}
}
}
return PDType1Font.HELVETICA;
}
private String clip(String s, int max) {
return s.length() <= max ? s : s.substring(0, max);
}
/** 转换结果:PDF 字节数组 + 原始文件名。 */
public static class ConvertedPdf {
private final byte[] bytes;
private final String originalName;
public ConvertedPdf(byte[] bytes, String originalName) {
this.bytes = bytes;
this.originalName = originalName;
}
public byte[] getBytes() {
return bytes;
}
public String getOriginalName() {
return originalName;
}
}
}

@ -0,0 +1,126 @@
package com.yau.digitalrmb.training.experimentreport.application;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yau.digitalrmb.security.application.CurrentUser;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.config.IFileUtil;
import com.yau.digitalrmb.shared.exception.BusinessException;
import com.yau.digitalrmb.training.experimentreport.infrastructure.ExperimentReportEntity;
import com.yau.digitalrmb.training.experimentreport.infrastructure.ExperimentReportMapper;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 实训报告业务:上传(转 PDF 后存储)与按当前学生查询。
*/
@Service
public class ExperimentReportService {
private final ExperimentReportMapper mapper;
private final IFileUtil fileUtil;
private final ExperimentReportPdfConverter converter;
public ExperimentReportService(ExperimentReportMapper mapper, IFileUtil fileUtil,
ExperimentReportPdfConverter converter) {
this.mapper = mapper;
this.fileUtil = fileUtil;
this.converter = converter;
}
/**
* 上传实训报告:先转 PDF,再存储到服务器,地址入库。
*
* <p>单用户仅允许一份有效报告:
* <ul>
* <li>首次上传 → 新增记录;</li>
* <li>再次上传(教师尚未评分)→ 覆盖修改原记录(文件/元信息更新,原评分与评语失效置空);</li>
* <li>教师已评分后上传 → 拒绝(400),避免覆盖已批改报告。</li>
* </ul>
*
* 报告归属当前登录用户,与实验模块无关。
*/
public ExperimentReportView upload(MultipartFile file, CurrentUser user) {
if (file == null || file.isEmpty()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "文件不能为空");
}
// 先查该用户已有有效报告:已评分则禁止修改,避免覆盖已批改报告;未评分则进入覆盖修改分支
ExperimentReportEntity existing = findActiveByUser(user.getUserId());
if (existing != null && existing.getTeacherScore() != null) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "教师已评分,不能修改报告");
}
ExperimentReportPdfConverter.ConvertedPdf pdf = converter.convert(file);
String objectName = System.nanoTime() + "_" + java.util.UUID.randomUUID().toString().replace("-", "")
+ ".pdf";
String relativePath;
try {
relativePath = fileUtil.upload(objectName, new ByteArrayInputStream(pdf.getBytes()));
} catch (Exception e) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "文件存储失败:" + e.getMessage());
}
if (existing != null) {
// 覆盖修改:保留同一份报告 id,替换文件与元信息,原评分/评语随新报告失效置空
String oldPath = existing.getFileUrl();
existing.setReportName(file.getOriginalFilename());
existing.setFileUrl(relativePath);
existing.setFileSize(file.getSize());
existing.setUploadTime(LocalDateTime.now());
existing.setTeacherScore(null);
existing.setTeacherComment(null);
existing.setUpdatedAt(LocalDateTime.now());
mapper.updateById(existing);
removeIfPresent(oldPath);
return new ExperimentReportView(existing);
}
ExperimentReportEntity entity = new ExperimentReportEntity();
entity.setReportName(file.getOriginalFilename());
entity.setFileUrl(relativePath);
entity.setFileSize(file.getSize());
entity.setUploadTime(LocalDateTime.now());
entity.setUserId(user.getUserId());
entity.setSchoolId(user.getSchoolId());
entity.setClassId(user.getClassId());
entity.setTeacherScore(null);
entity.setDeleted(false);
mapper.insert(entity);
return new ExperimentReportView(entity);
}
/** 查询当前用户未删除的有效报告(单用户仅一份,返回至多一条)。 */
private ExperimentReportEntity findActiveByUser(String userId) {
LambdaQueryWrapper<ExperimentReportEntity> q = new LambdaQueryWrapper<>();
q.eq(ExperimentReportEntity::getUserId, userId).eq(ExperimentReportEntity::getDeleted, false);
return mapper.selectOne(q);
}
/** 覆盖修改时清理被替换的旧文件,删除失败不影响主流程。 */
private void removeIfPresent(String path) {
if (path == null || path.trim().isEmpty()) {
return;
}
try {
fileUtil.remove(path);
} catch (Exception ignored) {
// 旧文件清理失败仅记录,不阻断上传
}
}
/** 查询当前学生提交的全部实训报告(不含已删除),按上传时间倒序。 */
public List<ExperimentReportView> listByUser(String userId) {
LambdaQueryWrapper<ExperimentReportEntity> q = new LambdaQueryWrapper<>();
q.eq(ExperimentReportEntity::getUserId, userId)
.eq(ExperimentReportEntity::getDeleted, false)
.orderByDesc(ExperimentReportEntity::getUploadTime);
return mapper.selectList(q).stream()
.map(ExperimentReportView::new)
.collect(Collectors.toList());
}
}

@ -0,0 +1,41 @@
package com.yau.digitalrmb.training.experimentreport.application;
import com.yau.digitalrmb.training.experimentreport.infrastructure.ExperimentReportEntity;
import lombok.Getter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 实训报告视图,用于接口返回。
*/
@Getter
public class ExperimentReportView {
private final String id;
private final String reportName;
private final String fileUrl;
private final Long fileSize;
private final LocalDateTime uploadTime;
private final BigDecimal teacherScore;
private final String teacherComment;
private final String userId;
private final String schoolId;
private final String classId;
private final LocalDateTime createdAt;
private final LocalDateTime updatedAt;
public ExperimentReportView(ExperimentReportEntity e) {
this.id = e.getId();
this.reportName = e.getReportName();
this.fileUrl = e.getFileUrl();
this.fileSize = e.getFileSize();
this.uploadTime = e.getUploadTime();
this.teacherScore = e.getTeacherScore();
this.teacherComment = e.getTeacherComment();
this.userId = e.getUserId();
this.schoolId = e.getSchoolId();
this.classId = e.getClassId();
this.createdAt = e.getCreatedAt();
this.updatedAt = e.getUpdatedAt();
}
}

@ -0,0 +1,61 @@
package com.yau.digitalrmb.training.experimentreport.infrastructure;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 实训报告表:与具体实验模块解耦,一个用户可多次提交,按 user_id 查询。
*/
@Getter
@Setter
@TableName("experiment_report")
public class ExperimentReportEntity {
@TableId(type = IdType.ASSIGN_UUID)
private String id;
/** 报告名称(上传时的原始文件名) */
@TableField("report_name")
private String reportName;
/** 文件地址:经转换后存储到服务器的 PDF 相对路径 */
@TableField("file_url")
private String fileUrl;
/** 文件大小(字节),存储原始上传文件大小 */
@TableField("file_size")
private Long fileSize;
/** 上传时间(业务时间,列表展示用) */
@TableField("upload_time")
private LocalDateTime uploadTime;
/** 教师评分 */
@TableField("teacher_score")
private BigDecimal teacherScore;
/** 教师评语 */
@TableField("teacher_comment")
private String teacherComment;
@TableField("user_id")
private String userId;
@TableField("school_id")
private String schoolId;
@TableField("class_id")
private String classId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Boolean deleted;
}

@ -0,0 +1,8 @@
package com.yau.digitalrmb.training.experimentreport.infrastructure;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ExperimentReportMapper extends BaseMapper<ExperimentReportEntity> {
}

@ -0,0 +1,37 @@
package com.yau.digitalrmb.training.experimentreport.interfaces.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import javax.validation.constraints.DecimalMax;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.Digits;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.math.BigDecimal;
/**
* 教师端实训报告评分请求体:按报告 id 定位记录,写入教师评分。
*/
@Getter
@Setter
@Schema(description = "实训报告评分请求")
public class ScoreReportRequest {
@Schema(description = "实训报告编号(experiment_report.id,来自报告列表接口)")
@NotBlank(message = "报告编号不能为空")
private String id;
@Schema(description = "教师评分,0-100,最多 2 位小数", example = "90.00")
@NotNull(message = "评分不能为空")
@Digits(integer = 3, fraction = 2, message = "评分最多保留 2 位小数")
@DecimalMin(value = "0", message = "评分不能小于 0")
@DecimalMax(value = "100", message = "评分不能大于 100")
private BigDecimal score;
@Schema(description = "教师评语,最多 500 字;不传表示不修改原评语,传空字符串表示清空", example = "报告结构完整,分析到位")
@Size(max = 500, message = "教师评语不能超过 500 字")
private String comment;
}

@ -0,0 +1,56 @@
package com.yau.digitalrmb.training.experimentreport.interfaces.rest;
import com.yau.digitalrmb.security.application.CurrentUser;
import com.yau.digitalrmb.security.application.CurrentUserService;
import com.yau.digitalrmb.shared.api.ApiResponse;
import com.yau.digitalrmb.shared.web.TraceIdFilter;
import com.yau.digitalrmb.training.experimentreport.application.ExperimentReportService;
import com.yau.digitalrmb.training.experimentreport.application.ExperimentReportView;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.MDC;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@RestController
@RequestMapping("/api/v1/experiment-reports")
@Tag(name = "实训报告", description = "学生上传实训报告(自动转 PDF 存储),并查询本人提交的报告列表。单用户仅允许一份有效报告:首次上传为新增,再次上传为覆盖修改,教师评分后禁止再上传。")
public class ExperimentReportController {
private final ExperimentReportService service;
private final CurrentUserService currentUserService;
public ExperimentReportController(ExperimentReportService service, CurrentUserService currentUserService) {
this.service = service;
this.currentUserService = currentUserService;
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "上传实训报告",
description = "仅接收单个 file 表单字段;服务端自动转 PDF 后存储到服务器,文件地址入库。"
+ "支持 PDF / 图片(png,jpg,jpeg,bmp,gif) / Word(doc,docx)。报告归属当前登录用户。"
+ "单用户仅一份有效报告:首次上传为新增;已有且教师未评分时为覆盖修改;教师已评分后再次上传返回 400。")
public ApiResponse<ExperimentReportView> upload(
@Parameter(description = "报告文件", required = true)
@RequestPart("file") MultipartFile file) {
CurrentUser user = currentUserService.getCurrentUser();
return ApiResponse.success(service.upload(file, user), MDC.get(TraceIdFilter.MDC_KEY));
}
@GetMapping
@Operation(summary = "查询当前学生提交的实训报告",
description = "返回本人提交的全部报告(报告名称、上传时间、报告大小、教师评分、文件地址等),按上传时间倒序。")
public ApiResponse<List<ExperimentReportView>> list() {
CurrentUser user = currentUserService.getCurrentUser();
return ApiResponse.success(service.listByUser(user.getUserId()), MDC.get(TraceIdFilter.MDC_KEY));
}
}

@ -0,0 +1,30 @@
package com.yau.digitalrmb.training.experimentreport.teacher.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.List;
/**
* 教师端实验报告分页结果,分页字段与教师端总成绩页同风格。
*/
@Getter
@AllArgsConstructor
@Schema(description = "教师端实验报告分页")
public class TeacherReportPage {
@Schema(description = "当前页码,从 1 开始", example = "1")
private final long current;
@Schema(description = "每页数量", example = "10")
private final long size;
@Schema(description = "总条数", example = "37")
private final long total;
@Schema(description = "总页数", example = "4")
private final long pages;
@Schema(description = "当前页数据")
private final List<TeacherReportRow> records;
}

@ -0,0 +1,52 @@
package com.yau.digitalrmb.training.experimentreport.teacher.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 教师端实验报告列表的一行:学生基本信息 + 报告信息(与学生端报告列表同字段)。
*/
@Getter
@AllArgsConstructor
@Schema(description = "教师端实验报告一行")
public class TeacherReportRow {
@Schema(description = "学生用户编号")
private final String userId;
@Schema(description = "学生姓名", example = "孟立凯")
private final String userName;
@Schema(description = "学号", example = "13641069072")
private final String studentId;
@Schema(description = "班级名称", example = "产品演示1班")
private final String className;
@Schema(description = "班级 id", example = "2001")
private final String classId;
@Schema(description = "报告记录 id")
private final String reportId;
@Schema(description = "报告名称(上传时的原始文件名)", example = "test.pdf")
private final String reportName;
@Schema(description = "上传时间")
private final LocalDateTime uploadTime;
@Schema(description = "报告大小(字节)", example = "11264")
private final Long fileSize;
@Schema(description = "教师评分(未评分为 null)", example = "80")
private final BigDecimal teacherScore;
@Schema(description = "教师评语(未评语为 null)", example = "报告结构完整,分析到位")
private final String teacherComment;
@Schema(description = "文件地址(服务器相对路径,下载用)", example = "/2026/9/29/020176a6.pdf")
private final String fileUrl;
}

@ -0,0 +1,86 @@
package com.yau.digitalrmb.training.experimentreport.teacher.application;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import com.yau.digitalrmb.training.experimentreport.application.ExperimentReportView;
import com.yau.digitalrmb.training.experimentreport.infrastructure.ExperimentReportEntity;
import com.yau.digitalrmb.training.experimentreport.infrastructure.ExperimentReportMapper;
import com.yau.digitalrmb.training.experimentreport.teacher.infrastructure.TeacherReportQueryMapper;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 教师端实验报告业务:按当前学校分页查询报告(关联学生信息),以及给报告评分。
*/
@Service
public class TeacherReportService {
private final TeacherReportQueryMapper mapper;
private final ExperimentReportMapper reportMapper;
public TeacherReportService(TeacherReportQueryMapper mapper, ExperimentReportMapper reportMapper) {
this.mapper = mapper;
this.reportMapper = reportMapper;
}
/**
* 分页查询当前学校学生的实验报告。
*
* @param schoolId 当前教师所属学校编号(数据隔离)
* @param userName 姓名模糊匹配,可空
* @param studentId 学号模糊匹配,可空
* @param classId 班级 id 精确匹配,可空
* @param current 页码,从 1 开始
* @param size 每页数量
*/
public TeacherReportPage pageQuery(String schoolId, String userName, String studentId,
String classId, int current, int size) {
int offset = (current - 1) * size;
long total = mapper.selectCount(schoolId, userName, studentId, classId);
List<TeacherReportRow> records = mapper.selectPage(schoolId, userName, studentId, classId, size, offset);
long pages = total == 0 ? 0 : (total + size - 1) / size;
return new TeacherReportPage(current, size, total, pages, records);
}
/**
* 教师给实训报告评分:按 id 定位报告,写入教师评分(experiment_report.teacher_score)。
*
* <p>校验:报告不存在或已删除 → 404;报告不属于当前教师所属学校 → 403;
* 评分为空、超过 2 位小数或不在 0-100 之间 → 400。
*
* @param schoolId 当前教师所属学校编号(数据隔离)
* @param id 实训报告编号
* @param score 教师评分
* @param comment 教师评语,最多 500 字;传 null 表示不修改原评语,传空字符串表示清空
* @return 评分后的报告视图(含 teacherScore、teacherComment)
*/
public ExperimentReportView score(String schoolId, String id, BigDecimal score, String comment) {
if (score == null) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "评分不能为空");
}
if (score.scale() > 2 || score.compareTo(BigDecimal.ZERO) < 0
|| score.compareTo(new BigDecimal("100")) > 0) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "评分最多保留 2 位小数且必须在 0 到 100 之间");
}
ExperimentReportEntity report = reportMapper.selectById(id);
if (report == null || Boolean.TRUE.equals(report.getDeleted())) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "实训报告不存在");
}
if (report.getSchoolId() == null || !report.getSchoolId().equals(schoolId)) {
throw new BusinessException(ErrorCode.FORBIDDEN, "无权为其他学校的学生评分");
}
// 评语:null 表示不改,空串表示清空,其余按 trimmed 文本写入
String text = comment == null ? report.getTeacherComment() : comment.trim();
if (text != null && text.length() > 500) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "教师评语不能超过 500 字");
}
report.setTeacherScore(score);
report.setTeacherComment(text);
report.setUpdatedAt(LocalDateTime.now());
reportMapper.updateById(report);
return new ExperimentReportView(report);
}
}

@ -0,0 +1,47 @@
package com.yau.digitalrmb.training.experimentreport.teacher.infrastructure;
import com.yau.digitalrmb.training.experimentreport.teacher.application.TeacherReportRow;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 教师端实验报告查询:按当前学校查 experiment_report 关联 sys_user(学生姓名/学号/班级筛选、分页)。
* 只读,不写入任何表。列名以驼峰别名返回,避免依赖 mapUnderscoreToCamelCase 配置。
*/
@Mapper
public interface TeacherReportQueryMapper {
@Select("<script>"
+ "SELECT r.id AS reportId, r.user_id AS userId, u.user_name AS userName, "
+ "u.student_id AS studentId, u.class_name AS className, u.class_id AS classId, "
+ "r.report_name AS reportName, r.upload_time AS uploadTime, r.file_size AS fileSize, "
+ "r.teacher_score AS teacherScore, r.teacher_comment AS teacherComment, r.file_url AS fileUrl "
+ "FROM experiment_report r INNER JOIN sys_user u ON r.user_id = u.user_id "
+ "WHERE r.school_id = #{schoolId} AND r.deleted = 0 "
+ "<if test='userName != null and userName != \"\"'>AND u.user_name LIKE CONCAT('%', #{userName}, '%') </if>"
+ "<if test='studentId != null and studentId != \"\"'>AND u.student_id LIKE CONCAT('%', #{studentId}, '%') </if>"
+ "<if test='classId != null and classId != \"\"'>AND u.class_id = #{classId} </if>"
+ "ORDER BY r.upload_time DESC LIMIT #{size} OFFSET #{offset}"
+ "</script>")
List<TeacherReportRow> selectPage(@Param("schoolId") String schoolId,
@Param("userName") String userName,
@Param("studentId") String studentId,
@Param("classId") String classId,
@Param("size") int size,
@Param("offset") int offset);
@Select("<script>"
+ "SELECT COUNT(*) FROM experiment_report r INNER JOIN sys_user u ON r.user_id = u.user_id "
+ "WHERE r.school_id = #{schoolId} AND r.deleted = 0 "
+ "<if test='userName != null and userName != \"\"'>AND u.user_name LIKE CONCAT('%', #{userName}, '%') </if>"
+ "<if test='studentId != null and studentId != \"\"'>AND u.student_id LIKE CONCAT('%', #{studentId}, '%') </if>"
+ "<if test='classId != null and classId != \"\"'>AND u.class_id = #{classId} </if>"
+ "</script>")
long selectCount(@Param("schoolId") String schoolId,
@Param("userName") String userName,
@Param("studentId") String studentId,
@Param("classId") String classId);
}

@ -0,0 +1,72 @@
package com.yau.digitalrmb.training.experimentreport.teacher.interfaces.rest;
import com.yau.digitalrmb.security.application.CurrentUser;
import com.yau.digitalrmb.security.application.CurrentUserService;
import com.yau.digitalrmb.shared.api.ApiResponse;
import com.yau.digitalrmb.shared.web.TraceIdFilter;
import com.yau.digitalrmb.training.experimentreport.application.ExperimentReportView;
import com.yau.digitalrmb.training.experimentreport.interfaces.dto.ScoreReportRequest;
import com.yau.digitalrmb.training.experimentreport.teacher.application.TeacherReportPage;
import com.yau.digitalrmb.training.experimentreport.teacher.application.TeacherReportService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.MDC;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
/**
* 教师端实验报告:按当前学校分页查询学生提交的报告(与学生端报告列表同字段,但查全校)。
*/
@RestController
@RequestMapping("/api/v1/experiment-reports/teacher")
@Tag(name = "教师端实验报告", description = "按当前学校查询学生提交的全部实验报告,支持姓名/学号/班级筛选,与实验报告上传无耦合")
public class TeacherExperimentReportController {
private final TeacherReportService service;
private final CurrentUserService currentUserService;
public TeacherExperimentReportController(TeacherReportService service, CurrentUserService currentUserService) {
this.service = service;
this.currentUserService = currentUserService;
}
@GetMapping
@Operation(summary = "教师端分页查询实验报告",
description = "按当前教师所属学校查询学生提交的全部报告,支持姓名(模糊)、学号(模糊)、班级 id(精确)筛选,分页返回。"
+ "每行包括姓名、学号、班级、报告名称、上传时间、报告大小、教师评分、文件地址。班级下拉复用 /training-scores/teacher/classes。")
public ApiResponse<TeacherReportPage> page(
@Parameter(description = "页码,从 1 开始", example = "1")
@RequestParam(value = "page", defaultValue = "1") int page,
@Parameter(description = "每页数量", example = "10")
@RequestParam(value = "size", defaultValue = "10") int size,
@Parameter(description = "姓名模糊匹配")
@RequestParam(value = "userName", required = false) String userName,
@Parameter(description = "学号模糊匹配")
@RequestParam(value = "studentId", required = false) String studentId,
@Parameter(description = "班级 id 精确匹配")
@RequestParam(value = "classId", required = false) String classId) {
CurrentUser user = currentUserService.getCurrentUser();
return ApiResponse.success(
service.pageQuery(user.getSchoolId(), userName, studentId, classId, page, size),
MDC.get(TraceIdFilter.MDC_KEY));
}
@PostMapping("/score")
@Operation(summary = "教师给实训报告评分",
description = "按报告 id 定位学生提交的实训报告并写入教师评分(teacher_score);返回评分后的报告信息。"
+ "报告不存在 → 404;报告不属于当前教师所属学校 → 403;评分最多 2 位小数且必须在 0-100 之间,否则 400。"
+ "写入后学生端总成绩中的实验报告成绩立即按新分数计算。")
public ApiResponse<ExperimentReportView> score(@Valid @RequestBody ScoreReportRequest request) {
CurrentUser user = currentUserService.getCurrentUser();
return ApiResponse.success(
service.score(user.getSchoolId(), request.getId(), request.getScore(), request.getComment()),
MDC.get(TraceIdFilter.MDC_KEY));
}
}

@ -0,0 +1,57 @@
package com.yau.digitalrmb.training.score.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import java.math.BigDecimal;
import java.util.List;
/**
* 学生端模块成绩汇总,一行对应一个实验模块,展示字段与成绩查询页面一致。
*/
@Getter
@Schema(description = "学生端模块成绩汇总")
public class ModuleScoreSummaryResult {
@Schema(description = "模块序号 1-6", example = "1")
private final Integer mouduleCode;
@Schema(description = "模块名称", example = "中国人民银行生成数字货币")
private final String moudule;
@Schema(description = "计分规则", example = "选错/填错一个扣 0.5 分")
private final String scoringCriteria;
@Schema(description = "选错/填错一个扣的分值(来自权重表,单位:分)", example = "0.5")
private final BigDecimal deductScore;
@Schema(description = "操作次数(含历史已删除轮次)", example = "3")
private final Integer operationCount;
@Schema(description = "当前有效操作次数(未删除记录数)", example = "1")
private final Integer currentCount;
@Schema(description = "模块权重,如 0.30 表示 30%", example = "0.30")
private final BigDecimal moduleWeight;
@Schema(description = "完成进度百分比", example = "66.67")
private final Double schedule;
@Schema(description = "最高成绩", example = "98.00")
private final Double maxScore;
@Schema(description = "最低成绩", example = "52.00")
private final Double minScore;
@Schema(description = "平均成绩,保留 2 位小数", example = "60.00")
private final Double avgScore;
@Schema(description = "该模块原始成绩明细(不含已删除记录)")
private final List<StuModuleScoreResult> details;
public ModuleScoreSummaryResult(Integer mouduleCode, String moudule, String scoringCriteria,
BigDecimal deductScore, Integer operationCount, Integer currentCount,
BigDecimal moduleWeight, Double schedule, Double maxScore, Double minScore,
Double avgScore, List<StuModuleScoreResult> details) {
this.mouduleCode = mouduleCode;
this.moudule = moudule;
this.scoringCriteria = scoringCriteria;
this.deductScore = deductScore;
this.operationCount = operationCount;
this.currentCount = currentCount;
this.moduleWeight = moduleWeight;
this.schedule = schedule;
this.maxScore = maxScore;
this.minScore = minScore;
this.avgScore = avgScore;
this.details = details;
}
}

@ -0,0 +1,36 @@
package com.yau.digitalrmb.training.score.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import java.math.BigDecimal;
/**
* 总成绩页面的一行:实验实训 / 实验报告 / 总成绩。
*
* <p>字段与前端总成绩表一一对应(项目、成绩、权重、进度、最终成绩)。
* 最终成绩 = 进度(百分比)/100 × 权重 × 成绩。
*/
@Getter
@Schema(description = "总成绩页面一行")
public class OverallScoreItem {
@Schema(description = "项目名称:实验实训 / 实验报告 / 总成绩", example = "实验实训")
private final String project;
@Schema(description = "成绩:实训为各模块最高分按模块权重加权;报告为教师评分;总成绩行无此项", example = "36.40")
private final BigDecimal score;
@Schema(description = "权重,如 0.80 表示 80%", example = "0.80")
private final BigDecimal weight;
@Schema(description = "完成进度百分比:实训为 6 个模块进度均值(缺的按 0,仍除 6);报告有提交即 100;总成绩行无此项", example = "16.67")
private final BigDecimal progress;
@Schema(description = "最终成绩 = 进度/100 × 权重 × 成绩,保留 2 位小数", example = "4.85")
private final BigDecimal finalScore;
public OverallScoreItem(String project, BigDecimal score, BigDecimal weight, BigDecimal progress,
BigDecimal finalScore) {
this.project = project;
this.score = score;
this.weight = weight;
this.progress = progress;
this.finalScore = finalScore;
}
}

@ -0,0 +1,24 @@
package com.yau.digitalrmb.training.score.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import java.math.BigDecimal;
import java.util.List;
/**
* 学生端总成绩查询结果:实验实训、实验报告、总成绩三行,及汇总后的最终总成绩。
*/
@Getter
@Schema(description = "学生端总成绩查询结果")
public class OverallScoreResult {
@Schema(description = "三行明细:实验实训 / 实验报告 / 总成绩")
private final List<OverallScoreItem> items;
@Schema(description = "最终总成绩 = 实验实训最终成绩 + 实验报告最终成绩", example = "22.85")
private final BigDecimal totalFinal;
public OverallScoreResult(List<OverallScoreItem> items, BigDecimal totalFinal) {
this.items = items;
this.totalFinal = totalFinal;
}
}

@ -0,0 +1,49 @@
package com.yau.digitalrmb.training.score.application;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
/**
* 学生模块成绩明细查询结果,字段与 stu_module_score_details 表一一对应,不返回逻辑删除标记。
*/
@Getter
@Schema(description = "学生模块成绩明细")
public class StuModuleScoreResult {
@Schema(description = "成绩明细主键")
private final String id;
@Schema(description = "实验模块名称,对应表字段 moudule", example = "中国人民银行生成数字货币")
private final String moudule;
@Schema(description = "学习项目", example = "实验实训")
private final String learningProjects;
@Schema(description = "考核项目", example = "三步流程错误次数")
private final String assessmentItems;
@Schema(description = "评分标准", example = "0.5")
private final String scoringCriteria;
@Schema(description = "完成情况,数字表示累计错误次数", example = "2")
private final String completionStatus;
@Schema(description = "项目得分", example = "88.50")
private final Double scoreProject;
@Schema(description = "用户编号")
private final String userId;
@Schema(description = "项目总分", example = "100.00")
private final Double totalScore;
@Schema(description = "模块序号", example = "3")
private final Integer serialNumber;
@Schema(description = "完成进度百分比", example = "66.67")
private final Double schedule;
public StuModuleScoreResult(StuModuleScoreDetailsEntity value) {
this.id = value.getId();
this.moudule = value.getMoudule();
this.learningProjects = value.getLearningProjects();
this.assessmentItems = value.getAssessmentItems();
this.scoringCriteria = value.getScoringCriteria();
this.completionStatus = value.getCompletionStatus();
this.scoreProject = value.getScoreProject();
this.userId = value.getUserId();
this.totalScore = value.getTotalScore();
this.serialNumber = value.getSerialNumber();
this.schedule = value.getSchedule();
}
}

@ -0,0 +1,37 @@
package com.yau.digitalrmb.training.score.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 学生端「成绩详情」:当前用户模块一~模块六各自最高成绩 + 综合分数。
* 最高成绩口径与模块成绩汇总一致:该模块全部轮次(含已逻辑删除的历史轮次)取最大值;
* 该模块没有成绩时对应字段为 null。
*/
@Getter
@AllArgsConstructor
@Schema(description = "学生端成绩详情(模块一~模块六最高成绩 + 综合分数)")
public class StudentScoreDetailResult {
@Schema(description = "模块一(中国人民银行生成数字货币)最高成绩")
private final Double module1Score;
@Schema(description = "模块二(中国人民银行发行数字货币)最高成绩")
private final Double module2Score;
@Schema(description = "模块三(开通数字货币钱包)最高成绩")
private final Double module3Score;
@Schema(description = "模块四(用户兑换数字货币)最高成绩")
private final Double module4Score;
@Schema(description = "模块五(用户支付数字货币)最高成绩")
private final Double module5Score;
@Schema(description = "模块六(数字人民币创新应用场景仿真)最高成绩")
private final Double module6Score;
@Schema(description = "综合分数:各模块最高分按模块权重加权求和(权重和为 1,缺失模块计 0),保留 2 位小数", example = "36.40")
private final Double totalScore;
}

@ -0,0 +1,49 @@
package com.yau.digitalrmb.training.score.application;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
/**
* 学生端「成绩详情」计算:当前用户模块一~模块六各取最高成绩,
* 并按各模块最高分×模块权重加权求和得到综合分数(totalScore)。
*
* <p>直接复用 {@link TrainingScoreQueryService#summary(String, String, String)} 的模块汇总结果:
* maxScore 口径一致(全部轮次含已逻辑删除取最大值),moduleWeight 来自权重表
* (本校未配置时由权重服务落默认行),与教师端「成绩详情」的综合分数完全同口径。
*/
@Service
public class StudentScoreDetailService {
private final TrainingScoreQueryService scoreQueryService;
public StudentScoreDetailService(TrainingScoreQueryService scoreQueryService) {
this.scoreQueryService = scoreQueryService;
}
/**
* 查询当前用户成绩详情。
*
* @param userId 当前登录用户编号
* @param schoolId 当前用户所属学校编号(用于读取本校模块权重,未配置时落默认)
*/
public StudentScoreDetailResult detail(String userId, String schoolId) {
List<ModuleScoreSummaryResult> modules = scoreQueryService.summary(userId, schoolId, null);
Double[] maxScores = new Double[TrainingModules.MODULE_COUNT];
BigDecimal total = BigDecimal.ZERO;
for (ModuleScoreSummaryResult m : modules) {
Integer code = m.getMouduleCode();
if (code == null || code < 1 || code > TrainingModules.MODULE_COUNT || m.getMaxScore() == null) {
continue;
}
maxScores[code - 1] = m.getMaxScore();
BigDecimal weight = m.getModuleWeight() == null ? BigDecimal.ZERO : m.getModuleWeight();
total = total.add(weight.multiply(BigDecimal.valueOf(m.getMaxScore())));
}
return new StudentScoreDetailResult(
maxScores[0], maxScores[1], maxScores[2], maxScores[3], maxScores[4], maxScores[5],
total.setScale(2, RoundingMode.HALF_UP).doubleValue());
}
}

@ -0,0 +1,26 @@
package com.yau.digitalrmb.training.score.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import java.math.BigDecimal;
/**
* 教师端单个模块权重项。
*/
@Getter
@Schema(description = "模块权重项")
public class TrainingModuleWeightItem {
@Schema(description = "模块名称", example = "中国人民银行生成数字货币")
private final String moudule;
@Schema(description = "模块序号 1-6", example = "1")
private final Integer mouduleCode;
@Schema(description = "模块权重,如 0.30 表示 30%", example = "0.30")
private final BigDecimal weight;
public TrainingModuleWeightItem(String moudule, Integer mouduleCode, BigDecimal weight) {
this.moudule = moudule;
this.mouduleCode = mouduleCode;
this.weight = weight;
}
}

@ -0,0 +1,31 @@
package com.yau.digitalrmb.training.score.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import java.math.BigDecimal;
import java.util.List;
/**
* 教师端模块权重查询结果:记录编号 + 扣分值 + 6 个模块权重。
*/
@Getter
@Schema(description = "模块权重配置")
public class TrainingModuleWeightResult {
@Schema(description = "配置记录编号,修改接口需回传该值", example = "3f2a...-... ")
private final String id;
@Schema(description = "选错/填错一个扣分", example = "0.50")
private final BigDecimal deductScore;
@Schema(description = "6 个模块权重")
private final List<TrainingModuleWeightItem> modules;
@Schema(description = "是否为本校已有配置;false 表示本次查询时刚落库一条默认配置")
private final boolean configured;
public TrainingModuleWeightResult(String id, BigDecimal deductScore, List<TrainingModuleWeightItem> modules,
boolean configured) {
this.id = id;
this.deductScore = deductScore;
this.modules = modules;
this.configured = configured;
}
}

@ -0,0 +1,81 @@
package com.yau.digitalrmb.training.score.application;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 实验模块常量:模块序号、标准名称、成绩表模块名与默认权重的映射。
* 模块名称与权重设置页面保持一致,默认权重合计 100%。
*/
public final class TrainingModules {
public static final int MODULE_COUNT = 6;
public static final BigDecimal DEFAULT_DEDUCT_SCORE = new BigDecimal("0.50");
public static final BigDecimal DEFAULT_TRAINING_WEIGHT = new BigDecimal("0.80");
public static final BigDecimal DEFAULT_REPORT_WEIGHT = new BigDecimal("0.20");
/** 模块序号 → 设置页标准模块名称。 */
private static final Map<Integer, String> STANDARD_NAMES = new LinkedHashMap<Integer, String>();
/** 模块序号 → 成绩明细表 moudule 字段实际记录的模块名。 */
private static final Map<Integer, String> SCORE_NAMES = new LinkedHashMap<Integer, String>();
/** 模块序号 → 默认权重。 */
private static final Map<Integer, BigDecimal> DEFAULT_WEIGHTS = new LinkedHashMap<Integer, BigDecimal>();
static {
STANDARD_NAMES.put(1, "中国人民银行生成数字货币");
STANDARD_NAMES.put(2, "中国人民银行发行数字货币");
STANDARD_NAMES.put(3, "开通数字货币钱包");
STANDARD_NAMES.put(4, "用户兑换数字货币");
STANDARD_NAMES.put(5, "用户支付数字货币");
STANDARD_NAMES.put(6, "数字人民币创新应用场景仿真");
SCORE_NAMES.put(1, "中国人民银行生成数字货币");
SCORE_NAMES.put(2, "发行数字货币");
SCORE_NAMES.put(3, "开通数字货币钱包");
SCORE_NAMES.put(4, "数字货币兑换");
SCORE_NAMES.put(5, "用户支付数字货币");
SCORE_NAMES.put(6, "数字人民币创新应用场景仿真");
DEFAULT_WEIGHTS.put(1, new BigDecimal("0.30"));
DEFAULT_WEIGHTS.put(2, new BigDecimal("0.25"));
DEFAULT_WEIGHTS.put(3, new BigDecimal("0.10"));
DEFAULT_WEIGHTS.put(4, new BigDecimal("0.10"));
DEFAULT_WEIGHTS.put(5, new BigDecimal("0.10"));
DEFAULT_WEIGHTS.put(6, new BigDecimal("0.15"));
}
private TrainingModules() {
}
public static List<Integer> codes() {
return Collections.unmodifiableList(Arrays.asList(1, 2, 3, 4, 5, 6));
}
public static String standardName(int code) {
return STANDARD_NAMES.get(code);
}
public static String scoreName(int code) {
return SCORE_NAMES.get(code);
}
/** 成绩表模块名 → 模块序号;未知模块名返回 null。 */
public static Integer codeOfScoreName(String scoreName) {
if (scoreName == null) {
return null;
}
for (Map.Entry<Integer, String> entry : SCORE_NAMES.entrySet()) {
if (entry.getValue().equals(scoreName)) {
return entry.getKey();
}
}
return null;
}
public static Map<Integer, BigDecimal> defaultWeights() {
return Collections.unmodifiableMap(DEFAULT_WEIGHTS);
}
}

@ -0,0 +1,96 @@
package com.yau.digitalrmb.training.score.application;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yau.digitalrmb.training.experimentreport.infrastructure.ExperimentReportEntity;
import com.yau.digitalrmb.training.experimentreport.infrastructure.ExperimentReportMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
/**
* 学生端总成绩计算:
* <ul>
* <li>实验实训成绩 = 各模块最高分按其模块权重加权之和(缺失模块计 0);</li>
* <li>实验实训进度 = 6 个模块进度相加 ÷ 6(缺失模块计 0,仍除 6);</li>
* <li>实验报告成绩 = 该学生唯一报告记录的教师评分(未评分计 0);</li>
* <li>实验报告进度 = 有提交记录即 100%,无则 0;</li>
* <li>各自最终成绩 = 进度(百分比)/100 × 权重 × 成绩;</li>
* <li>总成绩 = 实验实训最终成绩 + 实验报告最终成绩。</li>
* </ul>
* 权重(实验实训/报告)取自总成绩权重表,本校未配置时落默认 0.80/0.20。
*/
@Service
public class TrainingOverallScoreService {
private final TrainingScoreQueryService scoreQueryService;
private final TrainingWeightService weightService;
private final ExperimentReportMapper reportMapper;
public TrainingOverallScoreService(TrainingScoreQueryService scoreQueryService,
TrainingWeightService weightService,
ExperimentReportMapper reportMapper) {
this.scoreQueryService = scoreQueryService;
this.weightService = weightService;
this.reportMapper = reportMapper;
}
@Transactional
public OverallScoreResult overall(String userId, String schoolId) {
List<ModuleScoreSummaryResult> modules = scoreQueryService.summary(userId, schoolId, null);
// 总成绩权重(实验实训 / 实验报告),本校未配置则落默认
BigDecimal trainingWeight;
BigDecimal reportWeight;
if (schoolId != null && !schoolId.trim().isEmpty()) {
TrainingSchoolWeightResult sw = weightService.schoolWeight(schoolId);
trainingWeight = sw.getTrainingWeight();
reportWeight = sw.getReportWeight();
} else {
trainingWeight = TrainingModules.DEFAULT_TRAINING_WEIGHT;
reportWeight = TrainingModules.DEFAULT_REPORT_WEIGHT;
}
// 实验实训:成绩按模块权重加权,进度为 6 模块均值(缺失计 0)
BigDecimal trainingScore = BigDecimal.ZERO;
BigDecimal progressSum = BigDecimal.ZERO;
for (ModuleScoreSummaryResult m : modules) {
BigDecimal moduleWeight = m.getModuleWeight() == null ? BigDecimal.ZERO : m.getModuleWeight();
BigDecimal max = m.getMaxScore() == null ? BigDecimal.ZERO : BigDecimal.valueOf(m.getMaxScore());
trainingScore = trainingScore.add(moduleWeight.multiply(max));
BigDecimal sch = m.getSchedule() == null ? BigDecimal.ZERO : BigDecimal.valueOf(m.getSchedule());
progressSum = progressSum.add(sch);
}
BigDecimal trainingScoreR2 = trainingScore.setScale(2, RoundingMode.HALF_UP);
// 6 个模块进度相加 /6(缺的按 0,仍除 6)
BigDecimal trainingProgress = progressSum
.divide(BigDecimal.valueOf(TrainingModules.MODULE_COUNT), 2, RoundingMode.HALF_UP);
BigDecimal trainingFinal = trainingProgress
.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)
.multiply(trainingWeight).multiply(trainingScoreR2)
.setScale(2, RoundingMode.HALF_UP);
// 实验报告:唯一记录,有提交即进度 100%,成绩取教师评分(未评分计 0)
ExperimentReportEntity report = reportMapper.selectOne(new LambdaQueryWrapper<ExperimentReportEntity>()
.eq(ExperimentReportEntity::getUserId, userId)
.eq(ExperimentReportEntity::getDeleted, false)
.last("LIMIT 1"));
BigDecimal reportProgress = report != null ? BigDecimal.valueOf(100) : BigDecimal.ZERO;
BigDecimal reportScore = (report != null && report.getTeacherScore() != null)
? report.getTeacherScore() : BigDecimal.ZERO;
BigDecimal reportFinal = reportProgress
.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)
.multiply(reportWeight).multiply(reportScore)
.setScale(2, RoundingMode.HALF_UP);
BigDecimal totalFinal = trainingFinal.add(reportFinal).setScale(2, RoundingMode.HALF_UP);
List<OverallScoreItem> items = new ArrayList<OverallScoreItem>();
items.add(new OverallScoreItem("实验实训", trainingScoreR2, trainingWeight, trainingProgress, trainingFinal));
items.add(new OverallScoreItem("实验报告", reportScore, reportWeight, reportProgress, reportFinal));
items.add(new OverallScoreItem("总成绩", null, BigDecimal.ONE, null, totalFinal));
return new OverallScoreResult(items, totalFinal);
}
}

@ -0,0 +1,30 @@
package com.yau.digitalrmb.training.score.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import java.math.BigDecimal;
/**
* 教师端总成绩权重查询结果:记录编号 + 实验实训权重比 + 实训报告权重比。
*/
@Getter
@Schema(description = "总成绩权重配置")
public class TrainingSchoolWeightResult {
@Schema(description = "配置记录编号,修改接口需回传该值", example = "3f2a...-... ")
private final String id;
@Schema(description = "实验实训权重比,如 0.80 表示 80%", example = "0.80")
private final BigDecimal trainingWeight;
@Schema(description = "实训报告权重比,如 0.20 表示 20%", example = "0.20")
private final BigDecimal reportWeight;
@Schema(description = "是否为本校已有配置;false 表示本次查询时刚落库一条默认配置")
private final boolean configured;
public TrainingSchoolWeightResult(String id, BigDecimal trainingWeight, BigDecimal reportWeight,
boolean configured) {
this.id = id;
this.trainingWeight = trainingWeight;
this.reportWeight = reportWeight;
this.configured = configured;
}
}

@ -0,0 +1,122 @@
package com.yau.digitalrmb.training.score.application;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 按当前登录用户查询统一成绩明细表 stu_module_score_details,
* 并按模块汇总展示计分规则、操作次数、模块权重、进度和最高/最低/平均成绩。
*
* 模块权重与扣分值统一从权重表(training_module_weight)读取:本校尚无配置时,
* 由 TrainingWeightService 落一条默认配置后再返回,保证学生端成绩查询结果始终有权重与扣分数据。
*/
@Service
public class TrainingScoreQueryService {
private final StuModuleScoreDetailsMapper scoreDetailsMapper;
private final TrainingWeightService weightService;
public TrainingScoreQueryService(StuModuleScoreDetailsMapper scoreDetailsMapper,
TrainingWeightService weightService) {
this.scoreDetailsMapper = scoreDetailsMapper;
this.weightService = weightService;
}
/**
* 按模块汇总查询当前用户成绩。
*
* @param userId 当前登录用户编号
* @param schoolId 当前登录用户所属学校编号,用于读取(必要时落库)模块权重与扣分值
* @param module 模块名称;支持设置页标准名或成绩表模块名,为空时查询全部模块
* @return 模块成绩汇总列表。<b>明细</b>(details)不含已逻辑删除的数据;
* 但<b>最高/最低/平均/操作次数</b>均包含已删除(历史轮次)的记录。
*/
@Transactional
public List<ModuleScoreSummaryResult> summary(String userId, String schoolId, String module) {
if (userId == null || userId.trim().isEmpty()) {
return Collections.emptyList();
}
// 模块权重与扣分值:优先从权重表读取;本校尚无配置时由权重服务落一条默认配置
Map<Integer, BigDecimal> weights;
BigDecimal deductScore;
if (schoolId != null && !schoolId.trim().isEmpty()) {
TrainingModuleWeightResult weightResult = weightService.moduleWeight(schoolId);
weights = new HashMap<Integer, BigDecimal>(TrainingModules.MODULE_COUNT);
for (TrainingModuleWeightItem item : weightResult.getModules()) {
weights.put(item.getMouduleCode(), item.getWeight());
}
deductScore = weightResult.getDeductScore();
} else {
weights = TrainingModules.defaultWeights();
deductScore = TrainingModules.DEFAULT_DEDUCT_SCORE;
}
String scoringCriteria = "选错/填错一个扣 " + deductScore.stripTrailingZeros().toPlainString() + " 分";
List<ModuleScoreSummaryResult> result = new ArrayList<ModuleScoreSummaryResult>();
for (Integer code : TrainingModules.codes()) {
if (module != null && !module.trim().isEmpty()
&& !matchesModule(code, module.trim())) {
continue;
}
result.add(summarize(userId, code, scoringCriteria, deductScore, weights.get(code)));
}
return result;
}
private ModuleScoreSummaryResult summarize(String userId, Integer code, String scoringCriteria,
BigDecimal deductScore, BigDecimal moduleWeight) {
String scoreName = TrainingModules.scoreName(code);
int operationCount = scoreDetailsMapper.countAllIncludingDeleted(userId, scoreName);
List<StuModuleScoreDetailsEntity> rows = scoreDetailsMapper.selectList(
new LambdaQueryWrapper<StuModuleScoreDetailsEntity>()
.eq(StuModuleScoreDetailsEntity::getUserId, userId)
.eq(StuModuleScoreDetailsEntity::getMoudule, scoreName)
.orderByAsc(StuModuleScoreDetailsEntity::getSerialNumber));
// 最高/最低/平均:当前用户本人在该模块的全部轮次(含已删除历史轮次)的统计
List<StuModuleScoreDetailsEntity> allRows = scoreDetailsMapper.selectAllIncludingDeleted(userId, scoreName);
Double maxScore = null;
Double minScore = null;
double scoreSum = 0.0;
int scoredCount = 0;
for (StuModuleScoreDetailsEntity row : allRows) {
if (row.getScoreProject() != null) {
double score = row.getScoreProject();
maxScore = maxScore == null || score > maxScore ? score : maxScore;
minScore = minScore == null || score < minScore ? score : minScore;
scoreSum += score;
scoredCount++;
}
}
Double avgScore = scoredCount == 0 ? null
: BigDecimal.valueOf(scoreSum)
.divide(BigDecimal.valueOf(scoredCount), 2, RoundingMode.HALF_UP)
.doubleValue();
List<StuModuleScoreResult> details = new ArrayList<StuModuleScoreResult>(rows.size());
Double schedule = null;
for (StuModuleScoreDetailsEntity row : rows) {
details.add(new StuModuleScoreResult(row));
schedule = row.getSchedule() == null ? schedule : row.getSchedule();
}
return new ModuleScoreSummaryResult(code, TrainingModules.standardName(code), scoringCriteria,
deductScore, operationCount, rows.size(), moduleWeight, schedule, maxScore, minScore,
avgScore, details);
}
private boolean matchesModule(Integer code, String module) {
return module.equals(TrainingModules.standardName(code))
|| module.equals(TrainingModules.scoreName(code));
}
}

@ -0,0 +1,215 @@
package com.yau.digitalrmb.training.score.application;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import com.yau.digitalrmb.training.score.infrastructure.TrainingModuleWeightEntity;
import com.yau.digitalrmb.training.score.infrastructure.TrainingModuleWeightMapper;
import com.yau.digitalrmb.training.score.infrastructure.TrainingSchoolScoreWeightEntity;
import com.yau.digitalrmb.training.score.infrastructure.TrainingSchoolScoreWeightMapper;
import com.yau.digitalrmb.training.score.interfaces.dto.SaveModuleWeightsRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/**
* 教师端成绩权重配置:总成绩权重(实验实训 + 实训报告)、6 个模块权重与扣分值。
* 未配置时返回默认值;保存时校验合计与精度。
*/
@Service
public class TrainingWeightService {
private final TrainingSchoolScoreWeightMapper schoolWeightMapper;
private final TrainingModuleWeightMapper moduleWeightMapper;
public TrainingWeightService(TrainingSchoolScoreWeightMapper schoolWeightMapper,
TrainingModuleWeightMapper moduleWeightMapper) {
this.schoolWeightMapper = schoolWeightMapper;
this.moduleWeightMapper = moduleWeightMapper;
}
/**
* 查询总成绩权重;本校尚无配置时,按默认值 0.80 / 0.20 落库一条记录后返回。
*/
@Transactional
public TrainingSchoolWeightResult schoolWeight(String schoolId) {
requireSchool(schoolId);
TrainingSchoolScoreWeightEntity config = schoolConfig(schoolId);
boolean existing = config != null;
if (!existing) {
LocalDateTime now = LocalDateTime.now();
config = new TrainingSchoolScoreWeightEntity();
config.setId(UUID.randomUUID().toString());
config.setSchoolId(schoolId);
config.setTrainingWeight(TrainingModules.DEFAULT_TRAINING_WEIGHT);
config.setReportWeight(TrainingModules.DEFAULT_REPORT_WEIGHT);
config.setCreatedAt(now);
config.setUpdatedAt(now);
config.setDeleted(false);
schoolWeightMapper.insert(config);
}
return new TrainingSchoolWeightResult(config.getId(), config.getTrainingWeight(),
config.getReportWeight(), existing);
}
/** 按 id 修改总成绩权重:实验实训权重比 + 实训报告权重比必须等于 1。 */
@Transactional
public void saveSchoolWeight(String schoolId, String id, BigDecimal trainingWeight,
BigDecimal reportWeight) {
requireSchool(schoolId);
if (trainingWeight == null || reportWeight == null) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "权重比不能为空");
}
if (trainingWeight.add(reportWeight).compareTo(BigDecimal.ONE) != 0) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"实验实训权重比与实训报告权重比之和必须等于 1");
}
TrainingSchoolScoreWeightEntity config = requireSchoolConfig(schoolId, id);
config.setTrainingWeight(trainingWeight);
config.setReportWeight(reportWeight);
config.setUpdatedAt(LocalDateTime.now());
schoolWeightMapper.updateById(config);
}
/**
* 查询模块权重与扣分值;本校尚无配置时,按默认权重与默认扣分落库一条记录后返回。
*/
@Transactional
public TrainingModuleWeightResult moduleWeight(String schoolId) {
requireSchool(schoolId);
TrainingModuleWeightEntity config = moduleConfig(schoolId);
boolean existing = config != null;
if (!existing) {
LocalDateTime now = LocalDateTime.now();
config = new TrainingModuleWeightEntity();
config.setId(UUID.randomUUID().toString());
config.setSchoolId(schoolId);
for (Integer code : TrainingModules.codes()) {
config.setWeight(code, TrainingModules.defaultWeights().get(code));
}
config.setDeductScore(TrainingModules.DEFAULT_DEDUCT_SCORE);
config.setCreatedAt(now);
config.setUpdatedAt(now);
config.setDeleted(false);
moduleWeightMapper.insert(config);
}
List<TrainingModuleWeightItem> items = new ArrayList<TrainingModuleWeightItem>();
for (Integer code : TrainingModules.codes()) {
BigDecimal weight = config.weightOf(code);
items.add(new TrainingModuleWeightItem(TrainingModules.standardName(code), code,
weight == null ? TrainingModules.defaultWeights().get(code) : weight));
}
BigDecimal deductScore = config.getDeductScore() == null
? TrainingModules.DEFAULT_DEDUCT_SCORE : config.getDeductScore();
return new TrainingModuleWeightResult(config.getId(), deductScore, items, existing);
}
/**
* 按 id 修改模块权重与扣分值:必须一次提交 6 个模块,序号 1-6 不重复、名称与标准名一致、
* 权重最多 2 位小数且合计等于 1。
*/
@Transactional
public void saveModuleWeights(String schoolId, String id,
List<SaveModuleWeightsRequest.ModuleWeightEntry> entries,
BigDecimal deductScore) {
requireSchool(schoolId);
TrainingModuleWeightEntity config = requireModuleConfig(schoolId, id);
if (deductScore == null) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "扣分值不能为空");
}
if (deductScore.scale() > 2 || deductScore.compareTo(BigDecimal.ZERO) < 0
|| deductScore.compareTo(new BigDecimal("100")) > 0) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"扣分值最多保留 2 位小数且必须在 0 到 100 之间");
}
if (entries == null || entries.size() != TrainingModules.MODULE_COUNT) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"必须一次性保存全部 " + TrainingModules.MODULE_COUNT + " 个模块权重");
}
Set<Integer> codes = new HashSet<Integer>();
BigDecimal sum = BigDecimal.ZERO;
for (SaveModuleWeightsRequest.ModuleWeightEntry entry : entries) {
Integer code = entry.getMouduleCode();
if (code == null || code < 1 || code > TrainingModules.MODULE_COUNT) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "模块序号必须在 1-6 之间");
}
if (!codes.add(code)) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "模块序号不能重复");
}
String standardName = TrainingModules.standardName(code);
if (!standardName.equals(entry.getMoudule())) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"模块序号 " + code + " 对应的模块名称必须为" + standardName);
}
BigDecimal weight = entry.getWeight();
if (weight == null || weight.scale() > 2) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "模块权重最多保留 2 位小数");
}
if (weight.compareTo(BigDecimal.ZERO) < 0 || weight.compareTo(BigDecimal.ONE) > 0) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "模块权重必须在 0 到 1 之间");
}
sum = sum.add(weight);
}
if (sum.compareTo(BigDecimal.ONE) != 0) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "6 个模块权重合计必须等于 1");
}
for (SaveModuleWeightsRequest.ModuleWeightEntry entry : entries) {
config.setWeight(entry.getMouduleCode(), entry.getWeight());
}
config.setDeductScore(deductScore);
config.setUpdatedAt(LocalDateTime.now());
moduleWeightMapper.updateById(config);
}
/** 按 id 取总成绩权重配置:不存在报 404,不属于本校报 403。 */
private TrainingSchoolScoreWeightEntity requireSchoolConfig(String schoolId, String id) {
TrainingSchoolScoreWeightEntity config = schoolWeightMapper.selectById(id);
if (config == null || Boolean.TRUE.equals(config.getDeleted())) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "总成绩权重配置不存在");
}
if (!schoolId.equals(config.getSchoolId())) {
throw new BusinessException(ErrorCode.FORBIDDEN, "无权修改其他学校的权重配置");
}
return config;
}
/** 按 id 取模块权重配置:不存在报 404,不属于本校报 403。 */
private TrainingModuleWeightEntity requireModuleConfig(String schoolId, String id) {
TrainingModuleWeightEntity config = moduleWeightMapper.selectById(id);
if (config == null || Boolean.TRUE.equals(config.getDeleted())) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "模块权重配置不存在");
}
if (!schoolId.equals(config.getSchoolId())) {
throw new BusinessException(ErrorCode.FORBIDDEN, "无权修改其他学校的权重配置");
}
return config;
}
private TrainingSchoolScoreWeightEntity schoolConfig(String schoolId) {
return schoolWeightMapper.selectOne(new LambdaQueryWrapper<TrainingSchoolScoreWeightEntity>()
.eq(TrainingSchoolScoreWeightEntity::getSchoolId, schoolId)
.eq(TrainingSchoolScoreWeightEntity::getDeleted, false)
.last("LIMIT 1"));
}
private TrainingModuleWeightEntity moduleConfig(String schoolId) {
return moduleWeightMapper.selectOne(new LambdaQueryWrapper<TrainingModuleWeightEntity>()
.eq(TrainingModuleWeightEntity::getSchoolId, schoolId)
.eq(TrainingModuleWeightEntity::getDeleted, false)
.last("LIMIT 1"));
}
private void requireSchool(String schoolId) {
if (schoolId == null || schoolId.trim().isEmpty()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"当前用户缺少学校信息,无法配置成绩权重");
}
}
}

@ -0,0 +1,104 @@
package com.yau.digitalrmb.training.score.infrastructure;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 教师端模块权重配置:一校一行,6 个模块权重横向存储,并保存扣分值。
*/
@Getter
@Setter
@TableName("training_module_weight")
public class TrainingModuleWeightEntity {
@TableId(type = IdType.ASSIGN_UUID)
private String id;
@TableField("school_id")
private String schoolId;
/** 模块一:中国人民银行生成数字货币 */
@TableField("module1_weight")
private BigDecimal module1Weight;
/** 模块二:中国人民银行发行数字货币 */
@TableField("module2_weight")
private BigDecimal module2Weight;
/** 模块三:开通数字货币钱包 */
@TableField("module3_weight")
private BigDecimal module3Weight;
/** 模块四:用户兑换数字货币 */
@TableField("module4_weight")
private BigDecimal module4Weight;
/** 模块五:用户支付数字货币 */
@TableField("module5_weight")
private BigDecimal module5Weight;
/** 模块六:数字人民币创新应用场景仿真 */
@TableField("module6_weight")
private BigDecimal module6Weight;
@TableField("deduct_score")
private BigDecimal deductScore;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Boolean deleted;
/** 按模块序号 1-6 读取权重。 */
public BigDecimal weightOf(int moduleCode) {
switch (moduleCode) {
case 1:
return module1Weight;
case 2:
return module2Weight;
case 3:
return module3Weight;
case 4:
return module4Weight;
case 5:
return module5Weight;
case 6:
return module6Weight;
default:
return null;
}
}
/** 按模块序号 1-6 写入权重,序号非法时直接抛错。 */
public void setWeight(int moduleCode, BigDecimal weight) {
switch (moduleCode) {
case 1:
this.module1Weight = weight;
break;
case 2:
this.module2Weight = weight;
break;
case 3:
this.module3Weight = weight;
break;
case 4:
this.module4Weight = weight;
break;
case 5:
this.module5Weight = weight;
break;
case 6:
this.module6Weight = weight;
break;
default:
throw new IllegalArgumentException("模块序号必须在 1-6 之间:" + moduleCode);
}
}
}

@ -0,0 +1,8 @@
package com.yau.digitalrmb.training.score.infrastructure;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface TrainingModuleWeightMapper extends BaseMapper<TrainingModuleWeightEntity> {
}

@ -0,0 +1,34 @@
package com.yau.digitalrmb.training.score.infrastructure;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("training_school_score_weight")
public class TrainingSchoolScoreWeightEntity {
@TableId(type = IdType.ASSIGN_UUID)
private String id;
@TableField("school_id")
private String schoolId;
@TableField("training_weight")
private BigDecimal trainingWeight;
@TableField("report_weight")
private BigDecimal reportWeight;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Boolean deleted;
}

@ -0,0 +1,8 @@
package com.yau.digitalrmb.training.score.infrastructure;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface TrainingSchoolScoreWeightMapper extends BaseMapper<TrainingSchoolScoreWeightEntity> {
}

@ -0,0 +1,56 @@
package com.yau.digitalrmb.training.score.interfaces.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import javax.validation.Valid;
import javax.validation.constraints.DecimalMax;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.Digits;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.util.List;
/**
* 教师端修改 6 个模块权重的请求体;按 id 定位记录,扣分值随模块权重一起保存。
*/
@Getter
@Setter
@Schema(description = "修改模块权重请求")
public class SaveModuleWeightsRequest {
@Schema(description = "配置记录编号,取自查询接口返回的 id")
@NotBlank(message = "配置记录编号不能为空")
private String id;
@Schema(description = "选错/填错一个扣分", example = "0.50")
@NotNull(message = "扣分值不能为空")
@Digits(integer = 3, fraction = 2, message = "扣分值最多保留 2 位小数")
@DecimalMin(value = "0", message = "扣分值不能小于 0")
@DecimalMax(value = "100", message = "扣分值不能大于 100")
private BigDecimal deductScore;
@Schema(description = "模块权重列表,必须包含全部 6 个模块")
@NotEmpty(message = "模块权重列表不能为空")
@Valid
private List<ModuleWeightEntry> modules;
@Getter
@Setter
@Schema(description = "单个模块权重项")
public static class ModuleWeightEntry {
@Schema(description = "模块名称", example = "中国人民银行生成数字货币")
@NotNull(message = "模块名称不能为空")
private String moudule;
@Schema(description = "模块序号 1-6", example = "1")
@NotNull(message = "模块序号不能为空")
private Integer mouduleCode;
@Schema(description = "模块权重,两位小数", example = "0.30")
@NotNull(message = "模块权重不能为空")
private BigDecimal weight;
}
}

@ -0,0 +1,38 @@
package com.yau.digitalrmb.training.score.interfaces.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import javax.validation.constraints.DecimalMax;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.Digits;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.math.BigDecimal;
/**
* 教师端修改总成绩权重的请求体:按 id 定位记录,实验实训权重 + 实训报告权重必须等于 1。
*/
@Getter
@Setter
@Schema(description = "修改总成绩权重请求")
public class SaveSchoolWeightRequest {
@Schema(description = "配置记录编号,取自查询接口返回的 id")
@NotBlank(message = "配置记录编号不能为空")
private String id;
@Schema(description = "实验实训权重比", example = "0.80")
@NotNull(message = "实验实训权重比不能为空")
@Digits(integer = 1, fraction = 2, message = "实验实训权重比最多保留 2 位小数")
@DecimalMin(value = "0", message = "实验实训权重比不能小于 0")
@DecimalMax(value = "1", message = "实验实训权重比不能大于 1")
private BigDecimal trainingWeight;
@Schema(description = "实训报告权重比", example = "0.20")
@NotNull(message = "实训报告权重比不能为空")
@Digits(integer = 1, fraction = 2, message = "实训报告权重比最多保留 2 位小数")
@DecimalMin(value = "0", message = "实训报告权重比不能小于 0")
@DecimalMax(value = "1", message = "实训报告权重比不能大于 1")
private BigDecimal reportWeight;
}

@ -0,0 +1,85 @@
package com.yau.digitalrmb.training.score.interfaces.rest;
import com.yau.digitalrmb.security.application.CurrentUser;
import com.yau.digitalrmb.security.application.CurrentUserService;
import com.yau.digitalrmb.shared.api.ApiResponse;
import com.yau.digitalrmb.shared.web.TraceIdFilter;
import com.yau.digitalrmb.training.score.application.ModuleScoreSummaryResult;
import com.yau.digitalrmb.training.score.application.OverallScoreResult;
import com.yau.digitalrmb.training.score.application.StudentScoreDetailResult;
import com.yau.digitalrmb.training.score.application.StudentScoreDetailService;
import com.yau.digitalrmb.training.score.application.TrainingOverallScoreService;
import com.yau.digitalrmb.training.score.application.TrainingScoreQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.MDC;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/v1/training-scores")
@Tag(name = "实训成绩查询", description = "按当前登录用户查询 stu_module_score_details,按模块汇总模块名称、计分规则、操作次数、模块权重、完成进度与最高/最低/平均成绩")
public class TrainingScoreController {
private final TrainingScoreQueryService service;
private final TrainingOverallScoreService overallService;
private final StudentScoreDetailService detailService;
private final CurrentUserService currentUserService;
public TrainingScoreController(TrainingScoreQueryService service,
TrainingOverallScoreService overallService,
StudentScoreDetailService detailService,
CurrentUserService currentUserService) {
this.service = service;
this.overallService = overallService;
this.detailService = detailService;
this.currentUserService = currentUserService;
}
@GetMapping
@Operation(summary = "查询当前用户各模块成绩",
description = "不传 moudule 时固定返回 6 个模块(无数据的模块同样返回,统计为 null、明细为空),"
+ "传 moudule 时只返回该模块(支持设置页标准名或成绩表模块名)。"
+ "maxScore/minScore/avgScore 为当前用户本人在该模块全部轮次(含已逻辑删除的历史轮次)的成绩统计,"
+ "operationCount 口径相同;currentCount 与 details 只含未删除记录。")
public ApiResponse<List<ModuleScoreSummaryResult>> scores(
@Parameter(description = "模块名称,留空查询全部模块", example = "中国人民银行生成数字货币")
@RequestParam(value = "moudule", required = false) String moudule,
@Parameter(description = "moudule 的别名参数,二者传其一即可", hidden = true)
@RequestParam(value = "module", required = false) String module) {
CurrentUser user = currentUserService.getCurrentUser();
String target = moudule == null || moudule.trim().isEmpty() ? module : moudule;
return ApiResponse.success(
service.summary(user.getUserId(), user.getSchoolId(), target),
MDC.get(TraceIdFilter.MDC_KEY));
}
@GetMapping("/overall")
@Operation(summary = "查询当前用户总成绩",
description = "返回实验实训、实验报告、总成绩三行。实验实训成绩为各模块最高分按模块权重加权,"
+ "进度为 6 个模块进度均值(缺失模块计 0,仍除 6);实验报告进度有提交即 100%,成绩取教师评分;"
+ "最终成绩 = 进度/100 × 权重 × 成绩,总成绩 = 实训最终 + 报告最终。")
public ApiResponse<OverallScoreResult> overall() {
CurrentUser user = currentUserService.getCurrentUser();
return ApiResponse.success(
overallService.overall(user.getUserId(), user.getSchoolId()),
MDC.get(TraceIdFilter.MDC_KEY));
}
@GetMapping("/details")
@Operation(summary = "查询当前用户成绩详情",
description = "返回当前用户模块一~模块六各自最高成绩与综合分数。最高成绩口径与模块成绩汇总一致:"
+ "该模块全部轮次(含已逻辑删除的历史轮次)取最大值,没有成绩的模块为 null;"
+ "综合分数 = Σ(模块最高分 × 模块权重)(权重取自本校权重配置,未配置时落默认),"
+ "缺失模块计 0,保留 2 位小数,与教师端成绩详情同口径。")
public ApiResponse<StudentScoreDetailResult> details() {
CurrentUser user = currentUserService.getCurrentUser();
return ApiResponse.success(
detailService.detail(user.getUserId(), user.getSchoolId()),
MDC.get(TraceIdFilter.MDC_KEY));
}
}

@ -0,0 +1,79 @@
package com.yau.digitalrmb.training.score.interfaces.rest;
import com.yau.digitalrmb.security.application.CurrentUserService;
import com.yau.digitalrmb.shared.api.ApiResponse;
import com.yau.digitalrmb.shared.web.TraceIdFilter;
import com.yau.digitalrmb.training.score.application.TrainingModuleWeightResult;
import com.yau.digitalrmb.training.score.application.TrainingSchoolWeightResult;
import com.yau.digitalrmb.training.score.application.TrainingWeightService;
import com.yau.digitalrmb.training.score.interfaces.dto.SaveModuleWeightsRequest;
import com.yau.digitalrmb.training.score.interfaces.dto.SaveSchoolWeightRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.MDC;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
@RestController
@RequestMapping("/api/v1/training-weights")
@Tag(name = "成绩权重配置", description = "教师端总成绩权重与模块权重配置,按学校隔离")
public class TrainingWeightController {
private final TrainingWeightService service;
private final CurrentUserService currentUserService;
public TrainingWeightController(TrainingWeightService service, CurrentUserService currentUserService) {
this.service = service;
this.currentUserService = currentUserService;
}
@GetMapping("/school")
@Operation(summary = "查询总成绩权重",
description = "返回记录编号 id、实验实训权重比与实训报告权重比;本校首次查询时按默认值 0.80、0.20 "
+ "落库一条记录,configured=false 表示这条默认记录是本次刚生成的。")
public ApiResponse<TrainingSchoolWeightResult> schoolWeight() {
return ApiResponse.success(service.schoolWeight(schoolId()), traceId());
}
@PostMapping("/school")
@Operation(summary = "修改总成绩权重",
description = "按 id 定位配置记录;实验实训权重比与实训报告权重比之和必须等于 1,均最多 2 位小数。")
public ApiResponse<TrainingSchoolWeightResult> saveSchoolWeight(
@Valid @RequestBody SaveSchoolWeightRequest request) {
service.saveSchoolWeight(schoolId(), request.getId(), request.getTrainingWeight(),
request.getReportWeight());
return ApiResponse.success(service.schoolWeight(schoolId()), traceId());
}
@GetMapping("/modules")
@Operation(summary = "查询模块权重与扣分值",
description = "返回记录编号 id、扣分值 deductScore 与 6 个实验模块的序号、名称、权重;"
+ "本校首次查询时按默认值(扣分 0.50,权重 30%、25%、10%、10%、10%、15%)落库一条记录,"
+ "configured=false 表示这条默认记录是本次刚生成的。")
public ApiResponse<TrainingModuleWeightResult> moduleWeights() {
return ApiResponse.success(service.moduleWeight(schoolId()), traceId());
}
@PostMapping("/modules")
@Operation(summary = "修改模块权重与扣分值",
description = "按 id 定位配置记录;必须一次性提交 6 个模块,mouduleCode 为 1-6 且不重复,"
+ "moudule 名称与标准名一致,权重最多 2 位小数且合计等于 1;deductScore 为选错/填错一个扣多少分。")
public ApiResponse<TrainingModuleWeightResult> saveModuleWeights(
@Valid @RequestBody SaveModuleWeightsRequest request) {
service.saveModuleWeights(schoolId(), request.getId(), request.getModules(),
request.getDeductScore());
return ApiResponse.success(service.moduleWeight(schoolId()), traceId());
}
private String schoolId() {
return currentUserService.getCurrentUser().getSchoolId();
}
private String traceId() {
return MDC.get(TraceIdFilter.MDC_KEY);
}
}

@ -0,0 +1,18 @@
package com.yau.digitalrmb.training.score.teacher.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 班级下拉项:去重后的班级 id 与班级名称。
*/
@Getter
@AllArgsConstructor
@Schema(description = "班级下拉项")
public class TeacherClassOption {
@Schema(description = "班级 id", example = "2001")
private final String classId;
@Schema(description = "班级名称", example = "成绩测试班")
private final String className;
}

@ -0,0 +1,74 @@
package com.yau.digitalrmb.training.score.teacher.application;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* 教师端「成绩详情」导出:按当前筛选条件导出全部学生(不分页)为 xlsx,
* 顺序与详情页一致(按综合分数降序,同分按学号升序)。
* 列:姓名、学号、班级、模块一~模块六(各模块最高成绩)、综合分数。
*/
@Service
public class TeacherDetailExportService {
private final TeacherScoreService scoreService;
public TeacherDetailExportService(TeacherScoreService scoreService) {
this.scoreService = scoreService;
}
/** 导出文件名(含时间戳)。 */
public String fileName() {
return "学生成绩详情_"
+ LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")) + ".xlsx";
}
/** 导出 xlsx 字节流。 */
public byte[] export(String schoolId, String userName, String studentId, String classId) {
List<TeacherStudentDetailRow> rows = scoreService.studentDetailList(schoolId, userName, studentId, classId);
try (XSSFWorkbook wb = new XSSFWorkbook();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
Sheet sheet = wb.createSheet("成绩详情");
Row head = sheet.createRow(0);
String[] headers = {"姓名", "学号", "班级",
"模块一", "模块二", "模块三", "模块四", "模块五", "模块六", "综合分数"};
for (int i = 0; i < headers.length; i++) {
head.createCell(i).setCellValue(headers[i]);
}
int r = 1;
for (TeacherStudentDetailRow row : rows) {
Row data = sheet.createRow(r++);
data.createCell(0).setCellValue(nullToEmpty(row.getUserName()));
data.createCell(1).setCellValue(nullToEmpty(row.getStudentId()));
data.createCell(2).setCellValue(nullToEmpty(row.getClassName()));
setScore(data, 3, row.getModule1Score());
setScore(data, 4, row.getModule2Score());
setScore(data, 5, row.getModule3Score());
setScore(data, 6, row.getModule4Score());
setScore(data, 7, row.getModule5Score());
setScore(data, 8, row.getModule6Score());
data.createCell(9).setCellValue(row.getTotalScore() == null ? 0.0 : row.getTotalScore());
}
wb.write(out);
return out.toByteArray();
} catch (IOException e) {
throw new IllegalStateException("生成成绩详情导出文件失败", e);
}
}
private void setScore(Row row, int col, Double score) {
row.createCell(col).setCellValue(score == null ? 0.0 : score);
}
private String nullToEmpty(String value) {
return value == null ? "" : value;
}
}

@ -0,0 +1,17 @@
package com.yau.digitalrmb.training.score.teacher.application;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 聚合查询结果行:一名学生在某个成绩表模块名下的最高成绩。
* moudule 为成绩表 stu_module_score_details 里的原始模块名(如"发行数字货币"),
* 由 {@link com.yau.digitalrmb.training.score.application.TrainingModules#codeOfScoreName(String)} 映射为模块序号。
*/
@Getter
@AllArgsConstructor
public class TeacherModuleMaxScore {
private final String userId;
private final String moudule;
private final Double maxScore;
}

@ -0,0 +1,25 @@
package com.yau.digitalrmb.training.score.teacher.application;
import lombok.Getter;
/**
* 教师端「成绩详情/总成绩」只读复用 stu_module_score_details:
* 批量取每个 (user_id, moudule) 最新一条<b>有效</b>(未逻辑删除)记录的完成进度(schedule)。
* 与 {@link TeacherModuleMaxScore} 分开,是因为最高分口径含已删除轮次、进度取最新有效记录,
* 二者来源不同,不能用同一条 GROUP BY 聚合。
*/
@Getter
public class TeacherModuleSchedule {
/** 学生编号 */
private final String userId;
/** 模块名称(成绩表 moudule 列,拼写同库) */
private final String moudule;
/** 该模块最新有效记录的完成进度百分比 */
private final Double schedule;
public TeacherModuleSchedule(String userId, String moudule, Double schedule) {
this.userId = userId;
this.moudule = moudule;
this.schedule = schedule;
}
}

@ -0,0 +1,115 @@
package com.yau.digitalrmb.training.score.teacher.application;
import com.yau.digitalrmb.training.score.application.TrainingModules;
import com.yau.digitalrmb.training.score.teacher.infrastructure.TeacherModuleScoreMapper;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 教师端总成绩导出:按当前筛选条件导出全部学生(不分页)为 xlsx。
* 列:姓名、学号、班级、模块一~模块六(各模块最高成绩)、实训成绩、实验报告成绩、最终成绩。
*
* <p>模块最高分整体批量取(一条 GROUP BY SQL),不再对每个学生逐条调用 summary,避免导出 N+1。
*/
@Service
public class TeacherScoreExportService {
/** 模块数量固定为 6,与学生端模块数一致。 */
private static final int MODULE_COUNT = 6;
private final TeacherScoreService scoreService;
private final TeacherModuleScoreMapper moduleScoreMapper;
public TeacherScoreExportService(TeacherScoreService scoreService, TeacherModuleScoreMapper moduleScoreMapper) {
this.scoreService = scoreService;
this.moduleScoreMapper = moduleScoreMapper;
}
/** 生成导出文件名(含时间戳)。 */
public String fileName() {
String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
return "学生总成绩_" + ts + ".xlsx";
}
/** 导出 xlsx 字节流。 */
public byte[] export(String schoolId, String userName, String studentId, String classId) {
List<TeacherStudentOverallRow> rows = scoreService.studentOverallList(schoolId, userName, studentId, classId);
// 整体批量取各学生各模块最高成绩(含已删除轮次,与查询口径一致),一次 SQL
List<String> userIds = new ArrayList<String>(rows.size());
for (TeacherStudentOverallRow r : rows) {
userIds.add(r.getUserId());
}
Map<String, Map<Integer, Double>> moduleMaxByUser = new HashMap<String, Map<Integer, Double>>();
for (TeacherModuleMaxScore m : moduleScoreMapper.selectMaxScoresByUserIds(userIds)) {
Integer code = TrainingModules.codeOfScoreName(m.getMoudule());
if (code == null || m.getMaxScore() == null) {
continue;
}
moduleMaxByUser.computeIfAbsent(m.getUserId(), k -> new HashMap<Integer, Double>()).put(code, m.getMaxScore());
}
try (XSSFWorkbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("学生总成绩");
Row head = sheet.createRow(0);
String[] headers = {"姓名", "学号", "班级", "模块一", "模块二", "模块三", "模块四", "模块五", "模块六",
"实训成绩", "实验报告成绩", "最终成绩"};
for (int i = 0; i < headers.length; i++) {
Cell c = head.createCell(i);
c.setCellValue(headers[i]);
}
int r = 1;
for (TeacherStudentOverallRow row : rows) {
Row data = sheet.createRow(r++);
data.createCell(0).setCellValue(row.getUserName() == null ? "" : row.getUserName());
data.createCell(1).setCellValue(row.getStudentId() == null ? "" : row.getStudentId());
data.createCell(2).setCellValue(row.getClassName() == null ? "" : row.getClassName());
// 各模块最高成绩(按模块序号 1..6 对齐)
Double[] moduleMax = moduleMax(row.getUserId(), moduleMaxByUser);
for (int m = 0; m < MODULE_COUNT; m++) {
data.createCell(3 + m).setCellValue(moduleMax[m] == null ? 0.0 : moduleMax[m]);
}
data.createCell(9).setCellValue(row.getExperimentTrainingScore() == null ? 0.0 : row.getExperimentTrainingScore().doubleValue());
data.createCell(10).setCellValue(row.getExperimentReportScore() == null ? 0.0 : row.getExperimentReportScore().doubleValue());
data.createCell(11).setCellValue(row.getFinalScore() == null ? 0.0 : row.getFinalScore().doubleValue());
}
for (int i = 0; i < headers.length; i++) {
sheet.autoSizeColumn(i);
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
wb.write(out);
return out.toByteArray();
} catch (Exception e) {
throw new RuntimeException("生成总成绩导出文件失败:" + e.getMessage(), e);
}
}
/** 取该学生各模块最高成绩,按下标 0..5 对应模块一..模块六(来自批量 map)。 */
private Double[] moduleMax(String userId, Map<String, Map<Integer, Double>> moduleMaxByUser) {
Double[] max = new Double[MODULE_COUNT];
Map<Integer, Double> byCode = moduleMaxByUser.get(userId);
if (byCode != null) {
for (int code = 1; code <= MODULE_COUNT; code++) {
max[code - 1] = byCode.get(code);
}
}
return max;
}
}

@ -0,0 +1,326 @@
package com.yau.digitalrmb.training.score.teacher.application;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.yau.digitalrmb.identity.infrastructure.persistence.entity.UserEntity;
import com.yau.digitalrmb.training.experimentreport.infrastructure.ExperimentReportEntity;
import com.yau.digitalrmb.training.experimentreport.infrastructure.ExperimentReportMapper;
import com.yau.digitalrmb.training.score.application.TrainingModuleWeightItem;
import com.yau.digitalrmb.training.score.application.TrainingModuleWeightResult;
import com.yau.digitalrmb.training.score.application.TrainingModules;
import com.yau.digitalrmb.training.score.application.TrainingSchoolWeightResult;
import com.yau.digitalrmb.training.score.application.TrainingWeightService;
import com.yau.digitalrmb.training.score.teacher.infrastructure.TeacherModuleScoreMapper;
import com.yau.digitalrmb.training.score.teacher.infrastructure.TeacherUserMapper;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 教师端总成绩:按当前学校查询学生列表(支持用户名/学号/班级 id 筛选、分页),
* 复用学生端同一个总成绩算法计算每行总成绩;并提供当前学校去重班级下拉。
*
* <p>性能:总成绩计算原本对每个分页学生都调用一次 {@code overall},而 {@code overall} 内部又各自查询
* 模块汇总、模块权重、总成绩权重、实验报告(每学生约 20+ 次 DB 查询),形成严重 N+1:
* 一个 page=10 的请求会触发 200+ 次查询。现改为<b>按当前分页/筛选得到的学生整体批量查询</b>——
* 模块最高分与进度各一条 {@code GROUP BY} SQL、实验报告一条 {@code IN} 查询、权重整请求只查一次,
* 查询次数降到常数级(与每页人数无关),与学生端总成绩算法口径完全一致。
*
* <p>学生以 role_id = 4 标识(与成绩录入用户一致)。逻辑删除由 @TableLogic 自动过滤(分页查询走 BaseMapper);
* 班级下拉走原生 SQL,已手动过滤 is_deleted。
*/
@Service
public class TeacherScoreService {
/** 学生角色:与成绩录入用户的 role_id 一致。 */
private static final int STUDENT_ROLE_ID = 4;
private final TeacherUserMapper userMapper;
private final TeacherModuleScoreMapper moduleScoreMapper;
private final TrainingWeightService weightService;
private final ExperimentReportMapper reportMapper;
public TeacherScoreService(TeacherUserMapper userMapper, TeacherModuleScoreMapper moduleScoreMapper,
TrainingWeightService weightService, ExperimentReportMapper reportMapper) {
this.userMapper = userMapper;
this.moduleScoreMapper = moduleScoreMapper;
this.weightService = weightService;
this.reportMapper = reportMapper;
}
/**
* 分页查询当前学校学生总成绩。
*
* @param schoolId 当前教师所属学校编号(来自登录用户,用于隔离数据)
* @param userName 姓名模糊匹配,可空
* @param studentId 学号模糊匹配,可空
* @param classId 班级 id 精确匹配,可空
* @param current 页码,从 1 开始
* @param size 每页数量
*/
public TeacherStudentOverallPage studentOverallPage(String schoolId, String userName, String studentId,
String classId, int current, int size) {
IPage<UserEntity> result = userMapper.selectPage(new Page<UserEntity>(current, size),
studentWrapper(schoolId, userName, studentId, classId));
List<TeacherStudentOverallRow> rows = buildOverallRows(result.getRecords(), schoolId);
return new TeacherStudentOverallPage(result.getCurrent(), result.getSize(),
result.getTotal(), result.getPages(), rows);
}
/**
* 不分页查询当前学校学生总成绩(导出用),筛选规则与 {@link #studentOverallPage} 一致。
*/
public List<TeacherStudentOverallRow> studentOverallList(String schoolId, String userName,
String studentId, String classId) {
return buildOverallRows(userMapper.selectList(studentWrapper(schoolId, userName, studentId, classId)), schoolId);
}
/**
* 批量计算学生总成绩:整体批量取模块最高分、进度、报告评分,权重整请求只查一次,避免 N+1。
* 计算口径与学生端 {@code TrainingOverallScoreService#overall} 完全一致。
*/
private List<TeacherStudentOverallRow> buildOverallRows(List<UserEntity> users, String schoolId) {
if (users.isEmpty()) {
return new ArrayList<TeacherStudentOverallRow>();
}
// 权重(全校一份,整请求仅查一次)
Map<Integer, BigDecimal> weights = moduleWeights(schoolId);
BigDecimal trainingWeight;
BigDecimal reportWeight;
if (schoolId != null && !schoolId.trim().isEmpty()) {
TrainingSchoolWeightResult sw = weightService.schoolWeight(schoolId);
trainingWeight = sw.getTrainingWeight();
reportWeight = sw.getReportWeight();
} else {
trainingWeight = TrainingModules.DEFAULT_TRAINING_WEIGHT;
reportWeight = TrainingModules.DEFAULT_REPORT_WEIGHT;
}
// 批量取当前学生的模块数据(各一条 SQL,与每页人数无关)
List<String> userIds = new ArrayList<String>(users.size());
for (UserEntity u : users) {
userIds.add(u.getUserId());
}
Map<String, Map<Integer, Double>> maxByUser = batchMaxScores(userIds);
Map<String, Map<Integer, Double>> scheduleByUser = batchSchedules(userIds);
Map<String, BigDecimal> reportByUser = batchReports(userIds);
List<TeacherStudentOverallRow> rows = new ArrayList<TeacherStudentOverallRow>(users.size());
for (UserEntity u : users) {
rows.add(buildOverallRow(u, weights, trainingWeight, reportWeight,
maxByUser.get(u.getUserId()), scheduleByUser.get(u.getUserId()), reportByUser));
}
return rows;
}
/** 构建单个学生的总成绩行:与学生端 overall 同口径(含已删除轮次取最高分、进度取最新有效记录)。 */
private TeacherStudentOverallRow buildOverallRow(UserEntity u, Map<Integer, BigDecimal> weights,
BigDecimal trainingWeight, BigDecimal reportWeight,
Map<Integer, Double> maxMap, Map<Integer, Double> scheduleMap,
Map<String, BigDecimal> reportByUser) {
// 实验实训:各模块最高分按模块权重加权,进度为 6 模块均值(缺失计 0)
BigDecimal trainingScore = BigDecimal.ZERO;
BigDecimal progressSum = BigDecimal.ZERO;
for (int code = 1; code <= TrainingModules.MODULE_COUNT; code++) {
BigDecimal moduleWeight = weights.getOrDefault(code, BigDecimal.ZERO);
Double max = (maxMap == null || maxMap.get(code) == null) ? 0.0 : maxMap.get(code);
trainingScore = trainingScore.add(moduleWeight.multiply(BigDecimal.valueOf(max)));
Double sch = (scheduleMap == null || scheduleMap.get(code) == null) ? 0.0 : scheduleMap.get(code);
progressSum = progressSum.add(BigDecimal.valueOf(sch));
}
BigDecimal trainingScoreR2 = trainingScore.setScale(2, RoundingMode.HALF_UP);
BigDecimal trainingProgress = progressSum
.divide(BigDecimal.valueOf(TrainingModules.MODULE_COUNT), 2, RoundingMode.HALF_UP);
BigDecimal trainingFinal = trainingProgress
.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)
.multiply(trainingWeight).multiply(trainingScoreR2)
.setScale(2, RoundingMode.HALF_UP);
// 实验报告:有提交即进度 100%,成绩取教师评分(未评分计 0);整请求只查一次 IN
boolean hasReport = reportByUser.containsKey(u.getUserId());
BigDecimal reportScore = hasReport ? reportByUser.get(u.getUserId()) : BigDecimal.ZERO;
BigDecimal reportProgress = hasReport ? BigDecimal.valueOf(100) : BigDecimal.ZERO;
BigDecimal reportFinal = reportProgress
.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)
.multiply(reportWeight).multiply(reportScore)
.setScale(2, RoundingMode.HALF_UP);
BigDecimal totalFinal = trainingFinal.add(reportFinal).setScale(2, RoundingMode.HALF_UP);
return new TeacherStudentOverallRow(
u.getUserId(), u.getUserName(), u.getStudentId(),
u.getClassId(), u.getClassName(),
trainingScoreR2, trainingWeight, reportScore, reportWeight, totalFinal);
}
/** 批量取每个学生在各模块的最高成绩(含已删除轮次,与学生端口径一致),userId -> (模块序号 -> 最高分)。 */
private Map<String, Map<Integer, Double>> batchMaxScores(List<String> userIds) {
Map<String, Map<Integer, Double>> maxByUser = new HashMap<String, Map<Integer, Double>>();
for (TeacherModuleMaxScore m : moduleScoreMapper.selectMaxScoresByUserIds(userIds)) {
Integer code = TrainingModules.codeOfScoreName(m.getMoudule());
if (code == null || m.getMaxScore() == null) {
continue;
}
maxByUser.computeIfAbsent(m.getUserId(), k -> new HashMap<Integer, Double>()).put(code, m.getMaxScore());
}
return maxByUser;
}
/** 批量取每个学生在各模块的最新有效记录进度,userId -> (模块序号 -> 进度)。 */
private Map<String, Map<Integer, Double>> batchSchedules(List<String> userIds) {
Map<String, Map<Integer, Double>> scheduleByUser = new HashMap<String, Map<Integer, Double>>();
for (TeacherModuleSchedule s : moduleScoreMapper.selectLatestScheduleByUserIds(userIds)) {
Integer code = TrainingModules.codeOfScoreName(s.getMoudule());
if (code == null || s.getSchedule() == null) {
continue;
}
scheduleByUser.computeIfAbsent(s.getUserId(), k -> new HashMap<Integer, Double>()).put(code, s.getSchedule());
}
return scheduleByUser;
}
/** 批量取每个学生的实验报告教师评分(仅未删除报告),userId -> 教师评分(无评分记 0,但仍标记有报告)。 */
private Map<String, BigDecimal> batchReports(List<String> userIds) {
Map<String, BigDecimal> reportByUser = new HashMap<String, BigDecimal>();
List<ExperimentReportEntity> reports = reportMapper.selectList(
new LambdaQueryWrapper<ExperimentReportEntity>()
.in(ExperimentReportEntity::getUserId, userIds)
.eq(ExperimentReportEntity::getDeleted, false));
for (ExperimentReportEntity r : reports) {
// 每个学生取第一条报告的教师评分(与 overall 的 LIMIT 1 任意一条等价);无评分记 0 但仍标记有报告
reportByUser.putIfAbsent(r.getUserId(),
r.getTeacherScore() == null ? BigDecimal.ZERO : r.getTeacherScore());
}
return reportByUser;
}
/** 当前学校去重班级列表,用于前端筛选下拉。 */
public List<TeacherClassOption> classOptions(String schoolId) {
return userMapper.selectClassesBySchool(schoolId);
}
/**
* 分页查询当前学校学生「成绩详情」:一行一名学生,模块一~模块六各取该生最高成绩,
* 并按各模块最高分×模块权重加权求和得到综合分数(totalScore),按综合分数<b>降序</b>排列(同分按学号升序)。
* 最高成绩口径与学生端一致:该模块全部轮次(含已逻辑删除的历史轮次)取最大值;
* 没有成绩的模块为 null、综合分数按 0 计。排序需全量计算后进行,故先查全部匹配学生再在内存分页。
*/
public TeacherStudentDetailPage studentDetailPage(String schoolId, String userName, String studentId,
String classId, int current, int size) {
List<TeacherStudentDetailRow> all = studentDetailList(schoolId, userName, studentId, classId);
int from = (current - 1) * size;
List<TeacherStudentDetailRow> records = from >= all.size()
? new ArrayList<TeacherStudentDetailRow>()
: new ArrayList<TeacherStudentDetailRow>(all.subList(from, Math.min(from + size, all.size())));
long total = all.size();
long pages = size <= 0 ? 0 : (total + size - 1) / size;
return new TeacherStudentDetailPage((long) current, (long) size, total, pages, records);
}
/**
* 不分页查询当前学校学生「成绩详情」(导出用):按综合分数降序(同分按学号升序),
* 综合分数 = Σ(模块最高分 × 模块权重),权重取自权重表(本校未配置时落默认),缺失模块计 0。
*/
public List<TeacherStudentDetailRow> studentDetailList(String schoolId, String userName,
String studentId, String classId) {
List<TeacherStudentDetailRow> rows =
buildDetailRows(userMapper.selectList(studentWrapper(schoolId, userName, studentId, classId)),
moduleWeights(schoolId));
rows.sort((a, b) -> {
int c = b.getTotalScore().compareTo(a.getTotalScore());
if (c != 0) {
return c;
}
String sa = a.getStudentId() == null ? "" : a.getStudentId();
String sb = b.getStudentId() == null ? "" : b.getStudentId();
return sa.compareTo(sb);
});
return rows;
}
/** 模块序号 → 权重:读权重表(本校未配置时由权重服务落默认行);无学校归属时用默认权重。 */
private Map<Integer, BigDecimal> moduleWeights(String schoolId) {
if (schoolId == null || schoolId.trim().isEmpty()) {
return TrainingModules.defaultWeights();
}
Map<Integer, BigDecimal> weights = new HashMap<Integer, BigDecimal>(TrainingModules.MODULE_COUNT);
TrainingModuleWeightResult result = weightService.moduleWeight(schoolId);
for (TrainingModuleWeightItem item : result.getModules()) {
weights.put(item.getMouduleCode(), item.getWeight());
}
return weights;
}
/** 批量构建成绩详情行:一条 SQL 取全部学生各模块最高分,按模块序号摊平到 6 列并计算综合分数。 */
private List<TeacherStudentDetailRow> buildDetailRows(List<UserEntity> users, Map<Integer, BigDecimal> weights) {
List<TeacherStudentDetailRow> rows = new ArrayList<TeacherStudentDetailRow>(users.size());
if (users.isEmpty()) {
return rows;
}
List<String> userIds = new ArrayList<String>(users.size());
for (UserEntity u : users) {
userIds.add(u.getUserId());
}
// userId -> (模块序号 -> 最高成绩)
Map<String, Map<Integer, Double>> maxByUser = new HashMap<String, Map<Integer, Double>>();
for (TeacherModuleMaxScore m : moduleScoreMapper.selectMaxScoresByUserIds(userIds)) {
Integer code = TrainingModules.codeOfScoreName(m.getMoudule());
if (code == null || m.getMaxScore() == null) {
continue;
}
Map<Integer, Double> byCode = maxByUser.get(m.getUserId());
if (byCode == null) {
byCode = new HashMap<Integer, Double>();
maxByUser.put(m.getUserId(), byCode);
}
Double prev = byCode.get(code);
if (prev == null || m.getMaxScore() > prev) {
byCode.put(code, m.getMaxScore());
}
}
for (UserEntity u : users) {
Map<Integer, Double> byCode = maxByUser.get(u.getUserId());
// 综合分数 = Σ(模块最高分 × 模块权重),缺失模块计 0,保留 2 位小数
BigDecimal total = BigDecimal.ZERO;
for (int code = 1; code <= TrainingModules.MODULE_COUNT; code++) {
Double maxScore = byCode == null ? null : byCode.get(code);
if (maxScore != null) {
total = total.add(weights.getOrDefault(code, BigDecimal.ZERO)
.multiply(BigDecimal.valueOf(maxScore)));
}
}
rows.add(new TeacherStudentDetailRow(
u.getUserId(), u.getUserName(), u.getStudentId(), u.getClassId(), u.getClassName(),
byCode == null ? null : byCode.get(1),
byCode == null ? null : byCode.get(2),
byCode == null ? null : byCode.get(3),
byCode == null ? null : byCode.get(4),
byCode == null ? null : byCode.get(5),
byCode == null ? null : byCode.get(6),
total.setScale(2, RoundingMode.HALF_UP).doubleValue()));
}
return rows;
}
/** 学生分页/列表共用的查询条件:本校学生(role_id=4)+ 姓名/学号模糊、班级精确筛选。 */
private LambdaQueryWrapper<UserEntity> studentWrapper(String schoolId, String userName,
String studentId, String classId) {
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.eq(UserEntity::getSchoolId, schoolId)
.eq(UserEntity::getRoleId, STUDENT_ROLE_ID);
if (userName != null && !userName.trim().isEmpty()) {
wrapper.like(UserEntity::getUserName, userName.trim());
}
if (studentId != null && !studentId.trim().isEmpty()) {
wrapper.like(UserEntity::getStudentId, studentId.trim());
}
if (classId != null && !classId.trim().isEmpty()) {
wrapper.eq(UserEntity::getClassId, classId.trim());
}
wrapper.orderByAsc(UserEntity::getStudentId);
return wrapper;
}
}

@ -0,0 +1,31 @@
package com.yau.digitalrmb.training.score.teacher.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.List;
/**
* 教师端「成绩详情」分页结果,字段与现有分页约定(current/size/total/pages/records)保持一致。
*/
@Getter
@AllArgsConstructor
@Schema(description = "教师端成绩详情分页结果")
public class TeacherStudentDetailPage {
@Schema(description = "当前页码", example = "1")
private final Long current;
@Schema(description = "每页数量", example = "10")
private final Long size;
@Schema(description = "总记录数", example = "37")
private final Long total;
@Schema(description = "总页数", example = "4")
private final Long pages;
@Schema(description = "当前页学生成绩详情列表")
private final List<TeacherStudentDetailRow> records;
}

@ -0,0 +1,52 @@
package com.yau.digitalrmb.training.score.teacher.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 教师端「成绩详情」行:一名学生 + 模块一~模块六各自最高成绩。
* 最高成绩口径与学生端一致:当前学生该模块全部轮次(含已逻辑删除的历史轮次)取最大值;
* 该模块没有成绩时对应字段为 null。
*/
@Getter
@AllArgsConstructor
@Schema(description = "教师端成绩详情行(学生 + 模块一~模块六最高成绩)")
public class TeacherStudentDetailRow {
@Schema(description = "学生用户编号")
private final String userId;
@Schema(description = "姓名", example = "学生甲")
private final String userName;
@Schema(description = "学号", example = "stu-a")
private final String studentId;
@Schema(description = "班级 id", example = "2071")
private final String classId;
@Schema(description = "班级名称", example = "教师测班1")
private final String className;
@Schema(description = "模块一(中国人民银行生成数字货币)最高成绩")
private final Double module1Score;
@Schema(description = "模块二(中国人民银行发行数字货币)最高成绩")
private final Double module2Score;
@Schema(description = "模块三(开通数字货币钱包)最高成绩")
private final Double module3Score;
@Schema(description = "模块四(用户兑换数字货币)最高成绩")
private final Double module4Score;
@Schema(description = "模块五(用户支付数字货币)最高成绩")
private final Double module5Score;
@Schema(description = "模块六(数字人民币创新应用场景仿真)最高成绩")
private final Double module6Score;
@Schema(description = "综合分数:各模块最高分按模块权重加权求和(权重和为 1,缺失模块计 0),保留 2 位小数", example = "36.40")
private final Double totalScore;
}

@ -0,0 +1,26 @@
package com.yau.digitalrmb.training.score.teacher.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.List;
/**
* 教师端学生总成绩分页结果,字段顺序与现有分页约定(current/size/total/pages/records)保持一致。
*/
@Getter
@AllArgsConstructor
@Schema(description = "教师端学生总成绩分页结果")
public class TeacherStudentOverallPage {
@Schema(description = "当前页码", example = "1")
private final Long current;
@Schema(description = "每页数量", example = "10")
private final Long size;
@Schema(description = "总记录数", example = "128")
private final Long total;
@Schema(description = "总页数", example = "13")
private final Long pages;
@Schema(description = "当前页学生总成绩列表")
private final List<TeacherStudentOverallRow> records;
}

@ -0,0 +1,40 @@
package com.yau.digitalrmb.training.score.teacher.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.math.BigDecimal;
/**
* 教师端总成绩列表的一行:学生基本信息 + 与学生端同口径的总成绩三行数据。
*
* <p>实验实训成绩 = 各模块最高分按模块权重加权;实验实训权重 = 该生所属学校配置的实验实训权重;
* 实验报告成绩 = 教师评分;实验报告权重 = 该校配置的实验报告权重;最终成绩 = 实训最终 + 报告最终。
*/
@Getter
@AllArgsConstructor
@Schema(description = "教师端学生总成绩一行")
public class TeacherStudentOverallRow {
@Schema(description = "学生用户编号", example = "eef57628-4a17-40e7-b1cc-079eda54e997")
private final String userId;
@Schema(description = "姓名", example = "张三")
private final String userName;
@Schema(description = "学号", example = "2021010101")
private final String studentId;
@Schema(description = "班级 id", example = "2001")
private final String classId;
@Schema(description = "班级名称", example = "成绩测试班")
private final String className;
@Schema(description = "实验实训成绩(各模块最高分按模块权重加权)", example = "36.40")
private final BigDecimal experimentTrainingScore;
@Schema(description = "实验实训权重(来自学校总成绩权重配置,如 0.80)", example = "0.80")
private final BigDecimal experimentTrainingWeight;
@Schema(description = "实验报告成绩(教师评分,未评分计 0)", example = "90.00")
private final BigDecimal experimentReportScore;
@Schema(description = "实验报告权重(来自学校总成绩权重配置,如 0.20)", example = "0.20")
private final BigDecimal experimentReportWeight;
@Schema(description = "最终成绩 = 实训最终 + 报告最终", example = "22.85")
private final BigDecimal finalScore;
}

@ -0,0 +1,51 @@
package com.yau.digitalrmb.training.score.teacher.infrastructure;
import com.yau.digitalrmb.training.score.teacher.application.TeacherModuleMaxScore;
import com.yau.digitalrmb.training.score.teacher.application.TeacherModuleSchedule;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 教师端「成绩详情」只读复用 stu_module_score_details:
* 批量统计一批学生在各模块的最高成绩(手写 SQL,不含逻辑删除过滤——
* 最高成绩口径与学生端一致,已删除的历史轮次同样参与取最大值)。
*/
@Mapper
public interface TeacherModuleScoreMapper {
/**
* 按 user_id + moudule 分组取最高成绩。
*
* @param userIds 学生编号集合(当前页学生,非空)
*/
@Select("<script>"
+ "SELECT user_id AS userId, moudule AS moudule, MAX(score_project) AS maxScore "
+ "FROM stu_module_score_details WHERE user_id IN "
+ "<foreach collection='userIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> "
+ "GROUP BY user_id, moudule"
+ "</script>")
List<TeacherModuleMaxScore> selectMaxScoresByUserIds(@Param("userIds") List<String> userIds);
/**
* 批量取每个 (user_id, moudule) 最新一条<b>有效</b>(未逻辑删除)记录的完成进度(schedule)。
* 进度口径与学生端一致:取该模块 serial_number 最大且未删除的记录。
* 与 {@link #selectMaxScoresByUserIds} 分开,因为最高分口径含已删除轮次、进度取最新有效记录。
*/
@Select("<script>"
+ "SELECT t.user_id AS userId, t.moudule AS moudule, t.schedule AS schedule "
+ "FROM stu_module_score_details t "
+ "INNER JOIN ("
+ " SELECT user_id, moudule, MAX(serial_number) AS sn "
+ " FROM stu_module_score_details WHERE user_id IN "
+ " <foreach collection='userIds' item='id' open='(' separator=',' close=')'>#{id}</foreach>"
+ " AND deleted = 0 GROUP BY user_id, moudule"
+ ") latest ON latest.user_id = t.user_id AND latest.moudule = t.moudule AND latest.sn = t.serial_number "
+ "WHERE t.user_id IN "
+ " <foreach collection='userIds' item='id' open='(' separator=',' close=')'>#{id}</foreach>"
+ " AND t.deleted = 0"
+ "</script>")
List<TeacherModuleSchedule> selectLatestScheduleByUserIds(@Param("userIds") List<String> userIds);
}

@ -0,0 +1,27 @@
package com.yau.digitalrmb.training.score.teacher.infrastructure;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yau.digitalrmb.identity.infrastructure.persistence.entity.UserEntity;
import com.yau.digitalrmb.training.score.teacher.application.TeacherClassOption;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 教师端只读复用 sys_user:仅用于按学校筛选学生与班级下拉,不写入用户表。
*/
@Mapper
public interface TeacherUserMapper extends BaseMapper<UserEntity> {
/**
* 当前学校去重班级:class_id 与 class_name 配对去重,按 class_id 排序。
* 逻辑删除(is_deleted)需手动过滤,因为本查询走原生 SQL 不经过 @TableLogic。
*/
@Select("SELECT DISTINCT class_id, class_name FROM sys_user "
+ "WHERE school_id = #{schoolId} AND (is_deleted = 0 OR is_deleted IS NULL) "
+ "AND class_id IS NOT NULL AND class_name IS NOT NULL "
+ "ORDER BY class_id")
List<TeacherClassOption> selectClassesBySchool(@Param("schoolId") String schoolId);
}

@ -0,0 +1,155 @@
package com.yau.digitalrmb.training.score.teacher.interfaces.rest;
import com.yau.digitalrmb.security.application.CurrentUser;
import com.yau.digitalrmb.security.application.CurrentUserService;
import com.yau.digitalrmb.shared.api.ApiResponse;
import com.yau.digitalrmb.shared.web.TraceIdFilter;
import com.yau.digitalrmb.training.score.teacher.application.TeacherClassOption;
import com.yau.digitalrmb.training.score.teacher.application.TeacherDetailExportService;
import com.yau.digitalrmb.training.score.teacher.application.TeacherScoreExportService;
import com.yau.digitalrmb.training.score.teacher.application.TeacherScoreService;
import com.yau.digitalrmb.training.score.teacher.application.TeacherStudentDetailPage;
import com.yau.digitalrmb.training.score.teacher.application.TeacherStudentOverallPage;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.MDC;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.net.URLEncoder;
import java.util.List;
/**
* 教师端总成绩:按当前学校分页查询学生总成绩(支持用户名/学号/班级筛选),
* 以及当前学校去重班级下拉、总成绩 Excel 导出。
*/
@RestController
@RequestMapping("/api/v1/training-scores/teacher")
@Tag(name = "教师端总成绩", description = "按当前学校查询学生总成绩列表与班级下拉,成绩计算与学生端同口径")
public class TrainingScoreTeacherController {
private final TeacherScoreService service;
private final TeacherScoreExportService exportService;
private final TeacherDetailExportService detailExportService;
private final CurrentUserService currentUserService;
public TrainingScoreTeacherController(TeacherScoreService service, TeacherScoreExportService exportService,
TeacherDetailExportService detailExportService,
CurrentUserService currentUserService) {
this.service = service;
this.exportService = exportService;
this.detailExportService = detailExportService;
this.currentUserService = currentUserService;
}
@GetMapping("/overall")
@Operation(summary = "教师端分页查询学生总成绩",
description = "按当前教师所属学校查询学生列表,支持用户名(模糊)、学号(模糊)、班级 id(精确)筛选,分页返回。"
+ "每行成绩与学生端总成绩同口径:实验实训成绩=各模块最高分按模块权重加权、实验实训权重取自学校配置;"
+ "实验报告成绩=教师评分、实验报告权重取自学校配置;最终成绩=实训最终+报告最终。")
public ApiResponse<TeacherStudentOverallPage> overall(
@Parameter(description = "页码,从 1 开始", example = "1")
@RequestParam(value = "page", defaultValue = "1") int page,
@Parameter(description = "每页数量", example = "10")
@RequestParam(value = "size", defaultValue = "10") int size,
@Parameter(description = "姓名模糊匹配")
@RequestParam(value = "userName", required = false) String userName,
@Parameter(description = "学号模糊匹配")
@RequestParam(value = "studentId", required = false) String studentId,
@Parameter(description = "班级 id 精确匹配")
@RequestParam(value = "classId", required = false) String classId) {
CurrentUser user = currentUserService.getCurrentUser();
return ApiResponse.success(
service.studentOverallPage(user.getSchoolId(), userName, studentId, classId, page, size),
MDC.get(TraceIdFilter.MDC_KEY));
}
@GetMapping("/details")
@Operation(summary = "教师端分页查询学生成绩详情",
description = "按当前教师所属学校查询学生列表(用户名模糊、学号模糊、班级 id 精确筛选),每行一名学生,"
+ "module1Score~module6Score 为该生对应模块的最高成绩(取该模块全部轮次含已删除的最大值,"
+ "与学生端最高成绩口径一致;没有成绩的模块为 null)。")
public ApiResponse<TeacherStudentDetailPage> details(
@Parameter(description = "页码,从 1 开始", example = "1")
@RequestParam(value = "page", defaultValue = "1") int page,
@Parameter(description = "每页数量", example = "10")
@RequestParam(value = "size", defaultValue = "10") int size,
@Parameter(description = "姓名模糊匹配")
@RequestParam(value = "userName", required = false) String userName,
@Parameter(description = "学号模糊匹配")
@RequestParam(value = "studentId", required = false) String studentId,
@Parameter(description = "班级 id 精确匹配")
@RequestParam(value = "classId", required = false) String classId) {
CurrentUser user = currentUserService.getCurrentUser();
return ApiResponse.success(
service.studentDetailPage(user.getSchoolId(), userName, studentId, classId, page, size),
MDC.get(TraceIdFilter.MDC_KEY));
}
@GetMapping("/classes")
@Operation(summary = "当前学校班级下拉",
description = "从 sys_user 按当前学校去重返回班级 id 与班级名称,逻辑删除记录已过滤。")
public ApiResponse<List<TeacherClassOption>> classes() {
CurrentUser user = currentUserService.getCurrentUser();
return ApiResponse.success(
service.classOptions(user.getSchoolId()),
MDC.get(TraceIdFilter.MDC_KEY));
}
@GetMapping("/details/export")
@Operation(summary = "教师端导出学生成绩详情",
description = "按当前教师所属学校与筛选条件导出全部学生(不分页)为 xlsx,顺序与详情页一致"
+ "(按综合分数降序,同分按学号升序)。列:姓名、学号、班级、模块一~模块六(各模块最高成绩)、综合分数。")
public ResponseEntity<byte[]> exportDetails(
@Parameter(description = "姓名模糊匹配")
@RequestParam(value = "userName", required = false) String userName,
@Parameter(description = "学号模糊匹配")
@RequestParam(value = "studentId", required = false) String studentId,
@Parameter(description = "班级 id 精确匹配")
@RequestParam(value = "classId", required = false) String classId) {
CurrentUser user = currentUserService.getCurrentUser();
byte[] bytes = detailExportService.export(user.getSchoolId(), userName, studentId, classId);
String filename = detailExportService.fileName();
String encoded;
try {
encoded = URLEncoder.encode(filename, "UTF-8").replace("+", "%20");
} catch (java.io.UnsupportedEncodingException e) {
encoded = filename;
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encoded)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(bytes);
}
@GetMapping("/overall/export")
@Operation(summary = "教师端导出学生总成绩",
description = "按当前教师所属学校与筛选条件,导出全部学生(不分页)为 xlsx 文件。"
+ "列:姓名、学号、班级、模块一~模块六(各模块最高成绩)、实训成绩、实验报告成绩、最终成绩。")
public ResponseEntity<byte[]> exportOverall(
@Parameter(description = "姓名模糊匹配")
@RequestParam(value = "userName", required = false) String userName,
@Parameter(description = "学号模糊匹配")
@RequestParam(value = "studentId", required = false) String studentId,
@Parameter(description = "班级 id 精确匹配")
@RequestParam(value = "classId", required = false) String classId) {
CurrentUser user = currentUserService.getCurrentUser();
byte[] bytes = exportService.export(user.getSchoolId(), userName, studentId, classId);
String filename = exportService.fileName();
String encoded;
try {
encoded = URLEncoder.encode(filename, "UTF-8").replace("+", "%20");
} catch (java.io.UnsupportedEncodingException e) {
encoded = filename;
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encoded)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(bytes);
}
}

@ -7,6 +7,8 @@ import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import com.yau.digitalrmb.walletopening.domain.BankReceivedApplication;
import com.yau.digitalrmb.walletopening.domain.BankReceivedApplicationRepository;
import com.yau.digitalrmb.walletopening.domain.CentralWalletActivation;
import com.yau.digitalrmb.walletopening.domain.CentralWalletActivationRepository;
import com.yau.digitalrmb.walletopening.domain.CentralWalletRegistration;
import com.yau.digitalrmb.walletopening.domain.CentralWalletRegistrationRepository;
import com.yau.digitalrmb.walletopening.domain.SmartContractGeneration;
@ -40,6 +42,8 @@ public class SmartContractService {
@Resource
private CentralWalletRegistrationRepository centralRegistrationRepository;
@Resource
private CentralWalletActivationRepository activationRepository;
@Resource
private InstitutionKeyService institutionKeyService;
@Resource
private SmInstitutionIdentityCryptography cryptography;
@ -202,10 +206,48 @@ public class SmartContractService {
}
c.markSent();
contractRepository.update(c);
// 第四步发送成功即完成:后端内部串联央行端激活链路,落库 wallet_activated=TRUE、status=ACTIVATED
completeCentralActivation(subject, c);
scoreService.markStepCompleted(subject.getUserId(), 6);
return "成功";
}
/**
* 商业银行发送开通确认报文后,由后端完成央行端(接收→验签→重算摘要→摘要匹配→激活)链路,
* 保证模块四能查询到已激活钱包;已推进到步骤五的记录不回退。
*/
private void completeCentralActivation(InstitutionKeySubject subject, SmartContractGeneration c) {
CentralWalletActivation a = activationRepository
.findLatest(subject.getUserId(), subject.getSchoolId(), subject.getClassId()).orElse(null);
if (a == null) {
a = activationRepository.save(
CentralWalletActivation.create(subject.getUserId(), subject.getSchoolId(), subject.getClassId()));
}
if (a.getStatus() != null
&& a.getStatus().ordinal() > CentralWalletActivation.Status.ACTIVATED.ordinal()) {
return;
}
if (!a.isReceived()) {
a.receive(c.getConfirmId(), c.getWalletId(), c.getOrgCode(), c.getContractId(),
c.getActivateDigest(), c.getActivateSignature(), c.getActivateTimestamp(), c.getConfirmJson());
}
if (a.getSignVerifyResult() == null || !a.getSignVerifyResult().startsWith("通过")) {
String bankPublicKey = institutionKeyService.commercialBankSecondPublicKeyOrNull(subject);
boolean verified = bankPublicKey != null
&& cryptography.verify(bankPublicKey, a.getDigest(), a.getSignature());
a.verifySignature(bankPublicKey != null ? bankPublicKey : "", verified);
}
if (a.getRecalculatedDigest() == null) {
a.recalculateDigest(cryptography.sm3(c.getActivatePlaintext()));
}
if (a.getDigestMatchResult() == null || !a.getDigestMatchResult().startsWith("通过")) {
a.matchDigest(a.getRecalculatedDigest() != null
&& a.getRecalculatedDigest().equalsIgnoreCase(a.getDigest()));
}
a.activateWallet();
activationRepository.update(a);
}
private String buildContractJson(String contractId, SmartContractGeneration c) {
return "{\n"
+ " \"contractId\": \"" + contractId + "\",\n"

@ -22,4 +22,4 @@ security:
file:
type: local
path: D:\新建文件夹
path: /usr/local/tianzeProject/digital-rmb-backend/uploadFile

@ -0,0 +1,23 @@
-- 存量库升级:新增实训报告表(与实验模块解耦,一个用户可多次提交)
CREATE TABLE IF NOT EXISTS experiment_report (
id CHAR(36) PRIMARY KEY COMMENT '主键',
report_name VARCHAR(255) NOT NULL COMMENT '报告名称(上传时的原始文件名)',
file_url VARCHAR(512) NOT NULL COMMENT '文件地址:经转换后存储到服务器的 PDF 相对路径',
file_size BIGINT NOT NULL COMMENT '文件大小(字节)',
upload_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
teacher_score DECIMAL(5, 2) NULL COMMENT '教师评分',
user_id VARCHAR(36) NOT NULL COMMENT '用户ID',
school_id VARCHAR(36) NULL COMMENT '学校ID',
class_id VARCHAR(36) NULL COMMENT '班级ID',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
INDEX idx_experiment_report_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='实训报告表';
-- 已建过本表的环境执行(对齐 sys_user 的 utf8mb4_unicode_ci,否则 join 报
-- Illegal mix of collations utf8mb4_0900_ai_ci vs utf8mb4_unicode_ci):
ALTER TABLE experiment_report CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 存量库升级:实训报告新增教师评语(教师评分接口 /api/v1/experiment-reports/teacher/score 一并写入)
ALTER TABLE experiment_report ADD COLUMN teacher_comment VARCHAR(500) NULL COMMENT '教师评语' AFTER teacher_score;

@ -1,5 +1,86 @@
-- 模块六公益捐赠三步流程增量建表脚本。
-- 已有环境升级时执行;全新环境由 schema.sql 自动创建。
-- 本脚本可重复执行;四张表均使用 IF NOT EXISTS,只补缺失的表。
-- 模块六:捐赠企业发起捐赠表 - 捐赠企业向受赠机构发起捐赠全流程
CREATE TABLE IF NOT EXISTS donation (
id BIGINT PRIMARY KEY COMMENT '主键ID(雪花算法生成)',
user_id VARCHAR(36) NOT NULL COMMENT '用户ID(关联登录用户)',
school_id BIGINT NOT NULL COMMENT '学校ID',
class_id BIGINT NOT NULL COMMENT '班级ID',
donation_id VARCHAR(64) COMMENT '捐赠编号(如:DONATE_20260801_001)',
donating_enterprise VARCHAR(200) COMMENT '捐赠企业名称(如:创新科技公司)',
corp_wallet_id VARCHAR(128) COMMENT '付款钱包ID(CORP_前缀+十六进制)',
recipient_organization VARCHAR(200) COMMENT '受赠机构名称(如:阳光公益基金)',
org_wallet_id VARCHAR(128) COMMENT '收款钱包ID(CORP_前缀+十六进制)',
amount VARCHAR(32) COMMENT '捐赠金额(单位:元DC,保留两位小数)',
project VARCHAR(200) COMMENT '捐赠项目名称(如:乡村教育支持计划)',
memo VARCHAR(500) COMMENT '捐赠备注说明',
timestamp VARCHAR(20) COMMENT '时间戳,格式yyyyMMddHHmmss',
concatenated_message TEXT COMMENT '拼接捐赠原文(格式:DONATE|付款钱包ID|收款钱包ID|金额|项目|备注|时间戳)',
digest_algorithm VARCHAR(16) COMMENT '摘要算法(如:SM3)',
digest_value VARCHAR(128) COMMENT 'SM3摘要值(十六进制大写)',
corp_signature TEXT COMMENT '企业法人SM2签名值(DER编码十六进制)',
output_json TEXT COMMENT '输出JSON报文',
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态:DRAFT-草稿, DIGESTED-已生成摘要, SIGNED-已签名, OUTPUT-已输出',
created_at TIMESTAMP NOT NULL COMMENT '创建时间',
updated_at TIMESTAMP NOT NULL COMMENT '更新时间',
created_by VARCHAR(64) NOT NULL COMMENT '创建人',
updated_by VARCHAR(64) NOT NULL COMMENT '更新人',
deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记:false-未删除, true-已删除'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-捐赠企业发起捐赠表';
-- 模块六:受赠机构接收捐款与公示表 - 受赠机构查看捐款、提取公示原文、生成摘要、发布公示、输出
CREATE TABLE IF NOT EXISTS donation_receipt (
id BIGINT PRIMARY KEY COMMENT '主键ID(雪花算法生成)',
user_id VARCHAR(36) NOT NULL COMMENT '用户ID(关联登录用户)',
school_id BIGINT NOT NULL COMMENT '学校ID',
class_id BIGINT NOT NULL COMMENT '班级ID',
publication_id VARCHAR(64) COMMENT '公示编号(如:PUBLIC_20260801_001)',
org_id VARCHAR(128) COMMENT '受赠机构钱包ID(CORP_前缀+十六进制)',
org_name VARCHAR(200) COMMENT '受赠机构名称',
concatenated_message TEXT COMMENT '拼接公示原文(格式:PUBLIC|机构钱包ID|机构名称|公示标题|捐赠企业|金额|项目|时间戳)',
digest_algorithm VARCHAR(16) COMMENT '摘要算法(如:SM3)',
digest_value VARCHAR(128) COMMENT 'SM3摘要值(十六进制大写)',
publish_title VARCHAR(200) COMMENT '公示标题',
publish_content VARCHAR(500) COMMENT '公示内容',
publish_url VARCHAR(500) COMMENT '公示网站',
publish_time VARCHAR(20) COMMENT '公示时间,格式yyyyMMddHHmmss',
output_json TEXT COMMENT '输出JSON报文',
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态:DRAFT-草稿, CONCATENATED-已提取原文, DIGESTED-已生成摘要, PUBLISHED-已公示, OUTPUT-已输出',
created_at TIMESTAMP NOT NULL COMMENT '创建时间',
updated_at TIMESTAMP NOT NULL COMMENT '更新时间',
created_by VARCHAR(64) NOT NULL COMMENT '创建人',
updated_by VARCHAR(64) NOT NULL COMMENT '更新人',
deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记:false-未删除, true-已删除'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-受赠机构接收捐款与公示表';
-- 模块六:第三方监管监督与审核表 - 监管机构提取审核原文、生成摘要、SM2签名、备案、输出
CREATE TABLE IF NOT EXISTS donation_supervision (
id BIGINT PRIMARY KEY COMMENT '主键ID(雪花算法生成)',
user_id VARCHAR(36) NOT NULL COMMENT '用户ID(关联登录用户)',
school_id BIGINT NOT NULL COMMENT '学校ID',
class_id BIGINT NOT NULL COMMENT '班级ID',
approval_id VARCHAR(64) COMMENT '审核编号(如:APPROVE_20260801_001)',
donation_id VARCHAR(64) COMMENT '捐赠编号(关联donation表)',
supervisor VARCHAR(200) COMMENT '监管机构名称',
concatenated_message TEXT COMMENT '拼接审核原文(格式:SUPERVISE|捐赠编号|监管机构|金额|项目|时间戳)',
digest_algorithm VARCHAR(16) COMMENT '摘要算法(如:SM3)',
digest_value VARCHAR(128) COMMENT 'SM3摘要值(十六进制大写)',
sign_algorithm VARCHAR(16) COMMENT '签名算法(如:SM2)',
supervisor_signature TEXT COMMENT '监管机构SM2签名值(DER编码十六进制)',
approval_time VARCHAR(20) COMMENT '审核时间,格式yyyyMMddHHmmss',
record_number VARCHAR(64) COMMENT '备案编号(如:RECORD_20260801_001)',
output_json TEXT COMMENT '输出JSON报文',
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态:DRAFT-草稿, CONCATENATED-已提取原文, DIGESTED-已生成摘要, SIGNED-已签名, OUTPUT-已输出',
created_at TIMESTAMP NOT NULL COMMENT '创建时间',
updated_at TIMESTAMP NOT NULL COMMENT '更新时间',
created_by VARCHAR(64) NOT NULL COMMENT '创建人',
updated_by VARCHAR(64) NOT NULL COMMENT '更新人',
deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记:false-未删除, true-已删除'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-第三方监管监督与审核表';
-- 模块六:企业使用数字人民币进行公益捐赠 - 发起、公示、监管审核全流程合并记录表
CREATE TABLE IF NOT EXISTS donation_record (
id BIGINT PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,

@ -0,0 +1,25 @@
-- 实验成绩权重配置升级脚本:教师端总成绩权重与模块权重
CREATE TABLE IF NOT EXISTS training_school_score_weight (
id CHAR(36) PRIMARY KEY,
school_id VARCHAR(36) NOT NULL,
training_weight DECIMAL(5, 2) NOT NULL COMMENT '实验实训权重比',
report_weight DECIMAL(5, 2) NOT NULL COMMENT '实训报告权重比',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS training_module_weight (
id CHAR(36) PRIMARY KEY,
school_id VARCHAR(36) NOT NULL,
module1_weight DECIMAL(5, 2) NOT NULL COMMENT '中国人民银行生成数字货币',
module2_weight DECIMAL(5, 2) NOT NULL COMMENT '中国人民银行发行数字货币',
module3_weight DECIMAL(5, 2) NOT NULL COMMENT '开通数字货币钱包',
module4_weight DECIMAL(5, 2) NOT NULL COMMENT '用户兑换数字货币',
module5_weight DECIMAL(5, 2) NOT NULL COMMENT '用户支付数字货币',
module6_weight DECIMAL(5, 2) NOT NULL COMMENT '数字人民币创新应用场景仿真',
deduct_score DECIMAL(5, 2) NOT NULL COMMENT '选错/填错一个扣分',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT FALSE
);

@ -1269,7 +1269,6 @@ CREATE TABLE IF NOT EXISTS salary_batch (
deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记:false-未删除, true-已删除'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-工资发放批次表';
<<<<<<< HEAD
-- 模块六:捐赠企业发起捐赠表 - 捐赠企业向受赠机构发起捐赠全流程
CREATE TABLE IF NOT EXISTS donation (
id BIGINT PRIMARY KEY COMMENT '主键ID(雪花算法生成)',
@ -1347,7 +1346,7 @@ CREATE TABLE IF NOT EXISTS donation_supervision (
updated_by VARCHAR(64) NOT NULL COMMENT '更新人',
deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记:false-未删除, true-已删除'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-第三方监管监督与审核表';
=======
-- 模块六:企业使用数字人民币进行公益捐赠 - 发起、公示、监管审核全流程
CREATE TABLE IF NOT EXISTS donation_record (
id BIGINT PRIMARY KEY COMMENT '主键ID(雪花算法生成)',
@ -1391,7 +1390,6 @@ CREATE TABLE IF NOT EXISTS donation_record (
deleted BOOLEAN NOT NULL DEFAULT FALSE,
INDEX idx_donation_subject (user_id, school_id, class_id, deleted, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-公益捐赠记录表';
>>>>>>> 8af6f539bf42ee657bec4cc824118ff56d043811
-- 步骤四商业银行端:生成合约信息与开通确认
CREATE TABLE IF NOT EXISTS smart_contract_generation (
@ -1466,3 +1464,48 @@ CREATE TABLE IF NOT EXISTS experiment_statistics (
INSERT INTO experiment_statistics (id, total_experiments, completed_experiments, in_progress_experiments, not_started_experiments)
VALUES (1, 0, 0, 0, 0)
ON DUPLICATE KEY UPDATE id = id;
-- 教师端总成绩权重配置(实验实训 + 实训报告 = 1),一校一行
CREATE TABLE IF NOT EXISTS training_school_score_weight (
id CHAR(36) PRIMARY KEY,
school_id VARCHAR(36) NOT NULL,
training_weight DECIMAL(5, 2) NOT NULL COMMENT '实验实训权重比',
report_weight DECIMAL(5, 2) NOT NULL COMMENT '实训报告权重比',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 教师端模块权重配置,一校一行,6 个模块权重横向存储,合计 100%
CREATE TABLE IF NOT EXISTS training_module_weight (
id CHAR(36) PRIMARY KEY,
school_id VARCHAR(36) NOT NULL,
module1_weight DECIMAL(5, 2) NOT NULL COMMENT '中国人民银行生成数字货币',
module2_weight DECIMAL(5, 2) NOT NULL COMMENT '中国人民银行发行数字货币',
module3_weight DECIMAL(5, 2) NOT NULL COMMENT '开通数字货币钱包',
module4_weight DECIMAL(5, 2) NOT NULL COMMENT '用户兑换数字货币',
module5_weight DECIMAL(5, 2) NOT NULL COMMENT '用户支付数字货币',
module6_weight DECIMAL(5, 2) NOT NULL COMMENT '数字人民币创新应用场景仿真',
deduct_score DECIMAL(5, 2) NOT NULL COMMENT '选错/填错一个扣分',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 实训报告表:与实验模块解耦,一个用户可多次提交,按 user_id 查询
CREATE TABLE IF NOT EXISTS experiment_report (
id CHAR(36) PRIMARY KEY COMMENT '主键',
report_name VARCHAR(255) NOT NULL COMMENT '报告名称(上传时的原始文件名)',
file_url VARCHAR(512) NOT NULL COMMENT '文件地址:经转换后存储到服务器的 PDF 相对路径',
file_size BIGINT NOT NULL COMMENT '文件大小(字节)',
upload_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
teacher_score DECIMAL(5, 2) NULL COMMENT '教师评分',
teacher_comment VARCHAR(500) NULL COMMENT '教师评语',
user_id VARCHAR(36) NOT NULL COMMENT '用户ID',
school_id VARCHAR(36) NULL COMMENT '学校ID',
class_id VARCHAR(36) NULL COMMENT '班级ID',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
INDEX idx_experiment_report_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='实训报告表';

@ -4,6 +4,14 @@ import com.yau.digitalrmb.corporatewallet.infrastructure.CorporateWalletApplicat
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationRecordEntity;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationRecordMapper;
import com.yau.digitalrmb.corporatewallet.infrastructure.CorporateWalletApplicationEntity;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationEntity;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationMapper;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationReceiptEntity;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationReceiptMapper;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationRecordEntity;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationRecordMapper;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationSupervisionEntity;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationSupervisionMapper;
import com.yau.digitalrmb.corporatewallet.infrastructure.RemoteWalletApplicationEntity;
import com.yau.digitalrmb.corporatewallet.infrastructure.RemoteWalletApplicationMapper;
import com.yau.digitalrmb.corporatewallet.infrastructure.SalaryBatchEntity;
@ -35,7 +43,10 @@ class CorporateInnovationTrainingResetServiceTest {
@Mock private CorporateWalletApplicationMapper corporateWalletMapper;
@Mock private RemoteWalletApplicationMapper remoteWalletMapper;
@Mock private SalaryBatchMapper salaryBatchMapper;
@Mock private DonationMapper donationMapper;
@Mock private DonationRecordMapper donationRecordMapper;
@Mock private DonationReceiptMapper donationReceiptMapper;
@Mock private DonationSupervisionMapper donationSupervisionMapper;
@Mock private StuModuleScoreDetailsMapper scoreDetailsMapper;
@Mock private UserStepProgressNewService stepProgressService;
@InjectMocks private CorporateInnovationTrainingResetService service;
@ -46,7 +57,10 @@ class CorporateInnovationTrainingResetServiceTest {
TableInfoHelper.initTableInfo(assistant, CorporateWalletApplicationEntity.class);
TableInfoHelper.initTableInfo(assistant, RemoteWalletApplicationEntity.class);
TableInfoHelper.initTableInfo(assistant, SalaryBatchEntity.class);
TableInfoHelper.initTableInfo(assistant, DonationEntity.class);
TableInfoHelper.initTableInfo(assistant, DonationRecordEntity.class);
TableInfoHelper.initTableInfo(assistant, DonationReceiptEntity.class);
TableInfoHelper.initTableInfo(assistant, DonationSupervisionEntity.class);
TableInfoHelper.initTableInfo(assistant, StuModuleScoreDetailsEntity.class);
}
@ -55,13 +69,19 @@ class CorporateInnovationTrainingResetServiceTest {
when(corporateWalletMapper.update(any(), any())).thenReturn(2);
when(remoteWalletMapper.update(any(), any())).thenReturn(3);
when(salaryBatchMapper.update(any(), any())).thenReturn(4);
when(donationRecordMapper.update(any(), any())).thenReturn(5);
when(donationMapper.update(any(), any())).thenReturn(2);
when(donationReceiptMapper.update(any(), any())).thenReturn(2);
when(donationRecordMapper.update(any(), any())).thenReturn(1);
when(donationSupervisionMapper.update(any(), any())).thenReturn(1);
when(scoreDetailsMapper.update(any(), any())).thenReturn(1);
CorporateInnovationTrainingResetResult result = service.reset(SUBJECT);
assertThat(result.isReset()).isTrue();
assertThat(result.getDeletedRecordCount()).isEqualTo(15);
assertThat(result.getDeletedRecordCount()).isEqualTo(16);
verify(donationRecordMapper).update(any(), any());
verify(donationReceiptMapper).update(any(), any());
verify(donationSupervisionMapper).update(any(), any());
verify(stepProgressService).reset("reset-user", "innovation-scene");
}
}

@ -6,6 +6,7 @@ import com.yau.digitalrmb.corporatewallet.domain.SalaryBatch;
import com.yau.digitalrmb.corporatewallet.domain.SalaryBatchRepository;
import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject;
import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography;
import com.yau.digitalrmb.shared.exception.BusinessException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@ -17,6 +18,7 @@ import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
@ -64,17 +66,17 @@ class SalaryBatchServiceTest {
service.fetchBlockchain(SUBJECT, "tester");
service.concatenate(SUBJECT);
SalaryDigestResult firstDigest = service.computeDigest(SUBJECT);
SalaryDigestResult firstDigest = service.computeDigest(SUBJECT, "SM3");
SalaryBatch firstBatch = latest.get();
assertThat(firstBatch.getStatus()).isEqualTo(SalaryBatch.Status.DIGESTED);
SalarySignatureResult firstSignature = service.sign(SUBJECT, PRIVATE_KEY);
SalarySignatureResult firstSignature = service.sign(SUBJECT, PRIVATE_KEY, "SM2");
service.fetchBlockchain(SUBJECT, "tester");
service.concatenate(SUBJECT);
SalaryDigestResult secondDigest = service.computeDigest(SUBJECT);
SalaryDigestResult secondDigest = service.computeDigest(SUBJECT, "SM3");
SalaryBatch secondBatch = latest.get();
assertThat(secondBatch.getStatus()).isEqualTo(SalaryBatch.Status.DIGESTED);
SalarySignatureResult secondSignature = service.sign(SUBJECT, PRIVATE_KEY);
SalarySignatureResult secondSignature = service.sign(SUBJECT, PRIVATE_KEY, "SM2");
assertThat(secondBatch).isNotSameAs(firstBatch);
assertThat(secondBatch.getId()).isNotEqualTo(firstBatch.getId());
@ -87,4 +89,52 @@ class SalaryBatchServiceTest {
verify(cryptography).sign(PRIVATE_KEY, "DIGEST_ONE");
verify(cryptography).sign(PRIVATE_KEY, "DIGEST_TWO");
}
@Test
void recordsErrorWhenDigestOrSignAlgorithmIsWrong() {
AtomicReference<SalaryBatch> latest = new AtomicReference<>();
AtomicLong ids = new AtomicLong(20L);
when(repository.save(any(SalaryBatch.class))).thenAnswer(invocation -> {
SalaryBatch batch = invocation.getArgument(0);
batch.setId(ids.incrementAndGet());
latest.set(batch);
return batch;
});
when(repository.findLatest(SUBJECT.getUserId(), SUBJECT.getSchoolId(), SUBJECT.getClassId()))
.thenAnswer(invocation -> Optional.ofNullable(latest.get()));
CorporateWalletApplication wallet = CorporateWalletApplication.create(
SUBJECT.getUserId(), SUBJECT.getSchoolId(), SUBJECT.getClassId());
wallet.setWalletId("CORP_WALLET_002");
when(corporateWalletRepository.findLatest(
SUBJECT.getUserId(), SUBJECT.getSchoolId(), SUBJECT.getClassId()))
.thenReturn(Optional.of(wallet));
when(cryptography.sm3(any(String.class))).thenReturn("DIGEST_ALG_CHECK");
when(keyService.getOrCreatePrivateKey(
any(InstitutionKeySubject.class), eq("数币操作员私钥"), eq("王五")))
.thenReturn(PRIVATE_KEY);
when(cryptography.sign(PRIVATE_KEY, "DIGEST_ALG_CHECK")).thenReturn("SIG_ALG_CHECK");
service.fetchBlockchain(SUBJECT, "tester");
service.concatenate(SUBJECT);
// 摘要算法选错(如 SHA256):拒绝生成摘要并错误数+1
assertThatThrownBy(() -> service.computeDigest(SUBJECT, "SHA256"))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("算法选择有误");
verify(scoreService).recordError(SUBJECT.getUserId());
// 正常算法通过
assertThat(service.computeDigest(SUBJECT, "SM3").getDigestValue()).isEqualTo("DIGEST_ALG_CHECK");
// 签名算法选错(如 RSA):拒绝签名并错误数+1
assertThatThrownBy(() -> service.sign(SUBJECT, PRIVATE_KEY, "RSA"))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("签名算法选择有误");
verify(scoreService, times(2)).recordError(SUBJECT.getUserId());
// 算法正确时正常签名,不再计错误
assertThat(service.sign(SUBJECT, PRIVATE_KEY, "SM2").getSignature()).isEqualTo("SIG_ALG_CHECK");
verify(scoreService, times(2)).recordError(SUBJECT.getUserId());
}
}

@ -0,0 +1,196 @@
package com.yau.digitalrmb.training.experimentreport.interfaces.rest;
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.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
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.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
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 ExperimentReportControllerTest {
private static final String USER = "00000000-0000-0000-0000-000000000941";
private static final String OTHER_USER = "00000000-0000-0000-0000-000000000942";
private static final String SCHOOL = "1001";
private static final String OTHER_SCHOOL = "1002";
private static final String STORED_PATH = "/2026/09/29/abcdef.pdf";
/** 1x1 透明 PNG,用于验证“图片自动转 PDF”链路。 */
private static final byte[] PNG_BYTES = java.util.Base64.getDecoder().decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");
@Autowired private MockMvc mockMvc;
@Autowired private JdbcTemplate jdbc;
@MockBean
private com.yau.digitalrmb.shared.config.IFileUtil fileUtil;
@BeforeEach
void seed() {
clean();
insertUser(USER, "report-a", SCHOOL);
insertUser(OTHER_USER, "report-b", OTHER_SCHOOL);
when(fileUtil.upload(anyString(), any(java.io.InputStream.class))).thenReturn(STORED_PATH);
}
@AfterEach
void tearDown() {
clean();
}
@Test
void uploadImageConvertsToPdfAndStoresRow() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "实验报告.png",
"image/png", PNG_BYTES);
mockMvc.perform(multipart("/api/v1/experiment-reports").file(file).with(currentUser()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.id").isNotEmpty())
.andExpect(jsonPath("$.data.reportName").value("实验报告.png"))
.andExpect(jsonPath("$.data.fileUrl").value(STORED_PATH))
.andExpect(jsonPath("$.data.fileSize").value(PNG_BYTES.length))
.andExpect(jsonPath("$.data.userId").value(USER))
.andExpect(jsonPath("$.data.schoolId").value(SCHOOL))
.andExpect(jsonPath("$.data.classId").value("2001"))
.andExpect(jsonPath("$.data.teacherScore").isEmpty());
assertThat(jdbc.queryForObject(
"SELECT COUNT(*) FROM experiment_report WHERE user_id=?", Integer.class, USER)).isEqualTo(1);
assertThat(jdbc.queryForObject(
"SELECT teacher_score FROM experiment_report WHERE user_id=?", Integer.class, USER)).isNull();
assertThat(jdbc.queryForObject(
"SELECT deleted FROM experiment_report WHERE user_id=?", Boolean.class, USER)).isFalse();
}
@Test
void rejectsEmptyFile() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "empty.png",
"image/png", new byte[0]);
mockMvc.perform(multipart("/api/v1/experiment-reports").file(file).with(currentUser()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
}
@Test
void secondUploadOverwritesExistingReportInsteadOfInsertingNew() throws Exception {
// 首次上传
MockMultipartFile first = new MockMultipartFile("file", "实验报告.png",
"image/png", PNG_BYTES);
mockMvc.perform(multipart("/api/v1/experiment-reports").file(first).with(currentUser()))
.andExpect(status().isOk());
// 取出首传 id,再传一次
String firstId = jdbc.queryForObject(
"SELECT id FROM experiment_report WHERE user_id=?", String.class, USER);
MockMultipartFile second = new MockMultipartFile("file", "实验报告V2.png",
"image/png", PNG_BYTES);
mockMvc.perform(multipart("/api/v1/experiment-reports").file(second).with(currentUser()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").value(firstId))
.andExpect(jsonPath("$.data.reportName").value("实验报告V2.png"));
// 仍为一条有效报告,且文件名已更新
assertThat(jdbc.queryForObject(
"SELECT COUNT(*) FROM experiment_report WHERE user_id=?", Integer.class, USER)).isEqualTo(1);
assertThat(jdbc.queryForObject(
"SELECT report_name FROM experiment_report WHERE user_id=?", String.class, USER))
.isEqualTo("实验报告V2.png");
}
@Test
void rejectsUploadAfterTeacherScored() throws Exception {
// 教师先评分
insertReport(USER, SCHOOL, "报告A.png");
String reportId = jdbc.queryForObject(
"SELECT id FROM experiment_report WHERE user_id=?", String.class, USER);
jdbc.update("UPDATE experiment_report SET teacher_score=88 WHERE id=?", reportId);
MockMultipartFile file = new MockMultipartFile("file", "实验报告.png",
"image/png", PNG_BYTES);
mockMvc.perform(multipart("/api/v1/experiment-reports").file(file).with(currentUser()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
// 报告未被替换,仍是教师评分后的那一份
assertThat(jdbc.queryForObject(
"SELECT COUNT(*) FROM experiment_report WHERE user_id=?", Integer.class, USER)).isEqualTo(1);
assertThat(jdbc.queryForObject(
"SELECT teacher_score FROM experiment_report WHERE user_id=?", Integer.class, USER))
.isEqualTo(88);
}
@Test
void listReturnsOnlyCurrentUserReports() throws Exception {
insertReport(USER, SCHOOL, "报告A.png");
insertReport(OTHER_USER, OTHER_SCHOOL, "报告B.png");
mockMvc.perform(get("/api/v1/experiment-reports").with(currentUser()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[0].reportName").value("报告A.png"))
.andExpect(jsonPath("$.data[0].schoolId").value(SCHOOL));
mockMvc.perform(get("/api/v1/experiment-reports").with(otherUser()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[0].reportName").value("报告B.png"));
}
@Test
void rejectsAnonymousAccess() throws Exception {
mockMvc.perform(get("/api/v1/experiment-reports")).andExpect(status().isUnauthorized());
mockMvc.perform(multipart("/api/v1/experiment-reports")
.file(new MockMultipartFile("file", "x.png", "image/png", PNG_BYTES)))
.andExpect(status().isUnauthorized());
}
private RequestPostProcessor currentUser() {
return jwt().jwt(token -> token.subject(USER).claim("userId", USER)
.claim("preferred_username", "report-a"));
}
private RequestPostProcessor otherUser() {
return jwt().jwt(token -> token.subject(OTHER_USER).claim("userId", OTHER_USER)
.claim("preferred_username", "report-b"));
}
private void insertUser(String id, String account, String schoolId) {
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,?)",
id, account, "unused", account, "2001", schoolId, account);
}
private void insertReport(String userId, String schoolId, String reportName) {
jdbc.update("INSERT INTO experiment_report (id,report_name,file_url,file_size,upload_time,user_id,"
+ "school_id,class_id,created_at,updated_at,deleted) VALUES (?,?,?,?,CURRENT_TIMESTAMP,?,?,"
+ "'2001',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,FALSE)",
java.util.UUID.randomUUID().toString(), reportName, STORED_PATH, 1024L, userId, schoolId);
}
private void clean() {
jdbc.update("DELETE FROM experiment_report WHERE user_id IN (?,?)", USER, OTHER_USER);
jdbc.update("DELETE FROM sys_user WHERE user_id IN (?,?)", USER, OTHER_USER);
}
}

@ -0,0 +1,98 @@
package com.yau.digitalrmb.training.experimentreport.teacher.interfaces.rest;
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.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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 TeacherExperimentReportControllerTest {
private static final String TEACHER = "00000000-0000-0000-0000-000000000971";
private static final String STUDENT_A = "00000000-0000-0000-0000-000000000972";
private static final String STUDENT_B = "00000000-0000-0000-0000-000000000973";
private static final String SCHOOL = "1071";
@Autowired private MockMvc mockMvc;
@Autowired private JdbcTemplate jdbc;
@BeforeEach
void seed() {
clean();
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 (?,?,?,?,?,?,?,?,?,0,?)",
TEACHER, "tea-1", "unused", "李老师", "2071", "教师测班1", SCHOOL, "延安大学", 2, TEACHER);
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 (?,?,?,?,?,?,?,?,?,0,?)",
STUDENT_A, "stu-a", "unused", "学生甲", "2071", "教师测班1", SCHOOL, "延安大学", 4, STUDENT_A);
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 (?,?,?,?,?,?,?,?,?,0,?)",
STUDENT_B, "stu-b", "unused", "学生乙", "2072", "教师测班2", SCHOOL, "延安大学", 4, STUDENT_B);
jdbc.update("INSERT INTO experiment_report (id,report_name,file_url,file_size,upload_time,teacher_score,"
+ "user_id,school_id,class_id,created_at,updated_at,deleted) "
+ "VALUES ('r-a','报告A.pdf','/2026/9/29/a.pdf',2048,CURRENT_TIMESTAMP,85.00,?,?,?,"
+ "CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,FALSE)", STUDENT_A, SCHOOL, "2071");
jdbc.update("INSERT INTO experiment_report (id,report_name,file_url,file_size,upload_time,teacher_score,"
+ "user_id,school_id,class_id,created_at,updated_at,deleted) "
+ "VALUES ('r-b','报告B.pdf','/2026/9/29/b.pdf',4096,CURRENT_TIMESTAMP,NULL,?,?,?,"
+ "CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,FALSE)", STUDENT_B, SCHOOL, "2072");
}
@AfterEach
void tearDown() {
clean();
}
@Test
void returnsPagedReportsForSchool() throws Exception {
mockMvc.perform(get("/api/v1/experiment-reports/teacher").param("page", "1").param("size", "10").with(teacher()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.total").value(2))
.andExpect(jsonPath("$.data.records.length()").value(2))
.andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].reportName").value("报告A.pdf"))
.andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].teacherScore").value(85.00))
.andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-b')].classId").value("2072"));
}
@Test
void filtersReportsByStudentIdAndClass() throws Exception {
mockMvc.perform(get("/api/v1/experiment-reports/teacher").param("studentId", "stu-a").with(teacher()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.records[0].studentId").value("stu-a"));
mockMvc.perform(get("/api/v1/experiment-reports/teacher").param("classId", "2072").with(teacher()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.records[0].classId").value("2072"));
}
@Test
void rejectsAnonymousAccess() throws Exception {
mockMvc.perform(get("/api/v1/experiment-reports/teacher")).andExpect(status().isUnauthorized());
}
private RequestPostProcessor teacher() {
return jwt().jwt(token -> token.subject(TEACHER).claim("userId", TEACHER)
.claim("preferred_username", "tea-1"));
}
private void clean() {
jdbc.update("DELETE FROM experiment_report WHERE user_id IN (?,?)", STUDENT_A, STUDENT_B);
jdbc.update("DELETE FROM sys_user WHERE user_id IN (?,?,?)", TEACHER, STUDENT_A, STUDENT_B);
}
}

@ -0,0 +1,119 @@
package com.yau.digitalrmb.training.score.interfaces.rest;
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.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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 TrainingOverallScoreControllerTest {
private static final String USER = "00000000-0000-0000-0000-000000000951";
private static final String SCHOOL = "1051";
private static final String MODULE_ONE = "中国人民银行生成数字货币";
private static final String MODULE_THREE = "开通数字货币钱包";
@Autowired private MockMvc mockMvc;
@Autowired private JdbcTemplate jdbc;
@BeforeEach
void seed() {
clean();
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,?)",
USER, "overall-a", "unused", "overall-a", "2051", SCHOOL, "overall-a");
// 模块一:最高分 98(含一条已删除 30)→ 取 98;进度 50
jdbc.update("INSERT INTO stu_module_score_details (id,moudule,learning_projects,assessment_items,"
+ "scoring_criteria,completion_status,score_project,user_id,total_score,serial_number,schedule,deleted) "
+ "VALUES (?,?,'实验实训','错误次数','0.5','1',?,?,100.00,?,?,?)",
"ov-1", MODULE_ONE, 98.00, USER, 1, 50.00, false);
jdbc.update("INSERT INTO stu_module_score_details (id,moudule,learning_projects,assessment_items,"
+ "scoring_criteria,completion_status,score_project,user_id,total_score,serial_number,schedule,deleted) "
+ "VALUES (?,?,'实验实训','错误次数','0.5','1',?,?,100.00,?,?,?)",
"ov-2", MODULE_ONE, 30.00, USER, 2, 50.00, true);
// 模块三:最高分 70;进度 50
jdbc.update("INSERT INTO stu_module_score_details (id,moudule,learning_projects,assessment_items,"
+ "scoring_criteria,completion_status,score_project,user_id,total_score,serial_number,schedule,deleted) "
+ "VALUES (?,?,'实验实训','错误次数','0.5','1',?,?,100.00,?,?,?)",
"ov-3", MODULE_THREE, 70.00, USER, 3, 50.00, false);
// 其余 4 个模块无数据,进度/成绩按 0,仍纳入 /6
}
@AfterEach
void tearDown() {
clean();
}
@Test
void overallWithoutReport() throws Exception {
// 实训成绩 = 0.30*98 + 0.10*70 = 36.40;实训进度 = (50+0+50+0+0+0)/6 = 16.67
// 实训最终 = 16.67/100 * 0.80 * 36.40 = 4.85;无报告 → 报告与总成绩均为 0/4.85
mockMvc.perform(get("/api/v1/training-scores/overall").with(currentUser()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.items.length()").value(3))
.andExpect(jsonPath("$.data.items[0].project").value("实验实训"))
.andExpect(jsonPath("$.data.items[0].score").value(36.40))
.andExpect(jsonPath("$.data.items[0].weight").value(0.80))
.andExpect(jsonPath("$.data.items[0].progress").value(16.67))
.andExpect(jsonPath("$.data.items[0].finalScore").value(4.85))
.andExpect(jsonPath("$.data.items[1].project").value("实验报告"))
.andExpect(jsonPath("$.data.items[1].score").value(0))
.andExpect(jsonPath("$.data.items[1].weight").value(0.20))
.andExpect(jsonPath("$.data.items[1].progress").value(0))
.andExpect(jsonPath("$.data.items[1].finalScore").value(0))
.andExpect(jsonPath("$.data.items[2].project").value("总成绩"))
.andExpect(jsonPath("$.data.items[2].score").doesNotExist())
.andExpect(jsonPath("$.data.items[2].progress").doesNotExist())
.andExpect(jsonPath("$.data.items[2].finalScore").value(4.85))
.andExpect(jsonPath("$.data.totalFinal").value(4.85));
}
@Test
void overallWithGradedReport() throws Exception {
// 有报告且教师评分 90 → 报告进度 100,成绩 90,报告最终 = 100/100*0.20*90 = 18.00
// 总成绩 = 4.85 + 18.00 = 22.85
jdbc.update("INSERT INTO experiment_report (id,report_name,file_url,file_size,upload_time,teacher_score,"
+ "user_id,school_id,class_id,created_at,updated_at,deleted) "
+ "VALUES ('r-1','报告.pdf','/2026/9/29/r1.pdf',1024,CURRENT_TIMESTAMP,90.00,?,?,?,"
+ "CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,FALSE)",
USER, SCHOOL, "2051");
mockMvc.perform(get("/api/v1/training-scores/overall").with(currentUser()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[1].score").value(90.00))
.andExpect(jsonPath("$.data.items[1].progress").value(100))
.andExpect(jsonPath("$.data.items[1].finalScore").value(18.00))
.andExpect(jsonPath("$.data.items[2].finalScore").value(22.85))
.andExpect(jsonPath("$.data.totalFinal").value(22.85));
}
@Test
void rejectsAnonymousAccess() throws Exception {
mockMvc.perform(get("/api/v1/training-scores/overall")).andExpect(status().isUnauthorized());
}
private RequestPostProcessor currentUser() {
return jwt().jwt(token -> token.subject(USER).claim("userId", USER)
.claim("preferred_username", "overall-a"));
}
private void clean() {
jdbc.update("DELETE FROM stu_module_score_details WHERE user_id=?", USER);
jdbc.update("DELETE FROM experiment_report WHERE user_id=?", USER);
jdbc.update("DELETE FROM training_module_weight WHERE school_id=?", SCHOOL);
jdbc.update("DELETE FROM training_school_score_weight WHERE school_id=?", SCHOOL);
jdbc.update("DELETE FROM sys_user WHERE user_id=?", USER);
}
}

@ -0,0 +1,291 @@
package com.yau.digitalrmb.training.score.interfaces.rest;
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.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class TrainingScoreControllerTest {
private static final String USER = "00000000-0000-0000-0000-000000000931";
private static final String OTHER_USER = "00000000-0000-0000-0000-000000000932";
private static final String SCHOOL = "1001";
private static final String OTHER_SCHOOL = "1002";
/** 成绩表模块名(模块一:中国人民银行生成数字货币)。 */
private static final String MODULE_ONE = "中国人民银行生成数字货币";
/** 成绩表模块名(模块三:开通数字货币钱包)。 */
private static final String MODULE_THREE = "开通数字货币钱包";
@Autowired private MockMvc mockMvc;
@Autowired private JdbcTemplate jdbc;
@BeforeEach
void seed() {
clean();
insertUser(USER, "score-a", SCHOOL);
insertUser(OTHER_USER, "score-b", OTHER_SCHOOL);
// 模块一:2 条有效 + 1 条已删除 → 操作次数 3,当前操作次数 2
insertScore("score-a-1", USER, MODULE_ONE, 1, 98.00, false);
insertScore("score-a-2", USER, MODULE_ONE, 2, 52.00, false);
insertScore("score-a-3", USER, MODULE_ONE, 3, 30.00, true);
// 模块三:1 条有效
insertScore("score-a-4", USER, MODULE_THREE, 3, 70.00, false);
// 其它用户的模块一数据,不应出现在当前用户结果中
insertScore("score-b-1", OTHER_USER, MODULE_ONE, 1, 55.00, false);
}
@AfterEach
void tearDown() {
clean();
}
@Test
void returnsSixModulesWithDefaultsWhenNoConfig() throws Exception {
mockMvc.perform(get("/api/v1/training-scores").with(currentUser()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.length()").value(6))
.andExpect(jsonPath("$.data[0].mouduleCode").value(1))
.andExpect(jsonPath("$.data[0].moudule").value("中国人民银行生成数字货币"))
.andExpect(jsonPath("$.data[0].scoringCriteria").value("选错/填错一个扣 0.5 分"))
.andExpect(jsonPath("$.data[0].moduleWeight").value(0.30))
.andExpect(jsonPath("$.data[0].operationCount").value(3))
.andExpect(jsonPath("$.data[0].currentCount").value(2))
.andExpect(jsonPath("$.data[0].maxScore").value(98.00))
.andExpect(jsonPath("$.data[0].minScore").value(30.00))
.andExpect(jsonPath("$.data[0].avgScore").value(60.00))
.andExpect(jsonPath("$.data[0].details.length()").value(2))
.andExpect(jsonPath("$.data[1].moduleWeight").value(0.25))
.andExpect(jsonPath("$.data[1].operationCount").value(0))
.andExpect(jsonPath("$.data[1].details.length()").value(0))
.andExpect(jsonPath("$.data[5].mouduleCode").value(6))
.andExpect(jsonPath("$.data[5].moduleWeight").value(0.15));
// 无配置时成绩查询本身应为本校落库一条默认模块权重配置(含默认扣分)
assertThat(countModuleWeight(SCHOOL)).isEqualTo(1);
assertThat(jdbc.queryForObject(
"SELECT deduct_score FROM training_module_weight WHERE school_id=?", BigDecimal.class, SCHOOL))
.isEqualByComparingTo("0.50");
}
@Test
void returnsSingleModuleWhenModuleProvided() throws Exception {
mockMvc.perform(get("/api/v1/training-scores")
.param("moudule", "开通数字货币钱包").with(currentUser()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[0].mouduleCode").value(3))
.andExpect(jsonPath("$.data[0].operationCount").value(1))
.andExpect(jsonPath("$.data[0].currentCount").value(1))
.andExpect(jsonPath("$.data[0].maxScore").value(70.00))
.andExpect(jsonPath("$.data[0].details[0].id").value("score-a-4"))
.andExpect(jsonPath("$.data[0].details[0].userId").value(USER));
}
@Test
void hidesOtherUserScoresAndDeletedDetails() throws Exception {
mockMvc.perform(get("/api/v1/training-scores").with(otherUser()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].operationCount").value(1))
.andExpect(jsonPath("$.data[0].currentCount").value(1))
.andExpect(jsonPath("$.data[0].details[0].id").value("score-b-1"));
assertThat(jdbc.queryForObject(
"SELECT COUNT(*) FROM stu_module_score_details WHERE user_id=? AND moudule=?",
Integer.class, USER, MODULE_ONE)).isEqualTo(3);
}
@Test
void usesTeacherModuleWeightAndDeductScore() throws Exception {
insertModuleWeight(SCHOOL, 0.50, 0.25, 0.05, 0.05, 0.05, 0.10, 1);
mockMvc.perform(get("/api/v1/training-scores").with(currentUser()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].scoringCriteria").value("选错/填错一个扣 1 分"))
.andExpect(jsonPath("$.data[0].moduleWeight").value(0.50))
.andExpect(jsonPath("$.data[1].moduleWeight").value(0.25));
mockMvc.perform(get("/api/v1/training-scores").with(otherUser()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].scoringCriteria").value("选错/填错一个扣 0.5 分"))
.andExpect(jsonPath("$.data[0].moduleWeight").value(0.30));
}
@Test
void firstQuerySeedsDefaultSchoolWeightThenUpdatesById() throws Exception {
// 首次查询:库中无数据 → 落库一条默认配置
mockMvc.perform(get("/api/v1/training-weights/school").with(currentUser()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").isNotEmpty())
.andExpect(jsonPath("$.data.trainingWeight").value(0.80))
.andExpect(jsonPath("$.data.reportWeight").value(0.20))
.andExpect(jsonPath("$.data.configured").value(false));
assertThat(countSchoolWeight(SCHOOL)).isEqualTo(1);
String schoolWeightId = jdbc.queryForObject(
"SELECT id FROM training_school_score_weight WHERE school_id=?", String.class, SCHOOL);
// 再次查询:命中已有配置
mockMvc.perform(get("/api/v1/training-weights/school").with(currentUser()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").value(schoolWeightId))
.andExpect(jsonPath("$.data.configured").value(true));
// 按 id 修改
mockMvc.perform(post("/api/v1/training-weights/school").with(currentUser())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"id\":\"" + schoolWeightId + "\",\"trainingWeight\":0.70,"
+ "\"reportWeight\":0.30}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.trainingWeight").value(0.70))
.andExpect(jsonPath("$.data.reportWeight").value(0.30))
.andExpect(jsonPath("$.data.configured").value(true));
// 两次修改后仍只有一条记录
assertThat(countSchoolWeight(SCHOOL)).isEqualTo(1);
// 权重和不等于 1 → 400
mockMvc.perform(post("/api/v1/training-weights/school").with(currentUser())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"id\":\"" + schoolWeightId + "\",\"trainingWeight\":0.70,"
+ "\"reportWeight\":0.20}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
// 改别的学校的记录 → 403
jdbc.update("INSERT INTO training_school_score_weight (id,school_id,training_weight,report_weight,"
+ "created_at,updated_at,deleted) VALUES ('w-other',?,0.50,0.50,CURRENT_TIMESTAMP,"
+ "CURRENT_TIMESTAMP,FALSE)", OTHER_SCHOOL);
mockMvc.perform(post("/api/v1/training-weights/school").with(currentUser())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"id\":\"w-other\",\"trainingWeight\":0.70,\"reportWeight\":0.30}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(403));
// id 不存在 → 404
mockMvc.perform(post("/api/v1/training-weights/school").with(currentUser())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"id\":\"not-exist\",\"trainingWeight\":0.70,\"reportWeight\":0.30}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(404));
}
@Test
void firstQuerySeedsDefaultModuleWeightsThenUpdatesById() throws Exception {
mockMvc.perform(get("/api/v1/training-weights/modules").with(currentUser()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").isNotEmpty())
.andExpect(jsonPath("$.data.deductScore").value(0.50))
.andExpect(jsonPath("$.data.configured").value(false))
.andExpect(jsonPath("$.data.modules.length()").value(6))
.andExpect(jsonPath("$.data.modules[0].mouduleCode").value(1))
.andExpect(jsonPath("$.data.modules[0].weight").value(0.30));
assertThat(countModuleWeight(SCHOOL)).isEqualTo(1);
String moduleWeightId = jdbc.queryForObject(
"SELECT id FROM training_module_weight WHERE school_id=?", String.class, SCHOOL);
String valid = "{\"id\":\"" + moduleWeightId + "\",\"deductScore\":0.25,\"modules\":["
+ "{\"moudule\":\"中国人民银行生成数字货币\",\"mouduleCode\":1,\"weight\":0.20},"
+ "{\"moudule\":\"中国人民银行发行数字货币\",\"mouduleCode\":2,\"weight\":0.20},"
+ "{\"moudule\":\"开通数字货币钱包\",\"mouduleCode\":3,\"weight\":0.15},"
+ "{\"moudule\":\"用户兑换数字货币\",\"mouduleCode\":4,\"weight\":0.15},"
+ "{\"moudule\":\"用户支付数字货币\",\"mouduleCode\":5,\"weight\":0.15},"
+ "{\"moudule\":\"数字人民币创新应用场景仿真\",\"mouduleCode\":6,\"weight\":0.15}]}";
mockMvc.perform(post("/api/v1/training-weights/modules").with(currentUser())
.contentType(MediaType.APPLICATION_JSON).content(valid))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.deductScore").value(0.25))
.andExpect(jsonPath("$.data.configured").value(true))
.andExpect(jsonPath("$.data.modules[0].weight").value(0.20))
.andExpect(jsonPath("$.data.modules[1].weight").value(0.20));
assertThat(countModuleWeight(SCHOOL)).isEqualTo(1);
String invalidSum = valid.replace("\"mouduleCode\":6,\"weight\":0.15", "\"mouduleCode\":6,\"weight\":0.10");
mockMvc.perform(post("/api/v1/training-weights/modules").with(currentUser())
.contentType(MediaType.APPLICATION_JSON).content(invalidSum))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
String invalidName = valid.replace("\"moudule\":\"用户支付数字货币\"", "\"moudule\":\"支付模块\"");
mockMvc.perform(post("/api/v1/training-weights/modules").with(currentUser())
.contentType(MediaType.APPLICATION_JSON).content(invalidName))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
// 学生端成绩查询读到新权重与新扣分
mockMvc.perform(get("/api/v1/training-scores").with(currentUser()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].moduleWeight").value(0.20))
.andExpect(jsonPath("$.data[0].scoringCriteria").value("选错/填错一个扣 0.25 分"));
}
@Test
void rejectsAnonymousAccess() throws Exception {
mockMvc.perform(get("/api/v1/training-scores")).andExpect(status().isUnauthorized());
mockMvc.perform(get("/api/v1/training-weights/school")).andExpect(status().isUnauthorized());
}
private RequestPostProcessor currentUser() {
return jwt().jwt(token -> token.subject(USER).claim("userId", USER)
.claim("preferred_username", "score-a"));
}
private RequestPostProcessor otherUser() {
return jwt().jwt(token -> token.subject(OTHER_USER).claim("userId", OTHER_USER)
.claim("preferred_username", "score-b"));
}
private void insertUser(String id, String account, String schoolId) {
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,?)",
id, account, "unused", account, "2001", schoolId, account);
}
private void insertScore(String id, String userId, String module, int serialNumber, double score,
boolean deleted) {
jdbc.update("INSERT INTO stu_module_score_details (id,moudule,learning_projects,assessment_items,"
+ "scoring_criteria,completion_status,score_project,user_id,total_score,serial_number,schedule,deleted) "
+ "VALUES (?,?,'实验实训','错误次数','0.5','1',?,?,100.00,?,?,?)",
id, module, score, userId, serialNumber, 50.00, deleted);
}
private int countSchoolWeight(String schoolId) {
return jdbc.queryForObject("SELECT COUNT(*) FROM training_school_score_weight WHERE school_id=?",
Integer.class, schoolId);
}
private int countModuleWeight(String schoolId) {
return jdbc.queryForObject("SELECT COUNT(*) FROM training_module_weight WHERE school_id=?",
Integer.class, schoolId);
}
private void insertModuleWeight(String schoolId, double w1, double w2, double w3, double w4, double w5,
double w6, double deductScore) {
jdbc.update("INSERT INTO training_module_weight (id,school_id,module1_weight,module2_weight,"
+ "module3_weight,module4_weight,module5_weight,module6_weight,deduct_score,created_at,"
+ "updated_at,deleted) VALUES ('w-2',?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,FALSE)",
schoolId, w1, w2, w3, w4, w5, w6, deductScore);
}
private void clean() {
jdbc.update("DELETE FROM stu_module_score_details WHERE user_id IN (?,?)", USER, OTHER_USER);
jdbc.update("DELETE FROM training_module_weight WHERE school_id IN (?,?)", SCHOOL, OTHER_SCHOOL);
jdbc.update("DELETE FROM training_school_score_weight WHERE school_id IN (?,?)", SCHOOL, OTHER_SCHOOL);
jdbc.update("DELETE FROM sys_user WHERE user_id IN (?,?)", USER, OTHER_USER);
}
}

@ -0,0 +1,189 @@
package com.yau.digitalrmb.training.score.teacher.interfaces.rest;
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.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
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 TrainingScoreTeacherControllerTest {
private static final String TEACHER = "00000000-0000-0000-0000-000000000971";
private static final String STUDENT_A = "00000000-0000-0000-0000-000000000972";
private static final String STUDENT_B = "00000000-0000-0000-0000-000000000973";
private static final String SCHOOL = "1071";
private static final String MODULE_ONE = "中国人民银行生成数字货币";
private static final String MODULE_THREE = "开通数字货币钱包";
@Autowired private MockMvc mockMvc;
@Autowired private JdbcTemplate jdbc;
@BeforeEach
void seed() {
clean();
// 教师(登录用户):只需有学校归属,角色不限
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 (?,?,?,?,?,?,?,?,?,0,?)",
TEACHER, "tea-1", "unused", "李老师", "2071", "教师测班1", SCHOOL, "延安大学", 2, TEACHER);
// 学生 A:模块1最高98(含一条删除30)、模块3=70、进度均50、有报告评分90
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 (?,?,?,?,?,?,?,?,?,0,?)",
STUDENT_A, "stu-a", "unused", "学生甲", "2071", "教师测班1", SCHOOL, "延安大学", 4, STUDENT_A);
// 学生 B:无任何成绩、无报告
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 (?,?,?,?,?,?,?,?,?,0,?)",
STUDENT_B, "stu-b", "unused", "学生乙", "2072", "教师测班2", SCHOOL, "延安大学", 4, STUDENT_B);
jdbc.update("INSERT INTO stu_module_score_details (id,moudule,learning_projects,assessment_items,"
+ "scoring_criteria,completion_status,score_project,user_id,total_score,serial_number,schedule,deleted) "
+ "VALUES (?,?,'实验实训','错误次数','0.5','1',?,?,100.00,?,?,?)",
"t-a-1", MODULE_ONE, 98.00, STUDENT_A, 1, 50.00, false);
jdbc.update("INSERT INTO stu_module_score_details (id,moudule,learning_projects,assessment_items,"
+ "scoring_criteria,completion_status,score_project,user_id,total_score,serial_number,schedule,deleted) "
+ "VALUES (?,?,'实验实训','错误次数','0.5','1',?,?,100.00,?,?,?)",
"t-a-2", MODULE_ONE, 30.00, STUDENT_A, 2, 50.00, true);
jdbc.update("INSERT INTO stu_module_score_details (id,moudule,learning_projects,assessment_items,"
+ "scoring_criteria,completion_status,score_project,user_id,total_score,serial_number,schedule,deleted) "
+ "VALUES (?,?,'实验实训','错误次数','0.5','1',?,?,100.00,?,?,?)",
"t-a-3", MODULE_THREE, 70.00, STUDENT_A, 3, 50.00, false);
jdbc.update("INSERT INTO experiment_report (id,report_name,file_url,file_size,upload_time,teacher_score,"
+ "user_id,school_id,class_id,created_at,updated_at,deleted) "
+ "VALUES ('r-t1','报告.pdf','/2026/9/29/r.pdf',1024,CURRENT_TIMESTAMP,90.00,?,?,?,"
+ "CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,FALSE)",
STUDENT_A, SCHOOL, "2071");
}
@AfterEach
void tearDown() {
clean();
}
@Test
void returnsPagedStudentsWithOverall() throws Exception {
// 学生甲:实训成绩 0.30*98+0.10*70=36.40,进度 16.67,实训最终 4.85;报告 90→18.00;总 22.85
mockMvc.perform(get("/api/v1/training-scores/teacher/overall").param("page", "1").param("size", "10")
.with(teacher()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.total").value(2))
.andExpect(jsonPath("$.data.records.length()").value(2))
.andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].userName").value("学生甲"))
.andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].experimentTrainingScore").value(36.40))
.andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].experimentTrainingWeight").value(0.80))
.andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].experimentReportScore").value(90.00))
.andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].experimentReportWeight").value(0.20))
.andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].finalScore").value(22.85))
.andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-b')].experimentTrainingScore").value(0.0))
.andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-b')].finalScore").value(0.0));
}
@Test
void filtersByStudentIdAndClassId() throws Exception {
mockMvc.perform(get("/api/v1/training-scores/teacher/overall").param("studentId", "stu-a").with(teacher()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.records[0].studentId").value("stu-a"));
mockMvc.perform(get("/api/v1/training-scores/teacher/overall").param("classId", "2072").with(teacher()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.records[0].classId").value("2072"))
.andExpect(jsonPath("$.data.records[0].studentId").value("stu-b"));
}
@Test
void returnsPagedModuleDetailScores() throws Exception {
// 学生甲:模块1最高98(含一条已删除30,仍取98)、模块3=70,其余模块无成绩为 null;
// 综合分数 = 98×0.30 + 70×0.10 = 36.40(默认权重,缺失模块计 0);学生乙无任何成绩 → 综合分数 0
// 排序:按综合分数降序 → 学生甲在前
mockMvc.perform(get("/api/v1/training-scores/teacher/details").param("page", "1").param("size", "10")
.with(teacher()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.total").value(2))
.andExpect(jsonPath("$.data.records[0].studentId").value("stu-a"))
.andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].userName").value("学生甲"))
.andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].module1Score").value(98.0))
.andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].module3Score").value(70.0))
.andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].module2Score")
.value(org.hamcrest.Matchers.hasItem(org.hamcrest.Matchers.nullValue())))
.andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].totalScore").value(36.40))
.andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-b')].module1Score")
.value(org.hamcrest.Matchers.hasItem(org.hamcrest.Matchers.nullValue())))
.andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-b')].totalScore").value(0.0));
}
@Test
void filtersDetailByStudentId() throws Exception {
mockMvc.perform(get("/api/v1/training-scores/teacher/details").param("studentId", "stu-b").with(teacher()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.records[0].studentId").value("stu-b"))
.andExpect(jsonPath("$.data.records[0].module6Score").value(org.hamcrest.Matchers.nullValue()))
.andExpect(jsonPath("$.data.records[0].totalScore").value(0.0));
}
@Test
void exportsDetailsAsXlsx() throws Exception {
mockMvc.perform(get("/api/v1/training-scores/teacher/details/export").with(teacher()))
.andExpect(status().isOk())
.andExpect(header().string("Content-Disposition",
org.hamcrest.Matchers.containsString("attachment")))
.andExpect(content().contentTypeCompatibleWith(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM));
}
@Test
void returnsDistinctClasses() throws Exception {
mockMvc.perform(get("/api/v1/training-scores/teacher/classes").with(teacher()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.length()").value(2))
.andExpect(jsonPath("$.data[0].classId").value("2071"))
.andExpect(jsonPath("$.data[0].className").value("教师测班1"))
.andExpect(jsonPath("$.data[1].classId").value("2072"));
}
@Test
void rejectsAnonymousAccess() throws Exception {
mockMvc.perform(get("/api/v1/training-scores/teacher/overall")).andExpect(status().isUnauthorized());
mockMvc.perform(get("/api/v1/training-scores/teacher/details")).andExpect(status().isUnauthorized());
mockMvc.perform(get("/api/v1/training-scores/teacher/classes")).andExpect(status().isUnauthorized());
}
@Test
void exportsOverallAsXlsx() throws Exception {
mockMvc.perform(get("/api/v1/training-scores/teacher/overall/export").with(teacher()))
.andExpect(status().isOk())
.andExpect(header().string("Content-Disposition",
org.hamcrest.Matchers.containsString("attachment")))
.andExpect(content().contentTypeCompatibleWith(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM));
}
private RequestPostProcessor teacher() {
return jwt().jwt(token -> token.subject(TEACHER).claim("userId", TEACHER)
.claim("preferred_username", "tea-1"));
}
private void clean() {
jdbc.update("DELETE FROM stu_module_score_details WHERE user_id IN (?,?)", STUDENT_A, STUDENT_B);
jdbc.update("DELETE FROM experiment_report WHERE user_id IN (?,?)", STUDENT_A, STUDENT_B);
jdbc.update("DELETE FROM training_module_weight WHERE school_id=?", SCHOOL);
jdbc.update("DELETE FROM training_school_score_weight WHERE school_id=?", SCHOOL);
jdbc.update("DELETE FROM sys_user WHERE user_id IN (?,?,?)", TEACHER, STUDENT_A, STUDENT_B);
}
}

@ -0,0 +1,112 @@
package com.yau.digitalrmb.walletopening.application;
import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyService;
import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject;
import com.yau.digitalrmb.institutionidentity.infrastructure.SmInstitutionIdentityCryptography;
import com.yau.digitalrmb.walletopening.domain.BankReceivedApplicationRepository;
import com.yau.digitalrmb.walletopening.domain.CentralWalletActivation;
import com.yau.digitalrmb.walletopening.domain.CentralWalletActivationRepository;
import com.yau.digitalrmb.walletopening.domain.CentralWalletRegistrationRepository;
import com.yau.digitalrmb.walletopening.domain.SmartContractGeneration;
import com.yau.digitalrmb.walletopening.domain.SmartContractGenerationRepository;
import com.yau.digitalrmb.walletopening.domain.WalletApplicationRepository;
import com.yau.digitalrmb.walletopening.domain.WalletIdentifierGenerationRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SmartContractServiceTest {
private static final InstitutionKeySubject SUBJECT = new InstitutionKeySubject("step4-user", 1001L, 2001L);
private static final String ACTIVATE_DIGEST = "ACT_DIGEST_VALUE";
private static final String BANK_PUBLIC_KEY = "BANK_SECOND_PUBLIC_KEY";
@Mock private SmartContractGenerationRepository contractRepository;
@Mock private WalletIdentifierGenerationRepository identifierRepository;
@Mock private WalletApplicationRepository walletApplicationRepository;
@Mock private BankReceivedApplicationRepository bankReceivedRepository;
@Mock private CentralWalletRegistrationRepository centralRegistrationRepository;
@Mock private CentralWalletActivationRepository activationRepository;
@Mock private InstitutionKeyService institutionKeyService;
@Mock private SmInstitutionIdentityCryptography cryptography;
@Mock private WalletOpeningScoreService scoreService;
@InjectMocks private SmartContractService service;
@Test
void sendMarksContractSentAndActivatesCentralWallet() {
SmartContractGeneration contract = preparedContract();
when(contractRepository.findLatest(SUBJECT.getUserId(), SUBJECT.getSchoolId(), SUBJECT.getClassId()))
.thenReturn(Optional.of(contract));
when(activationRepository.findLatest(SUBJECT.getUserId(), SUBJECT.getSchoolId(), SUBJECT.getClassId()))
.thenReturn(Optional.empty());
when(activationRepository.save(any(CentralWalletActivation.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(institutionKeyService.commercialBankSecondPublicKeyOrNull(SUBJECT)).thenReturn(BANK_PUBLIC_KEY);
when(cryptography.verify(BANK_PUBLIC_KEY, ACTIVATE_DIGEST, "ACT_SIGNATURE")).thenReturn(true);
when(cryptography.sm3(contract.getActivatePlaintext())).thenReturn(ACTIVATE_DIGEST);
String result = service.send(SUBJECT);
assertThat(result).isEqualTo("成功");
assertThat(contract.isSent()).isTrue();
assertThat(contract.getStatus()).isEqualTo(SmartContractGeneration.Status.SENT);
ArgumentCaptor<CentralWalletActivation> captor = ArgumentCaptor.forClass(CentralWalletActivation.class);
verify(activationRepository).update(captor.capture());
CentralWalletActivation activation = captor.getValue();
assertThat(activation.isReceived()).isTrue();
assertThat(activation.getWalletId()).isEqualTo("WALLET_ID_001");
assertThat(activation.getSignVerifyResult()).startsWith("通过");
assertThat(activation.getDigestMatchResult()).startsWith("通过");
assertThat(activation.isWalletActivated()).isTrue();
assertThat(activation.getStatus()).isEqualTo(CentralWalletActivation.Status.ACTIVATED);
verify(scoreService).markStepCompleted(SUBJECT.getUserId(), 6);
}
@Test
void sendKeepsStepFiveProgressAlreadyReached() {
SmartContractGeneration contract = preparedContract();
CentralWalletActivation activation = CentralWalletActivation.create(
SUBJECT.getUserId(), SUBJECT.getSchoolId(), SUBJECT.getClassId());
activation.receive("ACTIVATE_CONFIRM_1", "WALLET_ID_001", "BKCHCNBJ00001", "CTR_1",
ACTIVATE_DIGEST, "ACT_SIGNATURE", "20260101010102", "{\"status\": \"ACTIVATED\"}");
activation.activateWallet();
activation.finalSign("CB_FINAL_WALLET_SIG");
when(contractRepository.findLatest(SUBJECT.getUserId(), SUBJECT.getSchoolId(), SUBJECT.getClassId()))
.thenReturn(Optional.of(contract));
when(activationRepository.findLatest(SUBJECT.getUserId(), SUBJECT.getSchoolId(), SUBJECT.getClassId()))
.thenReturn(Optional.of(activation));
service.send(SUBJECT);
assertThat(activation.getStatus()).isEqualTo(CentralWalletActivation.Status.FINAL_SIGNED);
assertThat(activation.getCbFinalSignature()).isEqualTo("CB_FINAL_WALLET_SIG");
}
private SmartContractGeneration preparedContract() {
SmartContractGeneration c = SmartContractGeneration.create(SUBJECT.getUserId(),
SUBJECT.getSchoolId(), SUBJECT.getClassId());
c.fillContractData("WALLET_ID_001", "一类钱包", "1000", "5000", "100000", "20000", "3年", "20260101010101");
c.computeContractDigest("CONTRACT_DIGEST");
c.outputContract("CTR_1", "{\"contractId\": \"CTR_1\"}", "20260101010101");
c.acquireActivateTimestamp("20260101010102", "BKCHCNBJ00001");
c.fillActivatePlaintext("ACTIVATE|WALLET_ID_001|BKCHCNBJ00001|CTR_1|20260101010102");
c.computeActivateDigest(ACTIVATE_DIGEST);
c.signActivate("商业银行第二私钥", "ACT_SIGNATURE");
c.outputConfirm("ACTIVATE_CONFIRM_1", "{\"status\": \"ACTIVATED\"}");
return c;
}
}

@ -1028,3 +1028,47 @@ CREATE TABLE IF NOT EXISTS training_experiment_action (
CONSTRAINT fk_experiment_action_attempt
FOREIGN KEY (attempt_id) REFERENCES training_experiment_attempt(id)
);
-- 教师端总成绩权重配置(实验实训 + 实训报告 = 1),一校一行
CREATE TABLE IF NOT EXISTS training_school_score_weight (
id CHAR(36) PRIMARY KEY,
school_id VARCHAR(36) NOT NULL,
training_weight DECIMAL(5, 2) NOT NULL COMMENT '实验实训权重比',
report_weight DECIMAL(5, 2) NOT NULL COMMENT '实训报告权重比',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 教师端模块权重配置,一校一行,6 个模块权重横向存储,合计 100%
CREATE TABLE IF NOT EXISTS training_module_weight (
id CHAR(36) PRIMARY KEY,
school_id VARCHAR(36) NOT NULL,
module1_weight DECIMAL(5, 2) NOT NULL COMMENT '中国人民银行生成数字货币',
module2_weight DECIMAL(5, 2) NOT NULL COMMENT '中国人民银行发行数字货币',
module3_weight DECIMAL(5, 2) NOT NULL COMMENT '开通数字货币钱包',
module4_weight DECIMAL(5, 2) NOT NULL COMMENT '用户兑换数字货币',
module5_weight DECIMAL(5, 2) NOT NULL COMMENT '用户支付数字货币',
module6_weight DECIMAL(5, 2) NOT NULL COMMENT '数字人民币创新应用场景仿真',
deduct_score DECIMAL(5, 2) NOT NULL COMMENT '选错/填错一个扣分',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 实训报告表:与实验模块解耦,一个用户可多次提交,按 user_id 查询
CREATE TABLE IF NOT EXISTS experiment_report (
id CHAR(36) PRIMARY KEY COMMENT '主键',
report_name VARCHAR(255) NOT NULL COMMENT '报告名称(上传时的原始文件名)',
file_url VARCHAR(512) NOT NULL COMMENT '文件地址:经转换后存储到服务器的 PDF 相对路径',
file_size BIGINT NOT NULL COMMENT '文件大小(字节)',
upload_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
teacher_score DECIMAL(5, 2) NULL COMMENT '教师评分',
teacher_comment VARCHAR(500) NULL COMMENT '教师评语',
user_id VARCHAR(36) NOT NULL COMMENT '用户ID',
school_id VARCHAR(36) NULL COMMENT '学校ID',
class_id VARCHAR(36) NULL COMMENT '班级ID',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT FALSE
);

Loading…
Cancel
Save