diff --git a/pom.xml b/pom.xml index a325a7b..81c4036 100644 --- a/pom.xml +++ b/pom.xml @@ -44,6 +44,26 @@ hutool-all 5.8.16 + + org.apache.pdfbox + pdfbox + 2.0.27 + + + org.apache.poi + poi + 4.1.2 + + + org.apache.poi + poi-ooxml + 4.1.2 + + + org.apache.poi + poi-scratchpad + 4.1.2 + org.springframework.boot spring-boot-starter-security diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetService.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetService.java index f639cfc..e5c6215 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetService.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetService.java @@ -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() - .eq(CorporateWalletApplicationEntity::getUserId, subject.getUserId()) - .eq(CorporateWalletApplicationEntity::getSchoolId, subject.getSchoolId()) - .eq(CorporateWalletApplicationEntity::getClassId, subject.getClassId()) - .set(CorporateWalletApplicationEntity::getDeleted, true)); + return logicalDelete("corporate_wallet_application", () -> corporateWalletMapper.update(null, + new LambdaUpdateWrapper() + .eq(CorporateWalletApplicationEntity::getUserId, subject.getUserId()) + .eq(CorporateWalletApplicationEntity::getSchoolId, subject.getSchoolId()) + .eq(CorporateWalletApplicationEntity::getClassId, subject.getClassId()) + .set(CorporateWalletApplicationEntity::getDeleted, true))); } private int logicalDeleteRemoteWalletApplications(InstitutionKeySubject subject) { - return remoteWalletMapper.update(null, new LambdaUpdateWrapper() - .eq(RemoteWalletApplicationEntity::getUserId, subject.getUserId()) - .eq(RemoteWalletApplicationEntity::getSchoolId, subject.getSchoolId()) - .eq(RemoteWalletApplicationEntity::getClassId, subject.getClassId()) - .set(RemoteWalletApplicationEntity::getDeleted, true)); + return logicalDelete("remote_wallet_application", () -> remoteWalletMapper.update(null, + new LambdaUpdateWrapper() + .eq(RemoteWalletApplicationEntity::getUserId, subject.getUserId()) + .eq(RemoteWalletApplicationEntity::getSchoolId, subject.getSchoolId()) + .eq(RemoteWalletApplicationEntity::getClassId, subject.getClassId()) + .set(RemoteWalletApplicationEntity::getDeleted, true))); } private int logicalDeleteSalaryBatches(InstitutionKeySubject subject) { - return salaryBatchMapper.update(null, new LambdaUpdateWrapper() - .eq(SalaryBatchEntity::getUserId, subject.getUserId()) - .eq(SalaryBatchEntity::getSchoolId, subject.getSchoolId()) - .eq(SalaryBatchEntity::getClassId, subject.getClassId()) - .set(SalaryBatchEntity::getDeleted, true)); + return logicalDelete("salary_batch", () -> salaryBatchMapper.update(null, + new LambdaUpdateWrapper() + .eq(SalaryBatchEntity::getUserId, subject.getUserId()) + .eq(SalaryBatchEntity::getSchoolId, subject.getSchoolId()) + .eq(SalaryBatchEntity::getClassId, subject.getClassId()) + .set(SalaryBatchEntity::getDeleted, true))); } private int logicalDeleteDonationRecords(InstitutionKeySubject subject) { int count = 0; - count += donationMapper.update(null, new LambdaUpdateWrapper() + count += logicalDelete("donation", () -> donationMapper.update(null, new LambdaUpdateWrapper() .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() - .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() - .eq(DonationSupervisionEntity::getUserId, subject.getUserId()) - .eq(DonationSupervisionEntity::getSchoolId, subject.getSchoolId()) - .eq(DonationSupervisionEntity::getClassId, subject.getClassId()) - .set(DonationSupervisionEntity::getDeleted, true)); + .set(DonationEntity::getDeleted, true))); + count += logicalDelete("donation_receipt", () -> donationReceiptMapper.update(null, + new LambdaUpdateWrapper() + .eq(DonationReceiptEntity::getUserId, subject.getUserId()) + .eq(DonationReceiptEntity::getSchoolId, subject.getSchoolId()) + .eq(DonationReceiptEntity::getClassId, subject.getClassId()) + .set(DonationReceiptEntity::getDeleted, true))); + // 捐赠全流程记录表:捐赠发起、公益信息发布、监管备案共用的一条主记录,随捐赠一起作废 + count += logicalDelete("donation_record", () -> donationRecordMapper.update(null, + new LambdaUpdateWrapper() + .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() + .eq(DonationSupervisionEntity::getUserId, subject.getUserId()) + .eq(DonationSupervisionEntity::getSchoolId, subject.getSchoolId()) + .eq(DonationSupervisionEntity::getClassId, subject.getClassId()) + .set(DonationSupervisionEntity::getDeleted, true))); return count; } private int logicalDeleteScore(String userId) { - return scoreDetailsMapper.update(null, new LambdaUpdateWrapper() - .eq(StuModuleScoreDetailsEntity::getUserId, userId) - .eq(StuModuleScoreDetailsEntity::getMoudule, SalaryScoreService.MODULE_NAME) - .set(StuModuleScoreDetailsEntity::getDeleted, true)); + return logicalDelete("stu_module_score_details", () -> scoreDetailsMapper.update(null, + new LambdaUpdateWrapper() + .eq(StuModuleScoreDetailsEntity::getUserId, userId) + .eq(StuModuleScoreDetailsEntity::getMoudule, SalaryScoreService.MODULE_NAME) + .set(StuModuleScoreDetailsEntity::getDeleted, true))); + } + + /** + * 执行逻辑删除。库中缺少该表时跳过并告警,避免重置接口因库结构漂移直接失败; + * 其他数据库异常照常抛出,不掩盖真实问题。 + */ + private int logicalDelete(String tableName, Supplier 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"); } } diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateWalletService.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateWalletService.java index 0f4a096..36ee5f8 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateWalletService.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateWalletService.java @@ -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()); } diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/RemoteWalletService.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/RemoteWalletService.java index 028a53c..fb53187 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/application/RemoteWalletService.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/RemoteWalletService.java @@ -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()); } diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchService.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchService.java index d3fe05d..09c5574 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchService.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchService.java @@ -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()); } diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/CorporateInnovationTrainingResetController.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/CorporateInnovationTrainingResetController.java index d7c0eaa..d38f778 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/CorporateInnovationTrainingResetController.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/CorporateInnovationTrainingResetController.java @@ -23,7 +23,7 @@ public class CorporateInnovationTrainingResetController { @PostMapping("/reset") @Operation(summary = "重新开始数字人民币创新应用场景实验", - description = "逻辑删除当前主体的对公钱包申请、工资批次、模块六成绩和步骤进度;保留模块六密钥,可从模块六第一步重新开始。") + description = "逻辑删除当前主体的对公钱包开立申请、远程开立申请、工资代发批次、公益捐赠及捐赠收据、捐赠记录、监管备案,以及模块六成绩明细与步骤进度;保留模块六密钥,可从模块六第一步重新开始。") public ApiResponse reset() { return ApiResponse.success(resetService.reset( InstitutionKeySubject.from(AuthContextHolder.get())), traceId()); diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/CorporateWalletController.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/CorporateWalletController.java index 5b48a8d..f8a1495 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/CorporateWalletController.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/CorporateWalletController.java @@ -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 computeDigest() { + description = "前端传入摘要算法,校验算法是否为SM3(错误则错误数+1)," + + "通过后对提取的摘要原文进行SM3哈希运算,返回算法名称和摘要值") + public ApiResponse 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") diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/RemoteWalletController.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/RemoteWalletController.java index bdc1221..2a8a7ac 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/RemoteWalletController.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/RemoteWalletController.java @@ -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 computeSigningDigest() { + description = "前端传入摘要算法,校验算法是否为SM3(错误则错误数+1)," + + "通过后对电子签约摘要原文进行SM3哈希运算") + public ApiResponse 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 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 computeWalletDigest() { + description = "前端传入摘要算法,校验算法是否为SM3(错误则错误数+1)," + + "通过后对钱包摘要原文进行SM3哈希运算,生成钱包ID") + public ApiResponse 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") diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/SalaryBatchController.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/SalaryBatchController.java index 10c7f15..acd204e 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/SalaryBatchController.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/SalaryBatchController.java @@ -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 computeDigest() { + description = "前端传入摘要算法,校验算法是否为SM3(错误则错误数+1)," + + "通过后对拼接原文进行SM3运算,只返回摘要值") + public ApiResponse 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 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") diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/SalaryConfirmController.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/SalaryConfirmController.java index b47f377..bd7caf8 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/SalaryConfirmController.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/SalaryConfirmController.java @@ -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 computeConfirmDigest() { + description = "前端传入摘要算法,校验算法是否为SM3(错误则错误数+1)," + + "通过后对拼接交易确认原文进行SM3运算,只返回摘要值") + public ApiResponse 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 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") diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/SalaryReviewController.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/SalaryReviewController.java index d14fa82..3baeae4 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/SalaryReviewController.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/SalaryReviewController.java @@ -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 computeReviewDigest() { + description = "前端传入摘要算法,校验算法是否为SM3(错误则错误数+1)," + + "通过后对拼接审核原文进行SM3运算,只返回摘要值") + public ApiResponse 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 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") diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/CorporateWalletDigestRequest.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/CorporateWalletDigestRequest.java new file mode 100644 index 0000000..1223d93 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/CorporateWalletDigestRequest.java @@ -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; +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/RemoteWalletDigestRequest.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/RemoteWalletDigestRequest.java new file mode 100644 index 0000000..5ed0df4 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/RemoteWalletDigestRequest.java @@ -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; +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/RemoteWalletSignRequest.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/RemoteWalletSignRequest.java index 5258b08..103a6d0 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/RemoteWalletSignRequest.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/RemoteWalletSignRequest.java @@ -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; } diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalaryConfirmDigestRequest.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalaryConfirmDigestRequest.java new file mode 100644 index 0000000..1d1d881 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalaryConfirmDigestRequest.java @@ -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; +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalaryConfirmSignRequest.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalaryConfirmSignRequest.java index 40d2bb5..e79d910 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalaryConfirmSignRequest.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalaryConfirmSignRequest.java @@ -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; } diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalaryDigestRequest.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalaryDigestRequest.java new file mode 100644 index 0000000..4419e99 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalaryDigestRequest.java @@ -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; +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalaryReviewDigestRequest.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalaryReviewDigestRequest.java new file mode 100644 index 0000000..0658197 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalaryReviewDigestRequest.java @@ -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; +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalaryReviewSignRequest.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalaryReviewSignRequest.java index 8af666d..7e07ae8 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalaryReviewSignRequest.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalaryReviewSignRequest.java @@ -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; } diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalarySignRequest.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalarySignRequest.java index db43d25..edad34b 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalarySignRequest.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalarySignRequest.java @@ -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; } diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/StuModuleScoreDetailsMapper.java b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/StuModuleScoreDetailsMapper.java index fa17679..98ba226 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/StuModuleScoreDetailsMapper.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/infrastructure/StuModuleScoreDetailsMapper.java @@ -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 { + + /** + * 统计模块操作总次数:包含已逻辑删除(历史轮次)的记录。 + */ + @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 selectAllIncludingDeleted(@Param("userId") String userId, + @Param("module") String module); } diff --git a/src/main/java/com/yau/digitalrmb/training/experimentreport/application/ExperimentReportPdfConverter.java b/src/main/java/com/yau/digitalrmb/training/experimentreport/application/ExperimentReportPdfConverter.java new file mode 100644 index 0000000..d085029 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/experimentreport/application/ExperimentReportPdfConverter.java @@ -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 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 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 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; + } + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/experimentreport/application/ExperimentReportService.java b/src/main/java/com/yau/digitalrmb/training/experimentreport/application/ExperimentReportService.java new file mode 100644 index 0000000..fa05faa --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/experimentreport/application/ExperimentReportService.java @@ -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,再存储到服务器,地址入库。 + * + *

单用户仅允许一份有效报告: + *

    + *
  • 首次上传 → 新增记录;
  • + *
  • 再次上传(教师尚未评分)→ 覆盖修改原记录(文件/元信息更新,原评分与评语失效置空);
  • + *
  • 教师已评分后上传 → 拒绝(400),避免覆盖已批改报告。
  • + *
+ * + * 报告归属当前登录用户,与实验模块无关。 + */ + 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 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 listByUser(String userId) { + LambdaQueryWrapper 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()); + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/experimentreport/application/ExperimentReportView.java b/src/main/java/com/yau/digitalrmb/training/experimentreport/application/ExperimentReportView.java new file mode 100644 index 0000000..f25c431 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/experimentreport/application/ExperimentReportView.java @@ -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(); + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/experimentreport/infrastructure/ExperimentReportEntity.java b/src/main/java/com/yau/digitalrmb/training/experimentreport/infrastructure/ExperimentReportEntity.java new file mode 100644 index 0000000..e508b56 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/experimentreport/infrastructure/ExperimentReportEntity.java @@ -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; +} diff --git a/src/main/java/com/yau/digitalrmb/training/experimentreport/infrastructure/ExperimentReportMapper.java b/src/main/java/com/yau/digitalrmb/training/experimentreport/infrastructure/ExperimentReportMapper.java new file mode 100644 index 0000000..497f3e2 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/experimentreport/infrastructure/ExperimentReportMapper.java @@ -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 { +} diff --git a/src/main/java/com/yau/digitalrmb/training/experimentreport/interfaces/dto/ScoreReportRequest.java b/src/main/java/com/yau/digitalrmb/training/experimentreport/interfaces/dto/ScoreReportRequest.java new file mode 100644 index 0000000..608b280 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/experimentreport/interfaces/dto/ScoreReportRequest.java @@ -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; +} diff --git a/src/main/java/com/yau/digitalrmb/training/experimentreport/interfaces/rest/ExperimentReportController.java b/src/main/java/com/yau/digitalrmb/training/experimentreport/interfaces/rest/ExperimentReportController.java new file mode 100644 index 0000000..73148df --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/experimentreport/interfaces/rest/ExperimentReportController.java @@ -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 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() { + CurrentUser user = currentUserService.getCurrentUser(); + return ApiResponse.success(service.listByUser(user.getUserId()), MDC.get(TraceIdFilter.MDC_KEY)); + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/application/TeacherReportPage.java b/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/application/TeacherReportPage.java new file mode 100644 index 0000000..923824e --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/application/TeacherReportPage.java @@ -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 records; +} diff --git a/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/application/TeacherReportRow.java b/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/application/TeacherReportRow.java new file mode 100644 index 0000000..5476bfc --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/application/TeacherReportRow.java @@ -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; +} diff --git a/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/application/TeacherReportService.java b/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/application/TeacherReportService.java new file mode 100644 index 0000000..0d3ff92 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/application/TeacherReportService.java @@ -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 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)。 + * + *

校验:报告不存在或已删除 → 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); + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/infrastructure/TeacherReportQueryMapper.java b/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/infrastructure/TeacherReportQueryMapper.java new file mode 100644 index 0000000..e5bd896 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/infrastructure/TeacherReportQueryMapper.java @@ -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("") + List 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("") + long selectCount(@Param("schoolId") String schoolId, + @Param("userName") String userName, + @Param("studentId") String studentId, + @Param("classId") String classId); +} diff --git a/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/interfaces/rest/TeacherExperimentReportController.java b/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/interfaces/rest/TeacherExperimentReportController.java new file mode 100644 index 0000000..8abd5fb --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/interfaces/rest/TeacherExperimentReportController.java @@ -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 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 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)); + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/ModuleScoreSummaryResult.java b/src/main/java/com/yau/digitalrmb/training/score/application/ModuleScoreSummaryResult.java new file mode 100644 index 0000000..fc76bf1 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/application/ModuleScoreSummaryResult.java @@ -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 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 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; + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/OverallScoreItem.java b/src/main/java/com/yau/digitalrmb/training/score/application/OverallScoreItem.java new file mode 100644 index 0000000..27273f3 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/application/OverallScoreItem.java @@ -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; + +/** + * 总成绩页面的一行:实验实训 / 实验报告 / 总成绩。 + * + *

字段与前端总成绩表一一对应(项目、成绩、权重、进度、最终成绩)。 + * 最终成绩 = 进度(百分比)/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; + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/OverallScoreResult.java b/src/main/java/com/yau/digitalrmb/training/score/application/OverallScoreResult.java new file mode 100644 index 0000000..485b16c --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/application/OverallScoreResult.java @@ -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 items; + @Schema(description = "最终总成绩 = 实验实训最终成绩 + 实验报告最终成绩", example = "22.85") + private final BigDecimal totalFinal; + + public OverallScoreResult(List items, BigDecimal totalFinal) { + this.items = items; + this.totalFinal = totalFinal; + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/StuModuleScoreResult.java b/src/main/java/com/yau/digitalrmb/training/score/application/StuModuleScoreResult.java new file mode 100644 index 0000000..8fc39bf --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/application/StuModuleScoreResult.java @@ -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(); + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/StudentScoreDetailResult.java b/src/main/java/com/yau/digitalrmb/training/score/application/StudentScoreDetailResult.java new file mode 100644 index 0000000..b342969 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/application/StudentScoreDetailResult.java @@ -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; +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/StudentScoreDetailService.java b/src/main/java/com/yau/digitalrmb/training/score/application/StudentScoreDetailService.java new file mode 100644 index 0000000..900e4ec --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/application/StudentScoreDetailService.java @@ -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)。 + * + *

直接复用 {@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 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()); + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/TrainingModuleWeightItem.java b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingModuleWeightItem.java new file mode 100644 index 0000000..96b9244 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingModuleWeightItem.java @@ -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; + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/TrainingModuleWeightResult.java b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingModuleWeightResult.java new file mode 100644 index 0000000..9164a22 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingModuleWeightResult.java @@ -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 modules; + @Schema(description = "是否为本校已有配置;false 表示本次查询时刚落库一条默认配置") + private final boolean configured; + + public TrainingModuleWeightResult(String id, BigDecimal deductScore, List modules, + boolean configured) { + this.id = id; + this.deductScore = deductScore; + this.modules = modules; + this.configured = configured; + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/TrainingModules.java b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingModules.java new file mode 100644 index 0000000..86d8769 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingModules.java @@ -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 STANDARD_NAMES = new LinkedHashMap(); + /** 模块序号 → 成绩明细表 moudule 字段实际记录的模块名。 */ + private static final Map SCORE_NAMES = new LinkedHashMap(); + /** 模块序号 → 默认权重。 */ + private static final Map DEFAULT_WEIGHTS = new LinkedHashMap(); + + 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 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 entry : SCORE_NAMES.entrySet()) { + if (entry.getValue().equals(scoreName)) { + return entry.getKey(); + } + } + return null; + } + + public static Map defaultWeights() { + return Collections.unmodifiableMap(DEFAULT_WEIGHTS); + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/TrainingOverallScoreService.java b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingOverallScoreService.java new file mode 100644 index 0000000..48cf5f3 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingOverallScoreService.java @@ -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; + +/** + * 学生端总成绩计算: + *

    + *
  • 实验实训成绩 = 各模块最高分按其模块权重加权之和(缺失模块计 0);
  • + *
  • 实验实训进度 = 6 个模块进度相加 ÷ 6(缺失模块计 0,仍除 6);
  • + *
  • 实验报告成绩 = 该学生唯一报告记录的教师评分(未评分计 0);
  • + *
  • 实验报告进度 = 有提交记录即 100%,无则 0;
  • + *
  • 各自最终成绩 = 进度(百分比)/100 × 权重 × 成绩;
  • + *
  • 总成绩 = 实验实训最终成绩 + 实验报告最终成绩。
  • + *
+ * 权重(实验实训/报告)取自总成绩权重表,本校未配置时落默认 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 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() + .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 items = new ArrayList(); + 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); + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/TrainingSchoolWeightResult.java b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingSchoolWeightResult.java new file mode 100644 index 0000000..f5ca35a --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingSchoolWeightResult.java @@ -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; + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/TrainingScoreQueryService.java b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingScoreQueryService.java new file mode 100644 index 0000000..2681bea --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingScoreQueryService.java @@ -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 模块成绩汇总列表。明细(details)不含已逻辑删除的数据; + * 但最高/最低/平均/操作次数均包含已删除(历史轮次)的记录。 + */ + @Transactional + public List summary(String userId, String schoolId, String module) { + if (userId == null || userId.trim().isEmpty()) { + return Collections.emptyList(); + } + // 模块权重与扣分值:优先从权重表读取;本校尚无配置时由权重服务落一条默认配置 + Map weights; + BigDecimal deductScore; + if (schoolId != null && !schoolId.trim().isEmpty()) { + TrainingModuleWeightResult weightResult = weightService.moduleWeight(schoolId); + weights = new HashMap(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 result = new ArrayList(); + 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 rows = scoreDetailsMapper.selectList( + new LambdaQueryWrapper() + .eq(StuModuleScoreDetailsEntity::getUserId, userId) + .eq(StuModuleScoreDetailsEntity::getMoudule, scoreName) + .orderByAsc(StuModuleScoreDetailsEntity::getSerialNumber)); + + // 最高/最低/平均:当前用户本人在该模块的全部轮次(含已删除历史轮次)的统计 + List 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 details = new ArrayList(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)); + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/TrainingWeightService.java b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingWeightService.java new file mode 100644 index 0000000..301a4ef --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingWeightService.java @@ -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 items = new ArrayList(); + 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 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 codes = new HashSet(); + 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() + .eq(TrainingSchoolScoreWeightEntity::getSchoolId, schoolId) + .eq(TrainingSchoolScoreWeightEntity::getDeleted, false) + .last("LIMIT 1")); + } + + private TrainingModuleWeightEntity moduleConfig(String schoolId) { + return moduleWeightMapper.selectOne(new LambdaQueryWrapper() + .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, + "当前用户缺少学校信息,无法配置成绩权重"); + } + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingModuleWeightEntity.java b/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingModuleWeightEntity.java new file mode 100644 index 0000000..950ed1d --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingModuleWeightEntity.java @@ -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); + } + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingModuleWeightMapper.java b/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingModuleWeightMapper.java new file mode 100644 index 0000000..cc39c17 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingModuleWeightMapper.java @@ -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 { +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingSchoolScoreWeightEntity.java b/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingSchoolScoreWeightEntity.java new file mode 100644 index 0000000..7cfcb1d --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingSchoolScoreWeightEntity.java @@ -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; +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingSchoolScoreWeightMapper.java b/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingSchoolScoreWeightMapper.java new file mode 100644 index 0000000..fab4fea --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingSchoolScoreWeightMapper.java @@ -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 { +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/interfaces/dto/SaveModuleWeightsRequest.java b/src/main/java/com/yau/digitalrmb/training/score/interfaces/dto/SaveModuleWeightsRequest.java new file mode 100644 index 0000000..7a28da7 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/interfaces/dto/SaveModuleWeightsRequest.java @@ -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 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; + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/interfaces/dto/SaveSchoolWeightRequest.java b/src/main/java/com/yau/digitalrmb/training/score/interfaces/dto/SaveSchoolWeightRequest.java new file mode 100644 index 0000000..22804f8 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/interfaces/dto/SaveSchoolWeightRequest.java @@ -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; +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingScoreController.java b/src/main/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingScoreController.java new file mode 100644 index 0000000..8d8b9f5 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingScoreController.java @@ -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> 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 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 details() { + CurrentUser user = currentUserService.getCurrentUser(); + return ApiResponse.success( + detailService.detail(user.getUserId(), user.getSchoolId()), + MDC.get(TraceIdFilter.MDC_KEY)); + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingWeightController.java b/src/main/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingWeightController.java new file mode 100644 index 0000000..3f1cc98 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingWeightController.java @@ -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 schoolWeight() { + return ApiResponse.success(service.schoolWeight(schoolId()), traceId()); + } + + @PostMapping("/school") + @Operation(summary = "修改总成绩权重", + description = "按 id 定位配置记录;实验实训权重比与实训报告权重比之和必须等于 1,均最多 2 位小数。") + public ApiResponse 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 moduleWeights() { + return ApiResponse.success(service.moduleWeight(schoolId()), traceId()); + } + + @PostMapping("/modules") + @Operation(summary = "修改模块权重与扣分值", + description = "按 id 定位配置记录;必须一次性提交 6 个模块,mouduleCode 为 1-6 且不重复," + + "moudule 名称与标准名一致,权重最多 2 位小数且合计等于 1;deductScore 为选错/填错一个扣多少分。") + public ApiResponse 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); + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherClassOption.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherClassOption.java new file mode 100644 index 0000000..0bc7983 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherClassOption.java @@ -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; +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherDetailExportService.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherDetailExportService.java new file mode 100644 index 0000000..d884a1f --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherDetailExportService.java @@ -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 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; + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherModuleMaxScore.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherModuleMaxScore.java new file mode 100644 index 0000000..b49064d --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherModuleMaxScore.java @@ -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; +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherModuleSchedule.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherModuleSchedule.java new file mode 100644 index 0000000..b7abbd4 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherModuleSchedule.java @@ -0,0 +1,25 @@ +package com.yau.digitalrmb.training.score.teacher.application; + +import lombok.Getter; + +/** + * 教师端「成绩详情/总成绩」只读复用 stu_module_score_details: + * 批量取每个 (user_id, moudule) 最新一条有效(未逻辑删除)记录的完成进度(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; + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherScoreExportService.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherScoreExportService.java new file mode 100644 index 0000000..64e6696 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherScoreExportService.java @@ -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。 + * 列:姓名、学号、班级、模块一~模块六(各模块最高成绩)、实训成绩、实验报告成绩、最终成绩。 + * + *

模块最高分整体批量取(一条 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 rows = scoreService.studentOverallList(schoolId, userName, studentId, classId); + + // 整体批量取各学生各模块最高成绩(含已删除轮次,与查询口径一致),一次 SQL + List userIds = new ArrayList(rows.size()); + for (TeacherStudentOverallRow r : rows) { + userIds.add(r.getUserId()); + } + Map> moduleMaxByUser = new HashMap>(); + 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()).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> moduleMaxByUser) { + Double[] max = new Double[MODULE_COUNT]; + Map byCode = moduleMaxByUser.get(userId); + if (byCode != null) { + for (int code = 1; code <= MODULE_COUNT; code++) { + max[code - 1] = byCode.get(code); + } + } + return max; + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherScoreService.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherScoreService.java new file mode 100644 index 0000000..3163cb2 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherScoreService.java @@ -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 筛选、分页), + * 复用学生端同一个总成绩算法计算每行总成绩;并提供当前学校去重班级下拉。 + * + *

性能:总成绩计算原本对每个分页学生都调用一次 {@code overall},而 {@code overall} 内部又各自查询 + * 模块汇总、模块权重、总成绩权重、实验报告(每学生约 20+ 次 DB 查询),形成严重 N+1: + * 一个 page=10 的请求会触发 200+ 次查询。现改为按当前分页/筛选得到的学生整体批量查询—— + * 模块最高分与进度各一条 {@code GROUP BY} SQL、实验报告一条 {@code IN} 查询、权重整请求只查一次, + * 查询次数降到常数级(与每页人数无关),与学生端总成绩算法口径完全一致。 + * + *

学生以 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 result = userMapper.selectPage(new Page(current, size), + studentWrapper(schoolId, userName, studentId, classId)); + List rows = buildOverallRows(result.getRecords(), schoolId); + return new TeacherStudentOverallPage(result.getCurrent(), result.getSize(), + result.getTotal(), result.getPages(), rows); + } + + /** + * 不分页查询当前学校学生总成绩(导出用),筛选规则与 {@link #studentOverallPage} 一致。 + */ + public List 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 buildOverallRows(List users, String schoolId) { + if (users.isEmpty()) { + return new ArrayList(); + } + // 权重(全校一份,整请求仅查一次) + Map 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 userIds = new ArrayList(users.size()); + for (UserEntity u : users) { + userIds.add(u.getUserId()); + } + Map> maxByUser = batchMaxScores(userIds); + Map> scheduleByUser = batchSchedules(userIds); + Map reportByUser = batchReports(userIds); + + List rows = new ArrayList(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 weights, + BigDecimal trainingWeight, BigDecimal reportWeight, + Map maxMap, Map scheduleMap, + Map 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> batchMaxScores(List userIds) { + Map> maxByUser = new HashMap>(); + 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()).put(code, m.getMaxScore()); + } + return maxByUser; + } + + /** 批量取每个学生在各模块的最新有效记录进度,userId -> (模块序号 -> 进度)。 */ + private Map> batchSchedules(List userIds) { + Map> scheduleByUser = new HashMap>(); + 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()).put(code, s.getSchedule()); + } + return scheduleByUser; + } + + /** 批量取每个学生的实验报告教师评分(仅未删除报告),userId -> 教师评分(无评分记 0,但仍标记有报告)。 */ + private Map batchReports(List userIds) { + Map reportByUser = new HashMap(); + List reports = reportMapper.selectList( + new LambdaQueryWrapper() + .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 classOptions(String schoolId) { + return userMapper.selectClassesBySchool(schoolId); + } + + /** + * 分页查询当前学校学生「成绩详情」:一行一名学生,模块一~模块六各取该生最高成绩, + * 并按各模块最高分×模块权重加权求和得到综合分数(totalScore),按综合分数降序排列(同分按学号升序)。 + * 最高成绩口径与学生端一致:该模块全部轮次(含已逻辑删除的历史轮次)取最大值; + * 没有成绩的模块为 null、综合分数按 0 计。排序需全量计算后进行,故先查全部匹配学生再在内存分页。 + */ + public TeacherStudentDetailPage studentDetailPage(String schoolId, String userName, String studentId, + String classId, int current, int size) { + List all = studentDetailList(schoolId, userName, studentId, classId); + int from = (current - 1) * size; + List records = from >= all.size() + ? new ArrayList() + : new ArrayList(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 studentDetailList(String schoolId, String userName, + String studentId, String classId) { + List 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 moduleWeights(String schoolId) { + if (schoolId == null || schoolId.trim().isEmpty()) { + return TrainingModules.defaultWeights(); + } + Map weights = new HashMap(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 buildDetailRows(List users, Map weights) { + List rows = new ArrayList(users.size()); + if (users.isEmpty()) { + return rows; + } + List userIds = new ArrayList(users.size()); + for (UserEntity u : users) { + userIds.add(u.getUserId()); + } + // userId -> (模块序号 -> 最高成绩) + Map> maxByUser = new HashMap>(); + for (TeacherModuleMaxScore m : moduleScoreMapper.selectMaxScoresByUserIds(userIds)) { + Integer code = TrainingModules.codeOfScoreName(m.getMoudule()); + if (code == null || m.getMaxScore() == null) { + continue; + } + Map byCode = maxByUser.get(m.getUserId()); + if (byCode == null) { + byCode = new HashMap(); + 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 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 studentWrapper(String schoolId, String userName, + String studentId, String classId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .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; + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentDetailPage.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentDetailPage.java new file mode 100644 index 0000000..b057f5e --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentDetailPage.java @@ -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 records; +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentDetailRow.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentDetailRow.java new file mode 100644 index 0000000..f788128 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentDetailRow.java @@ -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; +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentOverallPage.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentOverallPage.java new file mode 100644 index 0000000..b89fc6d --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentOverallPage.java @@ -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 records; +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentOverallRow.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentOverallRow.java new file mode 100644 index 0000000..64dbf15 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentOverallRow.java @@ -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; + +/** + * 教师端总成绩列表的一行:学生基本信息 + 与学生端同口径的总成绩三行数据。 + * + *

实验实训成绩 = 各模块最高分按模块权重加权;实验实训权重 = 该生所属学校配置的实验实训权重; + * 实验报告成绩 = 教师评分;实验报告权重 = 该校配置的实验报告权重;最终成绩 = 实训最终 + 报告最终。 + */ +@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; +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/infrastructure/TeacherModuleScoreMapper.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/infrastructure/TeacherModuleScoreMapper.java new file mode 100644 index 0000000..b2d2625 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/infrastructure/TeacherModuleScoreMapper.java @@ -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("") + List selectMaxScoresByUserIds(@Param("userIds") List userIds); + + /** + * 批量取每个 (user_id, moudule) 最新一条有效(未逻辑删除)记录的完成进度(schedule)。 + * 进度口径与学生端一致:取该模块 serial_number 最大且未删除的记录。 + * 与 {@link #selectMaxScoresByUserIds} 分开,因为最高分口径含已删除轮次、进度取最新有效记录。 + */ + @Select("") + List selectLatestScheduleByUserIds(@Param("userIds") List userIds); +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/infrastructure/TeacherUserMapper.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/infrastructure/TeacherUserMapper.java new file mode 100644 index 0000000..d6e85f4 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/infrastructure/TeacherUserMapper.java @@ -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 { + + /** + * 当前学校去重班级: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 selectClassesBySchool(@Param("schoolId") String schoolId); +} diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/interfaces/rest/TrainingScoreTeacherController.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/interfaces/rest/TrainingScoreTeacherController.java new file mode 100644 index 0000000..9887eaa --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/interfaces/rest/TrainingScoreTeacherController.java @@ -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 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 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> 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 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 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); + } +} diff --git a/src/main/java/com/yau/digitalrmb/walletopening/application/SmartContractService.java b/src/main/java/com/yau/digitalrmb/walletopening/application/SmartContractService.java index 588b0d4..c4efc2f 100644 --- a/src/main/java/com/yau/digitalrmb/walletopening/application/SmartContractService.java +++ b/src/main/java/com/yau/digitalrmb/walletopening/application/SmartContractService.java @@ -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" diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index b7b855b..247f1df 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -22,4 +22,4 @@ security: file: type: local - path: D:\新建文件夹 + path: /usr/local/tianzeProject/digital-rmb-backend/uploadFile diff --git a/src/main/resources/db/manual/experiment_report_upgrade.sql b/src/main/resources/db/manual/experiment_report_upgrade.sql new file mode 100644 index 0000000..272ebf3 --- /dev/null +++ b/src/main/resources/db/manual/experiment_report_upgrade.sql @@ -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; diff --git a/src/main/resources/db/manual/module_6_charity_upgrade.sql b/src/main/resources/db/manual/module_6_charity_upgrade.sql index b96dadc..d4b94af 100644 --- a/src/main/resources/db/manual/module_6_charity_upgrade.sql +++ b/src/main/resources/db/manual/module_6_charity_upgrade.sql @@ -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, diff --git a/src/main/resources/db/manual/training_weight_config_upgrade.sql b/src/main/resources/db/manual/training_weight_config_upgrade.sql new file mode 100644 index 0000000..e741e27 --- /dev/null +++ b/src/main/resources/db/manual/training_weight_config_upgrade.sql @@ -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 +); diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 3999b39..2acce82 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -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='实训报告表'; diff --git a/src/test/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetServiceTest.java b/src/test/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetServiceTest.java index e601cbc..fa44141 100644 --- a/src/test/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetServiceTest.java +++ b/src/test/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetServiceTest.java @@ -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"); } } diff --git a/src/test/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchServiceTest.java b/src/test/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchServiceTest.java index 6080d7c..693bc45 100644 --- a/src/test/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchServiceTest.java +++ b/src/test/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchServiceTest.java @@ -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 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()); + } } diff --git a/src/test/java/com/yau/digitalrmb/training/experimentreport/interfaces/rest/ExperimentReportControllerTest.java b/src/test/java/com/yau/digitalrmb/training/experimentreport/interfaces/rest/ExperimentReportControllerTest.java new file mode 100644 index 0000000..c6d9d6c --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/training/experimentreport/interfaces/rest/ExperimentReportControllerTest.java @@ -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); + } +} diff --git a/src/test/java/com/yau/digitalrmb/training/experimentreport/teacher/interfaces/rest/TeacherExperimentReportControllerTest.java b/src/test/java/com/yau/digitalrmb/training/experimentreport/teacher/interfaces/rest/TeacherExperimentReportControllerTest.java new file mode 100644 index 0000000..1ae3560 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/training/experimentreport/teacher/interfaces/rest/TeacherExperimentReportControllerTest.java @@ -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); + } +} diff --git a/src/test/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingOverallScoreControllerTest.java b/src/test/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingOverallScoreControllerTest.java new file mode 100644 index 0000000..2805b62 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingOverallScoreControllerTest.java @@ -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); + } +} diff --git a/src/test/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingScoreControllerTest.java b/src/test/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingScoreControllerTest.java new file mode 100644 index 0000000..2f683b8 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingScoreControllerTest.java @@ -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); + } +} diff --git a/src/test/java/com/yau/digitalrmb/training/score/teacher/interfaces/rest/TrainingScoreTeacherControllerTest.java b/src/test/java/com/yau/digitalrmb/training/score/teacher/interfaces/rest/TrainingScoreTeacherControllerTest.java new file mode 100644 index 0000000..12fc7ea --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/training/score/teacher/interfaces/rest/TrainingScoreTeacherControllerTest.java @@ -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); + } +} diff --git a/src/test/java/com/yau/digitalrmb/walletopening/application/SmartContractServiceTest.java b/src/test/java/com/yau/digitalrmb/walletopening/application/SmartContractServiceTest.java new file mode 100644 index 0000000..522fc9e --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/walletopening/application/SmartContractServiceTest.java @@ -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 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; + } +} diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql index fb43be5..b7ea1d1 100644 --- a/src/test/resources/schema.sql +++ b/src/test/resources/schema.sql @@ -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 +);