@ -6,8 +6,14 @@ import com.sztzjy.linkCommerce.config.security.JwtUser;
import com.sztzjy.linkCommerce.util.file.IFileUtil ;
import com.sztzjy.linkCommerce.util.file.IFileUtil ;
import org.apache.commons.lang3.StringUtils ;
import org.apache.commons.lang3.StringUtils ;
import org.apache.poi.ss.usermodel.* ;
import org.apache.poi.ss.usermodel.* ;
import org.apache.poi.ss.util.CellRangeAddress ;
import org.apache.poi.ss.util.CellRangeAddressList ;
import org.apache.poi.xssf.usermodel.XSSFWorkbook ;
import org.apache.poi.xssf.usermodel.XSSFWorkbook ;
import org.apache.poi.xwpf.usermodel.* ;
import org.apache.poi.xwpf.usermodel.* ;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr ;
import org.w3c.dom.Document ;
import org.w3c.dom.Element ;
import org.w3c.dom.Node ;
import org.springframework.jdbc.core.JdbcTemplate ;
import org.springframework.jdbc.core.JdbcTemplate ;
import org.springframework.scheduling.annotation.Scheduled ;
import org.springframework.scheduling.annotation.Scheduled ;
import org.springframework.security.access.AccessDeniedException ;
import org.springframework.security.access.AccessDeniedException ;
@ -25,6 +31,8 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter ;
import java.time.format.DateTimeFormatter ;
import java.util.* ;
import java.util.* ;
import java.util.function.Function ;
import java.util.function.Function ;
import java.util.regex.Matcher ;
import java.util.regex.Pattern ;
import java.util.stream.Collectors ;
import java.util.stream.Collectors ;
import java.util.zip.ZipEntry ;
import java.util.zip.ZipEntry ;
import java.util.zip.ZipOutputStream ;
import java.util.zip.ZipOutputStream ;
@ -39,6 +47,8 @@ public class ExamService {
private static final Set < String > ALLOWED_ATTACHMENTS = new HashSet < > ( Arrays . asList ( "pdf" , "doc" , "docx" , "png" , "jpg" , "jpeg" ) ) ;
private static final Set < String > ALLOWED_ATTACHMENTS = new HashSet < > ( Arrays . asList ( "pdf" , "doc" , "docx" , "png" , "jpg" , "jpeg" ) ) ;
private static final long MAX_ATTACHMENT_SIZE = 20L * 1024 * 1024 ;
private static final long MAX_ATTACHMENT_SIZE = 20L * 1024 * 1024 ;
private static final DateTimeFormatter TIME = DateTimeFormatter . ofPattern ( "yyyy-MM-dd HH:mm:ss" ) ;
private static final DateTimeFormatter TIME = DateTimeFormatter . ofPattern ( "yyyy-MM-dd HH:mm:ss" ) ;
private static final Pattern OPTION_LINE = Pattern . compile ( "^([A-Ha-h])\\s*[..、:: ]\\s*(.+)$" ) ;
private static final Pattern QUESTION_NUMBER = Pattern . compile ( "^\\s*(?:\\d+[.、.)]|[( (]\\d+[) )])\\s*" ) ;
private final JdbcTemplate jdbc ;
private final JdbcTemplate jdbc ;
private final ObjectMapper objectMapper ;
private final ObjectMapper objectMapper ;
@ -104,29 +114,102 @@ public class ExamService {
@Transactional
@Transactional
public List < Map < String , Object > > importQuestions ( JwtUser teacher , MultipartFile file ) {
public List < Map < String , Object > > importQuestions ( JwtUser teacher , MultipartFile file ) {
requireTeacher ( teacher ) ;
requireTeacher ( teacher ) ;
validateDocx ( file ) ;
String format = validateQuestionImportFile ( file ) ;
List < Map < String , Object > > parsed = parseQuestionDocument ( file ) ;
List < Map < String , Object > > parsed = "docx" . equals ( format )
? parseQuestionDocument ( file ) : parseQuestionSpreadsheet ( file ) ;
if ( parsed . isEmpty ( ) ) throw new IllegalArgumentException ( "未读取到符合模板的题目" ) ;
if ( parsed . isEmpty ( ) ) throw new IllegalArgumentException ( "未读取到符合模板的题目" ) ;
List < Map < String , Object > > created = new ArrayList < > ( ) ;
List < Map < String , Object > > created = new ArrayList < > ( ) ;
for ( Map < String , Object > question : parsed ) created . add ( createQuestion ( teacher , question ) ) ;
for ( Map < String , Object > question : parsed ) created . add ( createQuestion ( teacher , question ) ) ;
return created ;
return created ;
}
}
public void questionTemplate ( HttpServletResponse response ) throws IOException {
public void questionTemplate ( String format , HttpServletResponse response ) throws IOException {
String normalized = StringUtils . defaultIfBlank ( format , "word" ) . trim ( ) . toLowerCase ( Locale . ROOT ) ;
if ( "word" . equals ( normalized ) | | "docx" . equals ( normalized ) ) {
writeQuestionWordTemplate ( response ) ;
} else if ( "excel" . equals ( normalized ) | | "xlsx" . equals ( normalized ) ) {
writeQuestionExcelTemplate ( response ) ;
} else {
throw new IllegalArgumentException ( "模板格式仅支持word或excel" ) ;
}
}
private void writeQuestionWordTemplate ( HttpServletResponse response ) throws IOException {
try ( XWPFDocument doc = new XWPFDocument ( ) ) {
try ( XWPFDocument doc = new XWPFDocument ( ) ) {
addTitle ( doc , "考试题库导入模板" ) ;
configureA4Page ( doc ) ;
addParagraph ( doc , "请保留题型标记、题干、答案和解析字段。不同题目之间空一行;选项使用 A.、B. 格式。" , false ) ;
addTemplateTitle ( doc , "考试题库 Word 导入模板" ) ;
addParagraph ( doc , "[单选题]" , true ) ; addParagraph ( doc , "题干:示例单选题" , false ) ;
addTemplateNote ( doc , "使用说明" , "请删除示例内容后填写。系统按“题干:”拆分题目;同一题型连续出题时,无需重复题型标记。" ) ;
addParagraph ( doc , "A. 选项一" , false ) ; addParagraph ( doc , "B. 选项二" , false ) ;
addTemplateNote ( doc , "格式要求" , "题型使用 [单选题]、[多选题]、[判断题]、[简答题];选项使用 A.、B. 格式;答案字段必须保留,解析可为空。支持中英文冒号和字段内容换行。" ) ;
addParagraph ( doc , "答案: A" , false ) ; addParagraph ( doc , "解析:答案解析,可留空" , false ) ;
addParagraph ( doc , "" , false ) ; addParagraph ( doc , "[多选题]" , true ) ; addParagraph ( doc , "题干:示例多选题" , false ) ;
addTemplateSection ( doc , "[单选题]" ) ;
addParagraph ( doc , "A. 选项一" , false ) ; addParagraph ( doc , "B. 选项二" , false ) ; addParagraph ( doc , "C. 选项三" , false ) ;
addTemplateQuestion ( doc , "题干: 1. 在电商产品开发中,需供比的计算公式是?" ,
addParagraph ( doc , "答案: A,C" , false ) ; addParagraph ( doc , "解析:答案解析,可留空" , false ) ;
"A. 搜索次数 ÷ 在线商品数" , "B. 成交金额 ÷ 在线商家数" , "C. 在线商品数 ÷ 搜索次数" , "D. 转化率 ÷ 跳失率" ,
addParagraph ( doc , "" , false ) ; addParagraph ( doc , "[判断题]" , true ) ; addParagraph ( doc , "题干:示例判断题" , false ) ;
"答案: A" , "解析:需供比等于搜索次数除以在线商品数。" ) ;
addParagraph ( doc , "答案:正确" , false ) ; addParagraph ( doc , "解析:答案解析,可留空" , false ) ;
addTemplateQuestion ( doc , "题干: 2. Min-Max 归一化的核心作用是什么?" ,
addParagraph ( doc , "" , false ) ; addParagraph ( doc , "[简答题]" , true ) ; addParagraph ( doc , "题干:示例简答题" , false ) ;
"A. 删除异常值" , "B. 将数据映射到统一范围" , "C. 增加样本数量" , "D. 改变指标方向" ,
addParagraph ( doc , "答案:参考答案" , false ) ; addParagraph ( doc , "解析:评分说明,可留空" , false ) ;
"答案: B" , "解析:归一化用于消除不同指标量纲带来的影响。" ) ;
writeDocx ( response , doc , "考试题库导入模板.docx" ) ;
addTemplateSection ( doc , "[多选题]" ) ;
addTemplateQuestion ( doc , "题干:电商商品标题优化时,通常需要关注哪些因素?" ,
"A. 核心关键词" , "B. 用户搜索意图" , "C. 平台规则" , "D. 无关热词" ,
"答案: A,B,C" , "解析:标题应兼顾相关性、搜索意图和平台规范。" ) ;
addTemplateSection ( doc , "[判断题]" ) ;
addTemplateQuestion ( doc , "题干:商品转化率越高,通常说明访问流量的成交效率越高。" ,
"答案:正确" , "解析:转化率反映访问用户完成目标行为的比例。" ) ;
addTemplateSection ( doc , "[简答题]" ) ;
addTemplateQuestion ( doc , "题干:请简述新品上架前进行竞品分析的主要目的。" ,
"答案:识别市场需求、竞争格局、价格区间和差异化机会。" ,
"解析:评分时应覆盖需求、竞争、定价和差异化等关键点。" ) ;
writeDocx ( response , doc , "考试题库Word导入模板.docx" ) ;
}
}
private void writeQuestionExcelTemplate ( HttpServletResponse response ) throws IOException {
try ( Workbook workbook = new XSSFWorkbook ( ) ) {
String [ ] headers = { "题型" , "题干" , "选项A" , "选项B" , "选项C" , "选项D" , "选项E" , "选项F" , "选项G" , "选项H" , "正确答案" , "答案解析" } ;
Sheet input = workbook . createSheet ( "题目导入" ) ;
input . addMergedRegion ( new CellRangeAddress ( 0 , 0 , 0 , headers . length - 1 ) ) ;
Row titleRow = input . createRow ( 0 ) ; titleRow . setHeightInPoints ( 30 ) ;
Cell title = titleRow . createCell ( 0 ) ;
title . setCellValue ( "考试题库 Excel 导入模板" ) ;
title . setCellStyle ( excelTitleStyle ( workbook ) ) ;
input . addMergedRegion ( new CellRangeAddress ( 1 , 1 , 0 , headers . length - 1 ) ) ;
Row noteRow = input . createRow ( 1 ) ; noteRow . setHeightInPoints ( 28 ) ;
Cell note = noteRow . createCell ( 0 ) ;
note . setCellValue ( "一题一行;请从第 4 行开始填写。题型、题干、正确答案为必填项,详细规则及示例见“填写说明”。" ) ;
note . setCellStyle ( excelNoteStyle ( workbook ) ) ;
Row header = input . createRow ( 2 ) ;
header . setHeightInPoints ( 24 ) ;
CellStyle headerStyle = excelHeaderStyle ( workbook ) ;
for ( int i = 0 ; i < headers . length ; i + + ) {
Cell cell = header . createCell ( i ) ;
cell . setCellValue ( headers [ i ] ) ;
cell . setCellStyle ( headerStyle ) ;
}
CellStyle entryStyle = excelEntryStyle ( workbook ) ;
for ( int rowIndex = 3 ; rowIndex < = 202 ; rowIndex + + ) {
Row row = input . createRow ( rowIndex ) ;
row . setHeightInPoints ( 34 ) ;
for ( int column = 0 ; column < headers . length ; column + + ) row . createCell ( column ) . setCellStyle ( entryStyle ) ;
}
int [ ] widths = { 14 , 52 , 24 , 24 , 24 , 24 , 24 , 24 , 24 , 24 , 18 , 42 } ;
for ( int i = 0 ; i < widths . length ; i + + ) input . setColumnWidth ( i , widths [ i ] * 256 ) ;
input . createFreezePane ( 0 , 3 ) ;
input . setAutoFilter ( new CellRangeAddress ( 2 , 202 , 0 , headers . length - 1 ) ) ;
DataValidationHelper validationHelper = input . getDataValidationHelper ( ) ;
DataValidationConstraint typeConstraint = validationHelper . createExplicitListConstraint ( new String [ ] { "单选题" , "多选题" , "判断题" , "简答题" } ) ;
DataValidation typeValidation = validationHelper . createValidation ( typeConstraint , new CellRangeAddressList ( 3 , 202 , 0 , 0 ) ) ;
typeValidation . setShowErrorBox ( true ) ;
typeValidation . createErrorBox ( "题型不正确" , "请从下拉列表中选择题型" ) ;
input . addValidationData ( typeValidation ) ;
Sheet guide = workbook . createSheet ( "填写说明" ) ;
writeExcelGuide ( workbook , guide , headers ) ;
response . setContentType ( "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ) ;
downloadHeader ( response , "考试题库Excel导入模板.xlsx" ) ;
workbook . write ( response . getOutputStream ( ) ) ;
}
}
}
}
@ -143,7 +226,8 @@ public class ExamService {
public Map < String , Object > teacherExam ( JwtUser teacher , long id ) {
public Map < String , Object > teacherExam ( JwtUser teacher , long id ) {
requireTeacher ( teacher ) ; autoSubmitExpired ( ) ;
requireTeacher ( teacher ) ; autoSubmitExpired ( ) ;
Map < String , Object > result = ownedExam ( teacher , id ) ;
Map < String , Object > result = ownedExam ( teacher , id ) ;
result . put ( "classes" , jdbc . query ( "select c.teaching_class_id id,s.class_name name from exam_assessment_class c join school_class s on s.school_class_id=c.teaching_class_id where c.exam_id=?" ,
// exam_* 表为 utf8mb4_unicode_ci, 遗留表 school_class/userinfo 沿用各自建表时的排序规则,列对列比较必须显式 COLLATE, 否则报 1267。
result . put ( "classes" , jdbc . query ( "select c.teaching_class_id id,s.class_name name from exam_assessment_class c join school_class s on s.school_class_id=c.teaching_class_id collate utf8mb4_0900_ai_ci where c.exam_id=?" ,
( rs , n ) - > map ( "id" , rs . getString ( "id" ) , "name" , rs . getString ( "name" ) ) , id ) ) ;
( rs , n ) - > map ( "id" , rs . getString ( "id" ) , "name" , rs . getString ( "name" ) ) , id ) ) ;
result . put ( "questions" , examQuestions ( id , true ) ) ;
result . put ( "questions" , examQuestions ( id , true ) ) ;
return result ;
return result ;
@ -237,7 +321,8 @@ public class ExamService {
Map < String , Object > exam = ownedExam ( teacher , examId ) ; autoSubmitExpired ( ) ;
Map < String , Object > exam = ownedExam ( teacher , examId ) ; autoSubmitExpired ( ) ;
boolean running = "IN_PROGRESS" . equals ( exam . get ( "status" ) ) ;
boolean running = "IN_PROGRESS" . equals ( exam . get ( "status" ) ) ;
return jdbc . query ( "select a.id attempt_id,a.student_id,a.teaching_class_id,c.class_name,u.name,u.username,a.attempt_status,a.objective_score,a.subjective_score,a.total_score,a.submitted_at " +
return jdbc . query ( "select a.id attempt_id,a.student_id,a.teaching_class_id,c.class_name,u.name,u.username,a.attempt_status,a.objective_score,a.subjective_score,a.total_score,a.submitted_at " +
"from exam_attempt a join userinfo u on u.user_id=a.student_id join school_class c on c.school_class_id=a.teaching_class_id " +
"from exam_attempt a join userinfo u on u.user_id=a.student_id collate utf8mb4_general_ci " +
"join school_class c on c.school_class_id=a.teaching_class_id collate utf8mb4_0900_ai_ci " +
"where a.exam_id=?" + ( running ? "" : " and a.attempt_status='SUBMITTED'" ) +
"where a.exam_id=?" + ( running ? "" : " and a.attempt_status='SUBMITTED'" ) +
" order by case when a.attempt_status='SUBMITTED' then 0 else 1 end,a.submitted_at,u.username" ,
" order by case when a.attempt_status='SUBMITTED' then 0 else 1 end,a.submitted_at,u.username" ,
( rs , n ) - > map ( "attemptId" , rs . getLong ( "attempt_id" ) , "studentId" , rs . getString ( "student_id" ) , "classId" , rs . getString ( "teaching_class_id" ) ,
( rs , n ) - > map ( "attemptId" , rs . getLong ( "attempt_id" ) , "studentId" , rs . getString ( "student_id" ) , "classId" , rs . getString ( "teaching_class_id" ) ,
@ -629,24 +714,103 @@ public class ExamService {
private List < Map < String , Object > > parseQuestionDocument ( MultipartFile file ) {
private List < Map < String , Object > > parseQuestionDocument ( MultipartFile file ) {
try ( XWPFDocument doc = new XWPFDocument ( file . getInputStream ( ) ) ) {
try ( XWPFDocument doc = new XWPFDocument ( file . getInputStream ( ) ) ) {
List < String > lines = doc . getParagraphs ( ) . stream ( ) . map ( XWPFParagraph : : getText ) . map ( String : : trim ) . collect ( Collectors . toList ( ) ) ;
List < String > lines = new ArrayList < > ( ) ;
List < Map < String , Object > > result = new ArrayList < > ( ) ; Map < String , Object > current = null ; List < Map < String , Object > > options = null ;
for ( XWPFParagraph paragraph : doc . getParagraphs ( ) ) {
String paragraphText = StringUtils . defaultString ( paragraph . getText ( ) ) ;
String [ ] paragraphLines = paragraphText . split ( "\\R" , - 1 ) ;
for ( String paragraphLine : paragraphLines ) lines . add ( paragraphLine . trim ( ) ) ;
}
List < Map < String , Object > > result = new ArrayList < > ( ) ;
String currentType = null ; String activeField = null ;
Map < String , Object > current = null ; List < Map < String , Object > > options = null ; Map < String , Object > lastOption = null ;
for ( String line : lines ) {
for ( String line : lines ) {
String type = typeMarker ( line ) ;
String type = typeMarker ( line ) ;
if ( type ! = null ) { if ( current ! = null ) finishImported ( current , options , result ) ; current = new LinkedHashMap < > ( ) ; current . put ( "type" , type ) ; options = new ArrayList < > ( ) ; continue ; }
if ( type ! = null ) {
if ( current ! = null ) finishImported ( current , options , result , "第 " + ( result . size ( ) + 1 ) + " 题" ) ;
currentType = type ; current = null ; options = null ; lastOption = null ; activeField = null ;
continue ;
}
if ( isImportField ( line , "题干" ) ) {
if ( current ! = null ) finishImported ( current , options , result , "第 " + ( result . size ( ) + 1 ) + " 题" ) ;
if ( currentType = = null ) throw new IllegalArgumentException ( "第 " + ( result . size ( ) + 1 ) + " 题:题干前缺少题型标记" ) ;
current = new LinkedHashMap < > ( ) ; current . put ( "type" , currentType ) ;
current . put ( "stem" , stripQuestionNumber ( afterColon ( line ) ) ) ;
options = new ArrayList < > ( ) ; lastOption = null ; activeField = "stem" ;
continue ;
}
if ( current = = null | | line . isEmpty ( ) ) continue ;
if ( current = = null | | line . isEmpty ( ) ) continue ;
if ( line . startsWith ( "题干:" ) | | line . startsWith ( "题干:" ) ) current . put ( "stem" , afterColon ( line ) ) ;
Matcher optionMatcher = OPTION_LINE . matcher ( line ) ;
else if ( line . matches ( "^[A-Za-z][\\.、].+" ) ) options . add ( map ( "label" , line . substring ( 0 , 1 ) . toUpperCase ( ) , "content" , line . substring ( 2 ) . trim ( ) ) ) ;
if ( optionMatcher . matches ( ) ) {
else if ( line . startsWith ( "答案:" ) | | line . startsWith ( "答案:" ) ) current . put ( "answer" , importAnswer ( text ( current . get ( "type" ) ) , afterColon ( line ) ) ) ;
lastOption = map ( "label" , optionMatcher . group ( 1 ) . toUpperCase ( Locale . ROOT ) , "content" , optionMatcher . group ( 2 ) . trim ( ) ) ;
else if ( line . startsWith ( "解析:" ) | | line . startsWith ( "解析:" ) ) current . put ( "analysis" , afterColon ( line ) ) ;
options . add ( lastOption ) ; activeField = "option" ;
} else if ( isImportField ( line , "答案" ) ) {
current . put ( "answer" , afterColon ( line ) ) ; activeField = "answer" ; lastOption = null ;
} else if ( isImportField ( line , "解析" ) ) {
current . put ( "analysis" , afterColon ( line ) ) ; activeField = "analysis" ; lastOption = null ;
} else if ( "option" . equals ( activeField ) & & lastOption ! = null ) {
lastOption . put ( "content" , appendImportText ( lastOption . get ( "content" ) , line ) ) ;
} else if ( activeField ! = null ) {
current . put ( activeField , appendImportText ( current . get ( activeField ) , line ) ) ;
}
}
}
if ( current ! = null ) finishImported ( current , options , result ) ;
if ( current ! = null ) finishImported ( current , options , result , "第 " + ( result . size ( ) + 1 ) + " 题" );
return result ;
return result ;
} catch ( IOException e ) { throw new IllegalArgumentException ( "Word文件读取失败" ) ; }
} catch ( IOException e ) { throw new IllegalArgumentException ( "Word文件读取失败" ) ; }
}
}
private void finishImported ( Map < String , Object > current , List < Map < String , Object > > options , List < Map < String , Object > > result ) {
private List < Map < String , Object > > parseQuestionSpreadsheet ( MultipartFile file ) {
current . put ( "options" , options ) ; validateQuestion ( current ) ; result . add ( current ) ;
try ( Workbook workbook = WorkbookFactory . create ( file . getInputStream ( ) ) ) {
if ( workbook . getNumberOfSheets ( ) = = 0 ) throw new IllegalArgumentException ( "Excel文件中没有工作表" ) ;
Sheet sheet = workbook . getSheetAt ( 0 ) ;
DataFormatter formatter = new DataFormatter ( Locale . CHINA ) ;
int headerRowIndex = findQuestionHeaderRow ( sheet , formatter ) ;
if ( headerRowIndex < 0 ) throw new IllegalArgumentException ( "Excel前20行中未找到完整表头: 题型、题干、正确答案" ) ;
Map < String , Integer > headers = questionHeaders ( sheet . getRow ( headerRowIndex ) , formatter ) ;
List < Map < String , Object > > result = new ArrayList < > ( ) ;
for ( int rowIndex = headerRowIndex + 1 ; rowIndex < = sheet . getLastRowNum ( ) ; rowIndex + + ) {
Row row = sheet . getRow ( rowIndex ) ;
if ( row = = null | | questionRowIsBlank ( row , headers , formatter ) ) continue ;
int excelRow = rowIndex + 1 ;
String rawType = questionCell ( row , headers . get ( "题型" ) , formatter ) ;
String type = normalizeImportedType ( rawType ) ;
if ( type = = null ) throw new IllegalArgumentException ( "Excel 第 " + excelRow + " 行:不支持的题型“" + rawType + "”" ) ;
Map < String , Object > question = new LinkedHashMap < > ( ) ;
question . put ( "type" , type ) ;
question . put ( "stem" , stripQuestionNumber ( questionCell ( row , headers . get ( "题干" ) , formatter ) ) ) ;
List < Map < String , Object > > options = new ArrayList < > ( ) ;
for ( char label = 'A' ; label < = 'H' ; label + + ) {
Integer column = headers . get ( "选项" + label ) ;
String content = questionCell ( row , column , formatter ) ;
if ( StringUtils . isNotBlank ( content ) ) options . add ( map ( "label" , String . valueOf ( label ) , "content" , content ) ) ;
}
question . put ( "options" , options ) ;
question . put ( "answer" , importAnswer ( type , questionCell ( row , headers . get ( "正确答案" ) , formatter ) ) ) ;
question . put ( "analysis" , questionCell ( row , headers . get ( "答案解析" ) , formatter ) ) ;
validateImportedQuestion ( question , "Excel 第 " + excelRow + " 行" ) ;
result . add ( question ) ;
}
return result ;
} catch ( IllegalArgumentException e ) {
throw e ;
} catch ( Exception e ) {
throw new IllegalArgumentException ( "Excel文件读取失败" ) ;
}
}
private void finishImported ( Map < String , Object > current , List < Map < String , Object > > options ,
List < Map < String , Object > > result , String context ) {
current . put ( "options" , options = = null ? new ArrayList < > ( ) : options ) ;
current . put ( "answer" , importAnswer ( text ( current . get ( "type" ) ) , text ( current . get ( "answer" ) ) ) ) ;
validateImportedQuestion ( current , context ) ;
result . add ( current ) ;
}
private void validateImportedQuestion ( Map < String , Object > question , String context ) {
try {
validateQuestion ( question ) ;
} catch ( IllegalArgumentException e ) {
throw new IllegalArgumentException ( context + ": " + e . getMessage ( ) ) ;
}
}
}
private Object importAnswer ( String type , String raw ) {
private Object importAnswer ( String type , String raw ) {
@ -660,6 +824,64 @@ public class ExamService {
return raw ;
return raw ;
}
}
private int findQuestionHeaderRow ( Sheet sheet , DataFormatter formatter ) {
int last = Math . min ( sheet . getLastRowNum ( ) , 19 ) ;
for ( int i = 0 ; i < = last ; i + + ) {
Map < String , Integer > headers = questionHeaders ( sheet . getRow ( i ) , formatter ) ;
if ( headers . containsKey ( "题型" ) & & headers . containsKey ( "题干" ) & & headers . containsKey ( "正确答案" ) ) return i ;
}
return - 1 ;
}
private Map < String , Integer > questionHeaders ( Row row , DataFormatter formatter ) {
Map < String , Integer > headers = new LinkedHashMap < > ( ) ;
if ( row = = null ) return headers ;
for ( Cell cell : row ) {
String value = normalizeQuestionHeader ( formatter . formatCellValue ( cell ) ) ;
if ( "答案" . equals ( value ) ) value = "正确答案" ;
if ( "解析" . equals ( value ) ) value = "答案解析" ;
if ( "题型" . equals ( value ) | | "题干" . equals ( value ) | | "正确答案" . equals ( value ) | | "答案解析" . equals ( value )
| | value . matches ( "选项[A-H]" ) ) headers . putIfAbsent ( value , cell . getColumnIndex ( ) ) ;
}
return headers ;
}
private boolean questionRowIsBlank ( Row row , Map < String , Integer > headers , DataFormatter formatter ) {
for ( Integer column : headers . values ( ) ) if ( StringUtils . isNotBlank ( questionCell ( row , column , formatter ) ) ) return false ;
return true ;
}
private static String questionCell ( Row row , Integer column , DataFormatter formatter ) {
if ( row = = null | | column = = null ) return "" ;
Cell cell = row . getCell ( column , Row . MissingCellPolicy . RETURN_BLANK_AS_NULL ) ;
return cell = = null ? "" : formatter . formatCellValue ( cell ) . trim ( ) ;
}
private static String normalizeQuestionHeader ( String value ) {
return StringUtils . trimToEmpty ( value ) . replace ( "\uFEFF" , "" ) . replaceAll ( "\\s+" , "" ) ;
}
private static String normalizeImportedType ( String value ) {
String normalized = StringUtils . trimToEmpty ( value ) ;
String marker = typeMarker ( normalized ) ;
if ( marker ! = null ) return marker ;
String internal = normalized . toUpperCase ( Locale . ROOT ) ;
return TYPES . contains ( internal ) ? internal : null ;
}
private static boolean isImportField ( String line , String field ) {
return StringUtils . defaultString ( line ) . matches ( "^" + field + "\\s*[:: ].*" ) ;
}
private static String appendImportText ( Object current , String addition ) {
String existing = text ( current ) ;
return StringUtils . isBlank ( existing ) ? addition . trim ( ) : existing + "\n" + addition . trim ( ) ;
}
private static String stripQuestionNumber ( String value ) {
return QUESTION_NUMBER . matcher ( StringUtils . trimToEmpty ( value ) ) . replaceFirst ( "" ) . trim ( ) ;
}
private XWPFDocument buildPaperDocument ( Map < String , Object > exam , List < Map < String , Object > > questions , Map < String , Object > student , boolean answers ) {
private XWPFDocument buildPaperDocument ( Map < String , Object > exam , List < Map < String , Object > > questions , Map < String , Object > student , boolean answers ) {
XWPFDocument doc = new XWPFDocument ( ) ; addTitle ( doc , text ( exam . get ( "name" ) ) ) ;
XWPFDocument doc = new XWPFDocument ( ) ; addTitle ( doc , text ( exam . get ( "name" ) ) ) ;
if ( student ! = null ) addParagraph ( doc , "班级:" + text ( student . get ( "className" ) ) + " 姓名:" + text ( student . get ( "name" ) ) + " 学号:" + text ( student . get ( "studentNo" ) ) , false ) ;
if ( student ! = null ) addParagraph ( doc , "班级:" + text ( student . get ( "className" ) ) + " 姓名:" + text ( student . get ( "name" ) ) + " 学号:" + text ( student . get ( "studentNo" ) ) , false ) ;
@ -681,6 +903,168 @@ public class ExamService {
return map ( "name" , name , "value" , count ) ;
return map ( "name" , name , "value" , count ) ;
}
}
private static void addTemplateTitle ( XWPFDocument doc , String value ) {
XWPFParagraph paragraph = doc . createParagraph ( ) ;
paragraph . setAlignment ( ParagraphAlignment . CENTER ) ;
paragraph . setSpacingAfter ( 180 ) ;
XWPFRun run = paragraph . createRun ( ) ;
run . setBold ( true ) ; run . setFontFamily ( "Microsoft YaHei" ) ; run . setFontSize ( 20 ) ; run . setColor ( "123047" ) ; run . setText ( value ) ;
}
private static void configureA4Page ( XWPFDocument doc ) {
String namespace = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" ;
CTSectPr section = doc . getDocument ( ) . getBody ( ) . addNewSectPr ( ) ;
Node sectionNode = section . getDomNode ( ) ;
Document document = sectionNode . getOwnerDocument ( ) ;
Element pageSize = document . createElementNS ( namespace , "w:pgSz" ) ;
pageSize . setAttributeNS ( namespace , "w:w" , "11906" ) ; pageSize . setAttributeNS ( namespace , "w:h" , "16838" ) ;
sectionNode . appendChild ( pageSize ) ;
Element margins = document . createElementNS ( namespace , "w:pgMar" ) ;
margins . setAttributeNS ( namespace , "w:top" , "1134" ) ; margins . setAttributeNS ( namespace , "w:right" , "1276" ) ;
margins . setAttributeNS ( namespace , "w:bottom" , "1134" ) ; margins . setAttributeNS ( namespace , "w:left" , "1276" ) ;
sectionNode . appendChild ( margins ) ;
}
private static void addTemplateNote ( XWPFDocument doc , String label , String value ) {
XWPFParagraph paragraph = doc . createParagraph ( ) ;
paragraph . setIndentationLeft ( 240 ) ; paragraph . setIndentationRight ( 240 ) ; paragraph . setSpacingAfter ( 70 ) ;
XWPFRun labelRun = paragraph . createRun ( ) ;
labelRun . setBold ( true ) ; labelRun . setFontFamily ( "Microsoft YaHei" ) ; labelRun . setFontSize ( 10 ) ; labelRun . setColor ( "167C80" ) ; labelRun . setText ( label + ": " ) ;
XWPFRun valueRun = paragraph . createRun ( ) ;
valueRun . setFontFamily ( "Microsoft YaHei" ) ; valueRun . setFontSize ( 10 ) ; valueRun . setColor ( "34495E" ) ; valueRun . setText ( value ) ;
}
private static void addTemplateSection ( XWPFDocument doc , String value ) {
XWPFParagraph paragraph = doc . createParagraph ( ) ;
paragraph . setSpacingBefore ( 180 ) ; paragraph . setSpacingAfter ( 80 ) ;
XWPFRun run = paragraph . createRun ( ) ;
run . setBold ( true ) ; run . setFontFamily ( "Microsoft YaHei" ) ; run . setFontSize ( 13 ) ; run . setColor ( "0F6670" ) ; run . setText ( value ) ;
}
private static void addTemplateQuestion ( XWPFDocument doc , String . . . lines ) {
for ( String line : lines ) {
XWPFParagraph paragraph = doc . createParagraph ( ) ;
paragraph . setIndentationLeft ( 180 ) ; paragraph . setSpacingAfter ( 45 ) ; paragraph . setSpacingBetween ( 1.15 ) ;
int colon = Math . max ( line . indexOf ( ': ' ) , line . indexOf ( ':' ) ) ;
if ( colon > 0 & & ( line . startsWith ( "题干" ) | | line . startsWith ( "答案" ) | | line . startsWith ( "解析" ) ) ) {
XWPFRun label = paragraph . createRun ( ) ;
label . setBold ( true ) ; label . setFontFamily ( "Microsoft YaHei" ) ; label . setFontSize ( 10 ) ; label . setColor ( "23384A" ) ; label . setText ( line . substring ( 0 , colon + 1 ) ) ;
XWPFRun content = paragraph . createRun ( ) ;
content . setFontFamily ( "Microsoft YaHei" ) ; content . setFontSize ( 10 ) ; content . setColor ( "23384A" ) ; content . setText ( line . substring ( colon + 1 ) ) ;
} else {
XWPFRun run = paragraph . createRun ( ) ;
run . setFontFamily ( "Microsoft YaHei" ) ; run . setFontSize ( 10 ) ; run . setColor ( "34495E" ) ; run . setText ( line ) ;
}
}
XWPFParagraph spacer = doc . createParagraph ( ) ;
spacer . setSpacingAfter ( 40 ) ;
}
private static void writeExcelGuide ( Workbook workbook , Sheet guide , String [ ] headers ) {
guide . addMergedRegion ( new CellRangeAddress ( 0 , 0 , 0 , headers . length - 1 ) ) ;
Row titleRow = guide . createRow ( 0 ) ; titleRow . setHeightInPoints ( 30 ) ;
Cell title = titleRow . createCell ( 0 ) ;
title . setCellValue ( "考试题库 Excel 填写说明" ) ; title . setCellStyle ( excelTitleStyle ( workbook ) ) ;
String [ ] [ ] rules = {
{ "必填字段" , "题型、题干、正确答案。答案解析可以为空。" } ,
{ "题型写法" , "单选题、多选题、判断题、简答题,也支持 SINGLE、MULTIPLE、TRUE_FALSE、SHORT_ANSWER。" } ,
{ "选择题" , "至少填写两个选项;单选答案写一个字母,多选答案使用英文逗号分隔,如 A,C。" } ,
{ "判断题" , "正确答案填写“正确”或“错误”,也支持“对/错”和 true/false。" } ,
{ "简答题" , "正确答案填写参考答案;答案解析可用于填写评分说明。" } ,
{ "导入范围" , "系统只读取第一张工作表“题目导入”,本页示例不会被导入。" }
} ;
CellStyle ruleLabel = excelRuleLabelStyle ( workbook ) ;
CellStyle ruleText = excelRuleTextStyle ( workbook ) ;
for ( int i = 0 ; i < rules . length ; i + + ) {
Row row = guide . createRow ( i + 2 ) ; row . setHeightInPoints ( 28 ) ;
Cell label = row . createCell ( 0 ) ; label . setCellValue ( rules [ i ] [ 0 ] ) ; label . setCellStyle ( ruleLabel ) ;
Cell value = row . createCell ( 1 ) ; value . setCellValue ( rules [ i ] [ 1 ] ) ; value . setCellStyle ( ruleText ) ;
guide . addMergedRegion ( new CellRangeAddress ( i + 2 , i + 2 , 1 , headers . length - 1 ) ) ;
for ( int column = 2 ; column < headers . length ; column + + ) row . createCell ( column ) . setCellStyle ( ruleText ) ;
}
int headerRowIndex = rules . length + 3 ;
Row exampleTitle = guide . createRow ( headerRowIndex - 1 ) ;
exampleTitle . createCell ( 0 ) . setCellValue ( "四种题型填写示例(仅供参考,不会被导入)" ) ;
exampleTitle . getCell ( 0 ) . setCellStyle ( excelSectionStyle ( workbook ) ) ;
guide . addMergedRegion ( new CellRangeAddress ( headerRowIndex - 1 , headerRowIndex - 1 , 0 , headers . length - 1 ) ) ;
Row header = guide . createRow ( headerRowIndex ) ;
header . setHeightInPoints ( 24 ) ;
CellStyle headerStyle = excelHeaderStyle ( workbook ) ;
for ( int i = 0 ; i < headers . length ; i + + ) {
Cell cell = header . createCell ( i ) ; cell . setCellValue ( headers [ i ] ) ; cell . setCellStyle ( headerStyle ) ;
}
String [ ] [ ] examples = {
{ "单选题" , "需供比的计算公式是?" , "搜索次数 ÷ 在线商品数" , "成交金额 ÷ 在线商家数" , "在线商品数 ÷ 搜索次数" , "转化率 ÷ 跳失率" , "" , "" , "" , "" , "A" , "需供比反映市场供需关系。" } ,
{ "多选题" , "商品标题优化应关注哪些因素?" , "核心关键词" , "搜索意图" , "平台规则" , "无关热词" , "" , "" , "" , "" , "A,B,C" , "选择与商品相关且符合规范的要素。" } ,
{ "判断题" , "转化率越高通常说明成交效率越高。" , "" , "" , "" , "" , "" , "" , "" , "" , "正确" , "判断题无需填写选项。" } ,
{ "简答题" , "请简述竞品分析的主要目的。" , "" , "" , "" , "" , "" , "" , "" , "" , "识别需求、竞争、定价和差异化机会。" , "简答题答案填写参考答案。" }
} ;
CellStyle exampleStyle = excelEntryStyle ( workbook ) ;
for ( int i = 0 ; i < examples . length ; i + + ) {
Row row = guide . createRow ( headerRowIndex + i + 1 ) ; row . setHeightInPoints ( 42 ) ;
for ( int j = 0 ; j < headers . length ; j + + ) {
Cell cell = row . createCell ( j ) ;
if ( StringUtils . isNotBlank ( examples [ i ] [ j ] ) ) cell . setCellValue ( examples [ i ] [ j ] ) ;
cell . setCellStyle ( exampleStyle ) ;
}
}
int [ ] widths = { 14 , 44 , 22 , 22 , 22 , 22 , 18 , 18 , 18 , 18 , 28 , 38 } ;
for ( int i = 0 ; i < widths . length ; i + + ) guide . setColumnWidth ( i , widths [ i ] * 256 ) ;
guide . createFreezePane ( 0 , headerRowIndex + 1 ) ;
}
private static CellStyle excelTitleStyle ( Workbook workbook ) {
CellStyle style = workbook . createCellStyle ( ) ;
style . setFillForegroundColor ( IndexedColors . DARK_TEAL . getIndex ( ) ) ; style . setFillPattern ( FillPatternType . SOLID_FOREGROUND ) ;
style . setAlignment ( HorizontalAlignment . CENTER ) ; style . setVerticalAlignment ( VerticalAlignment . CENTER ) ;
Font font = workbook . createFont ( ) ; font . setBold ( true ) ; font . setFontHeightInPoints ( ( short ) 16 ) ; font . setColor ( IndexedColors . WHITE . getIndex ( ) ) ;
style . setFont ( font ) ; return style ;
}
private static CellStyle excelNoteStyle ( Workbook workbook ) {
CellStyle style = workbook . createCellStyle ( ) ;
style . setFillForegroundColor ( IndexedColors . LIGHT_TURQUOISE . getIndex ( ) ) ; style . setFillPattern ( FillPatternType . SOLID_FOREGROUND ) ;
style . setWrapText ( true ) ; style . setVerticalAlignment ( VerticalAlignment . CENTER ) ;
Font font = workbook . createFont ( ) ; font . setColor ( IndexedColors . DARK_TEAL . getIndex ( ) ) ; font . setFontHeightInPoints ( ( short ) 10 ) ;
style . setFont ( font ) ; return style ;
}
private static CellStyle excelHeaderStyle ( Workbook workbook ) {
CellStyle style = workbook . createCellStyle ( ) ;
style . setFillForegroundColor ( IndexedColors . DARK_TEAL . getIndex ( ) ) ; style . setFillPattern ( FillPatternType . SOLID_FOREGROUND ) ;
style . setAlignment ( HorizontalAlignment . CENTER ) ; style . setVerticalAlignment ( VerticalAlignment . CENTER ) ;
style . setBorderTop ( BorderStyle . THIN ) ; style . setBorderBottom ( BorderStyle . THIN ) ; style . setBorderLeft ( BorderStyle . THIN ) ; style . setBorderRight ( BorderStyle . THIN ) ;
Font font = workbook . createFont ( ) ; font . setBold ( true ) ; font . setColor ( IndexedColors . WHITE . getIndex ( ) ) ;
style . setFont ( font ) ; return style ;
}
private static CellStyle excelEntryStyle ( Workbook workbook ) {
CellStyle style = workbook . createCellStyle ( ) ;
style . setWrapText ( true ) ; style . setVerticalAlignment ( VerticalAlignment . TOP ) ;
style . setBorderTop ( BorderStyle . HAIR ) ; style . setBorderBottom ( BorderStyle . HAIR ) ; style . setBorderLeft ( BorderStyle . HAIR ) ; style . setBorderRight ( BorderStyle . HAIR ) ;
style . setFillForegroundColor ( IndexedColors . WHITE . getIndex ( ) ) ; style . setFillPattern ( FillPatternType . SOLID_FOREGROUND ) ;
return style ;
}
private static CellStyle excelRuleLabelStyle ( Workbook workbook ) {
CellStyle style = excelEntryStyle ( workbook ) ;
style . setFillForegroundColor ( IndexedColors . LIGHT_TURQUOISE . getIndex ( ) ) ;
Font font = workbook . createFont ( ) ; font . setBold ( true ) ; font . setColor ( IndexedColors . DARK_TEAL . getIndex ( ) ) ;
style . setFont ( font ) ; return style ;
}
private static CellStyle excelRuleTextStyle ( Workbook workbook ) {
CellStyle style = excelEntryStyle ( workbook ) ; style . setVerticalAlignment ( VerticalAlignment . CENTER ) ; return style ;
}
private static CellStyle excelSectionStyle ( Workbook workbook ) {
CellStyle style = workbook . createCellStyle ( ) ;
style . setFillForegroundColor ( IndexedColors . LIGHT_ORANGE . getIndex ( ) ) ; style . setFillPattern ( FillPatternType . SOLID_FOREGROUND ) ;
style . setVerticalAlignment ( VerticalAlignment . CENTER ) ;
Font font = workbook . createFont ( ) ; font . setBold ( true ) ; font . setColor ( IndexedColors . BROWN . getIndex ( ) ) ;
style . setFont ( font ) ; return style ;
}
private static void addTitle ( XWPFDocument doc , String value ) { XWPFParagraph p = doc . createParagraph ( ) ; p . setAlignment ( ParagraphAlignment . CENTER ) ; XWPFRun r = p . createRun ( ) ; r . setBold ( true ) ; r . setFontSize ( 18 ) ; r . setText ( value ) ; }
private static void addTitle ( XWPFDocument doc , String value ) { XWPFParagraph p = doc . createParagraph ( ) ; p . setAlignment ( ParagraphAlignment . CENTER ) ; XWPFRun r = p . createRun ( ) ; r . setBold ( true ) ; r . setFontSize ( 18 ) ; r . setText ( value ) ; }
private static void addParagraph ( XWPFDocument doc , String value , boolean bold ) { XWPFRun r = doc . createParagraph ( ) . createRun ( ) ; r . setBold ( bold ) ; r . setFontSize ( 11 ) ; r . setText ( value ) ; }
private static void addParagraph ( XWPFDocument doc , String value , boolean bold ) { XWPFRun r = doc . createParagraph ( ) . createRun ( ) ; r . setBold ( bold ) ; r . setFontSize ( 11 ) ; r . setText ( value ) ; }
private static void copy ( InputStream in , OutputStream out ) throws IOException { byte [ ] b = new byte [ 8192 ] ; int n ; while ( ( n = in . read ( b ) ) > = 0 ) out . write ( b , 0 , n ) ; }
private static void copy ( InputStream in , OutputStream out ) throws IOException { byte [ ] b = new byte [ 8192 ] ; int n ; while ( ( n = in . read ( b ) ) > = 0 ) out . write ( b , 0 , n ) ; }
@ -688,7 +1072,8 @@ public class ExamService {
private static String displayAnswer ( Object answer ) { if ( answer = = null ) return "未作答" ; if ( answer instanceof Collection ) return ( ( Collection < ? > ) answer ) . stream ( ) . map ( String : : valueOf ) . collect ( Collectors . joining ( "、" ) ) ; return String . valueOf ( answer ) ; }
private static String displayAnswer ( Object answer ) { if ( answer = = null ) return "未作答" ; if ( answer instanceof Collection ) return ( ( Collection < ? > ) answer ) . stream ( ) . map ( String : : valueOf ) . collect ( Collectors . joining ( "、" ) ) ; return String . valueOf ( answer ) ; }
static String typeMarker ( String line ) {
static String typeMarker ( String line ) {
String marker = StringUtils . trimToEmpty ( line ) ;
String marker = StringUtils . trimToEmpty ( line ) ;
if ( marker . startsWith ( "[" ) & & marker . endsWith ( "]" ) & & marker . length ( ) > 2 ) marker = marker . substring ( 1 , marker . length ( ) - 1 ) . trim ( ) ;
if ( ( ( marker . startsWith ( "[" ) & & marker . endsWith ( "]" ) ) | | ( marker . startsWith ( "【" ) & & marker . endsWith ( "】" ) ) ) & & marker . length ( ) > 2 )
marker = marker . substring ( 1 , marker . length ( ) - 1 ) . trim ( ) ;
if ( "单选题" . equals ( marker ) ) return SINGLE ;
if ( "单选题" . equals ( marker ) ) return SINGLE ;
if ( "多选题" . equals ( marker ) ) return MULTIPLE ;
if ( "多选题" . equals ( marker ) ) return MULTIPLE ;
if ( "判断题" . equals ( marker ) ) return TRUE_FALSE ;
if ( "判断题" . equals ( marker ) ) return TRUE_FALSE ;
@ -697,7 +1082,14 @@ public class ExamService {
}
}
private static String afterColon ( String line ) { int i = Math . max ( line . indexOf ( ': ' ) , line . indexOf ( ':' ) ) ; return i < 0 ? "" : line . substring ( i + 1 ) . trim ( ) ; }
private static String afterColon ( String line ) { int i = Math . max ( line . indexOf ( ': ' ) , line . indexOf ( ':' ) ) ; return i < 0 ? "" : line . substring ( i + 1 ) . trim ( ) ; }
private static void validateType ( String type ) { if ( ! TYPES . contains ( type ) ) throw new IllegalArgumentException ( "不支持的题型" ) ; }
private static void validateType ( String type ) { if ( ! TYPES . contains ( type ) ) throw new IllegalArgumentException ( "不支持的题型" ) ; }
private static void validateDocx ( MultipartFile file ) { if ( file = = null | | file . isEmpty ( ) | | ! safeFilename ( file . getOriginalFilename ( ) ) . toLowerCase ( ) . endsWith ( ".docx" ) ) throw new IllegalArgumentException ( "请选择docx格式的Word文件" ) ; }
private static String validateQuestionImportFile ( MultipartFile file ) {
if ( file = = null | | file . isEmpty ( ) ) throw new IllegalArgumentException ( "请选择要导入的Word或Excel文件" ) ;
String filename = safeFilename ( file . getOriginalFilename ( ) ) . toLowerCase ( Locale . ROOT ) ;
int dot = filename . lastIndexOf ( '.' ) ;
String extension = dot < 0 ? "" : filename . substring ( dot + 1 ) ;
if ( ! Arrays . asList ( "docx" , "xls" , "xlsx" ) . contains ( extension ) ) throw new IllegalArgumentException ( "仅支持docx、xls或xlsx格式" ) ;
return extension ;
}
private static void validateAttachment ( MultipartFile file ) { if ( file = = null | | file . isEmpty ( ) ) throw new IllegalArgumentException ( "请选择附件" ) ; if ( file . getSize ( ) > MAX_ATTACHMENT_SIZE ) throw new IllegalArgumentException ( "附件不能超过20MB" ) ; String n = safeFilename ( file . getOriginalFilename ( ) ) ; int i = n . lastIndexOf ( '.' ) ; if ( i < 0 | | ! ALLOWED_ATTACHMENTS . contains ( n . substring ( i + 1 ) . toLowerCase ( ) ) ) throw new IllegalArgumentException ( "附件仅支持PDF、Word和图片格式" ) ; }
private static void validateAttachment ( MultipartFile file ) { if ( file = = null | | file . isEmpty ( ) ) throw new IllegalArgumentException ( "请选择附件" ) ; if ( file . getSize ( ) > MAX_ATTACHMENT_SIZE ) throw new IllegalArgumentException ( "附件不能超过20MB" ) ; String n = safeFilename ( file . getOriginalFilename ( ) ) ; int i = n . lastIndexOf ( '.' ) ; if ( i < 0 | | ! ALLOWED_ATTACHMENTS . contains ( n . substring ( i + 1 ) . toLowerCase ( ) ) ) throw new IllegalArgumentException ( "附件仅支持PDF、Word和图片格式" ) ; }
private static String safeFilename ( String name ) { return StringUtils . defaultString ( name ) . replace ( "\\" , "_" ) . replace ( "/" , "_" ) ; }
private static String safeFilename ( String name ) { return StringUtils . defaultString ( name ) . replace ( "\\" , "_" ) . replace ( "/" , "_" ) ; }
private static void requireTeacher ( JwtUser user ) { if ( user = = null ) throw new AccessDeniedException ( "请先登录" ) ; if ( user . getRoleId ( ) ! = 3 ) throw new AccessDeniedException ( "仅教师可以操作" ) ; }
private static void requireTeacher ( JwtUser user ) { if ( user = = null ) throw new AccessDeniedException ( "请先登录" ) ; if ( user . getRoleId ( ) ! = 3 ) throw new AccessDeniedException ( "仅教师可以操作" ) ; }