package com.gxwebsoft.gxmu.controller; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import cn.hutool.core.util.StrUtil; import com.gxwebsoft.common.core.annotation.OperationLog; import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.core.web.BatchParam; import com.gxwebsoft.common.core.web.PageParam; import com.gxwebsoft.common.core.web.PageResult; import com.gxwebsoft.common.system.entity.FileRecord; import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.service.FileRecordService; import com.gxwebsoft.gxmu.entity.QmgcForm; import com.gxwebsoft.gxmu.entity.SjqnForm; import com.gxwebsoft.gxmu.entity.SjtbzbsjForm; import com.gxwebsoft.gxmu.entity.TzbcyForm; import com.gxwebsoft.gxmu.entity.WxxzxForm; import com.gxwebsoft.gxmu.entity.WshqtwForm; import com.gxwebsoft.gxmu.entity.WshqtzbForm; import com.gxwebsoft.gxmu.entity.YxgqtdgbForm; import com.gxwebsoft.gxmu.entity.YxgqtyForm; import com.gxwebsoft.gxmu.model.WxxzxDocxValidationResult; import com.gxwebsoft.gxmu.model.WxxzxTopicSemanticSearchResult; import com.gxwebsoft.gxmu.param.WxxzxMaterialUpdateParam; import com.gxwebsoft.gxmu.param.WxxzxFormParam; import com.gxwebsoft.gxmu.service.QmgcFormService; import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; import com.gxwebsoft.gxmu.model.DeclareCheckResult; import com.gxwebsoft.gxmu.service.DeclareGuardService; import org.springframework.transaction.annotation.Transactional; import com.gxwebsoft.gxmu.service.SjqnFormService; import com.gxwebsoft.gxmu.service.SjtbzbsjFormService; import com.gxwebsoft.gxmu.service.TzbcyFormService; import com.gxwebsoft.gxmu.service.WxxzxFormService; import com.gxwebsoft.gxmu.service.WshqtwFormService; import com.gxwebsoft.gxmu.service.WshqtzbFormService; import com.gxwebsoft.gxmu.service.YxgqtdgbFormService; import com.gxwebsoft.gxmu.service.YxgqtyFormService; import com.gxwebsoft.gxmu.util.GxmuQueryHelper; import com.gxwebsoft.gxmu.util.WxxzxDocxExportUtil; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFRun; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Comparator; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.stream.Collectors; import java.util.zip.ZipOutputStream; /** * 未来学术之星课题申报表控制器 */ @Api(tags = "未来学术之星课题申报表管理") @RestController @RequestMapping("/api/gxmu/wxxzx-form") public class WxxzxFormController extends BaseController { private static final String PROJECT_BOOK_KEYWORD = "项目申报书"; private static final String TITLE_FONT_CN = "黑体"; private static final String TITLE_FONT_EN = "SimHei"; private static final double TITLE_FONT_SIZE = 26D; private static final String BODY_FONT_CN = "宋体"; private static final String BODY_FONT_EN = "SimSun"; private static final double BODY_FONT_SIZE = 10.5D; private static final int MAX_DETAIL_ITEMS = 20; private static final int MAX_TOPIC_SEARCH_RESULTS = 30; private static final String DEFAULT_TOPIC_SEARCH_MODULE = "gxmu_wxxzx_project"; private static final Map TOPIC_SEARCH_MODULE_NAME_MAP; static { Map moduleNameMap = new LinkedHashMap<>(); moduleNameMap.put("gxmu_sjqn", "十佳青年岗位能手"); moduleNameMap.put("gxmu_sjtbzbsj", "十佳团支部书记"); moduleNameMap.put("gxmu_wshqtw", "五四红旗团委"); moduleNameMap.put("gxmu_wshqtzb", "五四红旗团支部"); moduleNameMap.put("gxmu_yxgqtdgb", "优秀共青团干部"); moduleNameMap.put("gxmu_yxgqty", "优秀共青团员"); moduleNameMap.put("gxmu_tzbcy_form", "挑战杯"); moduleNameMap.put("gxmu_qmgc_form", "青马工程"); moduleNameMap.put(DEFAULT_TOPIC_SEARCH_MODULE, "未来学术之星"); TOPIC_SEARCH_MODULE_NAME_MAP = Collections.unmodifiableMap(moduleNameMap); } @Resource private WxxzxFormService wxxzxFormService; @Resource private SjqnFormService sjqnFormService; @Resource private SjtbzbsjFormService sjtbzbsjFormService; @Resource private WshqtwFormService wshqtwFormService; @Resource private WshqtzbFormService wshqtzbFormService; @Resource private YxgqtdgbFormService yxgqtdgbFormService; @Resource private YxgqtyFormService yxgqtyFormService; @Resource private TzbcyFormService tzbcyFormService; @Resource private QmgcFormService qmgcFormService; @Resource private ReviewFlowStarterService reviewFlowStarterService; @Resource private DeclareGuardService declareGuardService; @Resource private FileRecordService fileRecordService; @Value("${config.upload-path}") private String uploadPath; @Value("${config.server-url}") private String requestURL; @ApiOperation("分页查询未来学术之星课题申报表") @GetMapping("/page") public ApiResult> page(WxxzxFormParam param) { applyBackendUserScope(param, param::setUserId, param::setUserIds, true); PageParam page = new PageParam<>(param); page.setDefaultOrder("id desc"); return success(new PageResult<>(wxxzxFormService.page(page, GxmuQueryHelper.applyKeywords(page.getWrapper("keywords"), param.getKeywords(), "topic_name", "leader", "college_grade_class")).getRecords(), page.getTotal())); } @ApiOperation("分页查询当前用户未来学术之星课题申报表") @GetMapping("/userPage") public ApiResult> userPage(WxxzxFormParam param) { applyBackendUserScope(param, param::setUserId, param::setUserIds, true); if (param.getUserId() == null && (param.getUserIds() == null || param.getUserIds().isEmpty())) { param.setUserId(getLoginUserId()); } PageParam page = new PageParam<>(param); page.setDefaultOrder("id desc"); return success(new PageResult<>(wxxzxFormService.page(page, GxmuQueryHelper.applyKeywords(page.getWrapper("keywords"), param.getKeywords(), "topic_name", "leader", "college_grade_class")).getRecords(), page.getTotal())); } @ApiOperation("查询全部未来学术之星课题申报表") @GetMapping() public ApiResult> list(WxxzxFormParam param) { PageParam page = new PageParam<>(param); page.setDefaultOrder("id desc"); return success(wxxzxFormService.list(page.getOrderWrapper( GxmuQueryHelper.applyKeywords(page.getWrapper("keywords"), param.getKeywords(), "topic_name", "leader", "college_grade_class")))); } @OperationLog @ApiOperation("导出未来学术之星课题申报材料") @PostMapping("/export") public ApiResult export(@RequestBody WxxzxFormParam param, HttpServletRequest request) throws IOException { applyBackendUserScope(param, param::setUserId, param::setUserIds, true); if (param.getUserId() == null && (param.getUserIds() == null || param.getUserIds().isEmpty())) { param.setUserId(getLoginUserId()); } PageParam page = new PageParam<>(param); page.setDefaultOrder("id desc"); List baseRecords = wxxzxFormService.list(page.getOrderWrapper( GxmuQueryHelper.applyKeywords(page.getWrapper("keywords"), param.getKeywords(), "topic_name", "leader", "college_grade_class"))); if (baseRecords == null || baseRecords.isEmpty()) { return fail("暂无可导出的数据"); } List records = new ArrayList<>(); for (WxxzxForm item : baseRecords) { if (item == null || item.getId() == null) { continue; } WxxzxForm full = wxxzxFormService.getById(item.getId()); if (full != null) { records.add(full); } } if (records.isEmpty()) { return fail("暂无可导出的数据"); } WxxzxDocxExportUtil.SummaryMeta summaryMeta = new WxxzxDocxExportUtil.SummaryMeta(); summaryMeta.setYearText(resolveYearText(param, records)); summaryMeta.setReviewCollege(resolveReviewCollege(records)); summaryMeta.setReviewReporter(resolveReviewReporter(records)); summaryMeta.setReviewDate(resolveReviewDate(records)); String relativePath = "file/docx/未来学术之星申报材料_" + System.currentTimeMillis() + ".zip"; File targetFile = new File(uploadPath + relativePath); File parentFile = targetFile.getParentFile(); if (parentFile != null && !parentFile.exists()) { parentFile.mkdirs(); } try (FileOutputStream outputStream = new FileOutputStream(targetFile); ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) { WxxzxDocxExportUtil.writeExportZip(zipOutputStream, records, summaryMeta); zipOutputStream.finish(); } FileRecord result = new FileRecord(); result.setCreateUserId(getLoginUserId()); result.setName(targetFile.getName()); result.setPath(targetFile.getAbsolutePath()); result.setContentType("application/zip"); result.setUrl(requestURL + "/" + relativePath); fileRecordService.save(result); return success(result); } @ApiOperation("根据id查询未来学术之星课题申报表") @GetMapping("/{id:\\d+}") public ApiResult get(@PathVariable("id") Integer id) { return success(wxxzxFormService.getById(id)); } @ApiOperation("项目主题语义检索") @GetMapping("/semantic-search") public ApiResult> semanticSearch( @RequestParam(value = "keyword", required = false) String keyword, @RequestParam(value = "module", required = false) String module) { String normalizedKeyword = normalizeDisplayText(keyword); if (StrUtil.isBlank(normalizedKeyword)) { return success(new ArrayList<>()); } String normalizedModule = resolveTopicSearchModule(module); if (normalizedModule == null) { return success(new ArrayList<>()); } return success(searchTopicByModule(normalizedModule, normalizedKeyword)); } @OperationLog @ApiOperation("添加未来学术之星课题申报表") @PostMapping() @Transactional(rollbackFor = Exception.class) public ApiResult save(@RequestBody WxxzxForm wxxzxForm) { User loginUser = getLoginUser(); if (loginUser != null) { wxxzxForm.setUserId(loginUser.getUserId()); } DeclareCheckResult declareCheck = declareGuardService.checkBeforeCreate("gxmu_wxxzx_project", wxxzxForm.getDeclareId(), wxxzxForm.getYear(), wxxzxForm.getUserId()); if (declareCheck.isRejected()) { return fail(declareCheck.getError()); } if (declareCheck.getDeclareId() != null) { wxxzxForm.setDeclareId(declareCheck.getDeclareId()); } wxxzxForm.setDeclareStatus(1); if (wxxzxFormService.save(wxxzxForm)) { reviewFlowStarterService.startReview("gxmu_wxxzx_project", wxxzxForm.getId()); return success("添加成功"); } return fail("添加失败"); } @OperationLog @ApiOperation("修改未来学术之星课题申报表") @PutMapping() public ApiResult update(@RequestBody WxxzxForm wxxzxForm) { if (wxxzxFormService.updateById(wxxzxForm)) { reviewFlowStarterService.restartReviewWhenLastRejected("gxmu_wxxzx_project", wxxzxForm.getId()); return success("修改成功"); } return fail("修改失败"); } @OperationLog @ApiOperation("更新未来学术之星过程材料") @PutMapping("/materials/{id}") public ApiResult updateMaterials(@PathVariable("id") Integer id, @RequestBody WxxzxMaterialUpdateParam param) { return wxxzxFormService.updateMaterials(id, param) ? success("保存成功") : fail("保存失败"); } @OperationLog @ApiOperation("校验未来学术之星项目申报书docx格式") @PostMapping("/validate-project-book") public ApiResult validateProjectBook(@RequestParam("file") MultipartFile file) { if (file == null || file.isEmpty()) { return fail("请上传docx文件", null); } String originalFilename = file.getOriginalFilename(); if (StrUtil.isBlank(originalFilename) || !StrUtil.endWithIgnoreCase(originalFilename, ".docx")) { return fail("只能上传docx文件", null); } WxxzxDocxValidationResult result = new WxxzxDocxValidationResult(); result.setFileName(originalFilename); try (InputStream inputStream = file.getInputStream(); XWPFDocument document = new XWPFDocument(inputStream)) { buildValidationResult(document, result); return success(result); } catch (Exception e) { e.printStackTrace(); return fail("文件解析失败,请确认上传的是有效的docx文件", null); } } @OperationLog @ApiOperation("删除未来学术之星课题申报表") @DeleteMapping("/{id:\\d+}") public ApiResult remove(@PathVariable("id") Integer id) { return wxxzxFormService.removeById(id) ? success("删除成功") : fail("删除失败"); } @OperationLog @ApiOperation("批量修改未来学术之星课题申报表") @PutMapping("/batch") public ApiResult updateBatch(@RequestBody BatchParam batchParam) { return batchParam.update(wxxzxFormService, "id") ? success("修改成功") : fail("修改失败"); } @OperationLog @ApiOperation("批量删除未来学术之星课题申报表") @DeleteMapping("/batch") public ApiResult removeBatch(@RequestBody List ids) { return wxxzxFormService.removeByIds(ids) ? success("删除成功") : fail("删除失败"); } private String resolveYearText(WxxzxFormParam param, List records) { if (param.getYear() != null) { return String.valueOf(param.getYear()); } if (records != null && !records.isEmpty() && records.get(0).getYear() != null) { return String.valueOf(records.get(0).getYear()); } return String.valueOf(java.time.LocalDate.now().getYear()); } private String resolveReviewCollege(List records) { if (records == null) { return ""; } for (WxxzxForm item : records) { if (StrUtil.isNotBlank(item.getReviewCollege())) { return item.getReviewCollege().trim(); } } return ""; } private String resolveReviewReporter(List records) { if (records == null) { return ""; } for (WxxzxForm item : records) { if (StrUtil.isNotBlank(item.getReviewReporter())) { return item.getReviewReporter().trim(); } } User loginUser = getLoginUser(); if (loginUser == null) { return ""; } if (StrUtil.isNotBlank(loginUser.getRealName())) { return loginUser.getRealName().trim(); } if (StrUtil.isNotBlank(loginUser.getNickname())) { return loginUser.getNickname().trim(); } return loginUser.getUsername(); } private String resolveReviewDate(List records) { if (records == null) { return ""; } for (WxxzxForm item : records) { if (StrUtil.isNotBlank(item.getReviewDate())) { return item.getReviewDate().trim(); } } return ""; } private WxxzxTopicSemanticSearchResult toTopicSearchResult(WxxzxForm form, String keyword) { WxxzxTopicSemanticSearchResult result = new WxxzxTopicSemanticSearchResult(); result.setId(form.getId()); result.setModule(DEFAULT_TOPIC_SEARCH_MODULE); result.setModuleName(TOPIC_SEARCH_MODULE_NAME_MAP.get(DEFAULT_TOPIC_SEARCH_MODULE)); result.setTopicName(form.getTopicName()); result.setTopicType(form.getTopicType()); result.setRationale(form.getRationale()); result.setSnippet(buildSnippet(form.getRationale(), keyword)); result.setScore(calculateSemanticScore(form, keyword)); return result; } private String resolveTopicSearchModule(String module) { String normalizedModule = StrUtil.isBlank(module) ? DEFAULT_TOPIC_SEARCH_MODULE : module.trim(); return TOPIC_SEARCH_MODULE_NAME_MAP.containsKey(normalizedModule) ? normalizedModule : null; } private List searchTopicByModule(String module, String keyword) { if ("gxmu_sjqn".equals(module)) { return finishTopicSearchResults(sjqnFormService.list(new LambdaQueryWrapper() .select(SjqnForm::getId, SjqnForm::getName, SjqnForm::getApplyType, SjqnForm::getMainStory, SjqnForm::getCreateTime, SjqnForm::getUpdateTime) .eq(SjqnForm::getTenantId, getTenantId()) .like(SjqnForm::getName, keyword) .orderByDesc(SjqnForm::getUpdateTime) .orderByDesc(SjqnForm::getCreateTime)) .stream() .map(item -> toTopicSearchResult(module, item.getId(), item.getName(), item.getApplyType(), item.getMainStory(), keyword)) .collect(Collectors.toList())); } if ("gxmu_sjtbzbsj".equals(module)) { return finishTopicSearchResults(sjtbzbsjFormService.list(new LambdaQueryWrapper() .select(SjtbzbsjForm::getId, SjtbzbsjForm::getName, SjtbzbsjForm::getBranch, SjtbzbsjForm::getMainStory, SjtbzbsjForm::getCreateTime, SjtbzbsjForm::getUpdateTime) .eq(SjtbzbsjForm::getTenantId, getTenantId()) .like(SjtbzbsjForm::getName, keyword) .orderByDesc(SjtbzbsjForm::getUpdateTime) .orderByDesc(SjtbzbsjForm::getCreateTime)) .stream() .map(item -> toTopicSearchResult(module, item.getId(), item.getName(), item.getBranch(), item.getMainStory(), keyword)) .collect(Collectors.toList())); } if ("gxmu_wshqtw".equals(module)) { return finishTopicSearchResults(wshqtwFormService.list(new LambdaQueryWrapper() .select(WshqtwForm::getId, WshqtwForm::getOrgName, WshqtwForm::getLeader, WshqtwForm::getWorkSummaryThreeYears, WshqtwForm::getCreateTime, WshqtwForm::getUpdateTime) .eq(WshqtwForm::getTenantId, getTenantId()) .like(WshqtwForm::getOrgName, keyword) .orderByDesc(WshqtwForm::getUpdateTime) .orderByDesc(WshqtwForm::getCreateTime)) .stream() .map(item -> toTopicSearchResult(module, item.getId(), item.getOrgName(), item.getLeader(), item.getWorkSummaryThreeYears(), keyword)) .collect(Collectors.toList())); } if ("gxmu_wshqtzb".equals(module)) { return finishTopicSearchResults(wshqtzbFormService.list(new LambdaQueryWrapper() .select(WshqtzbForm::getId, WshqtzbForm::getBranchName, WshqtzbForm::getSecondOrg, WshqtzbForm::getWorkSummaryThreeYears, WshqtzbForm::getCreateTime, WshqtzbForm::getUpdateTime) .eq(WshqtzbForm::getTenantId, getTenantId()) .like(WshqtzbForm::getBranchName, keyword) .orderByDesc(WshqtzbForm::getUpdateTime) .orderByDesc(WshqtzbForm::getCreateTime)) .stream() .map(item -> toTopicSearchResult(module, item.getId(), item.getBranchName(), item.getSecondOrg(), item.getWorkSummaryThreeYears(), keyword)) .collect(Collectors.toList())); } if ("gxmu_yxgqtdgb".equals(module)) { return finishTopicSearchResults(yxgqtdgbFormService.list(new LambdaQueryWrapper() .select(YxgqtdgbForm::getId, YxgqtdgbForm::getName, YxgqtdgbForm::getOrganization, YxgqtdgbForm::getMainStory, YxgqtdgbForm::getCreateTime, YxgqtdgbForm::getUpdateTime) .eq(YxgqtdgbForm::getTenantId, getTenantId()) .like(YxgqtdgbForm::getName, keyword) .orderByDesc(YxgqtdgbForm::getUpdateTime) .orderByDesc(YxgqtdgbForm::getCreateTime)) .stream() .map(item -> toTopicSearchResult(module, item.getId(), item.getName(), item.getOrganization(), item.getMainStory(), keyword)) .collect(Collectors.toList())); } if ("gxmu_yxgqty".equals(module)) { return finishTopicSearchResults(yxgqtyFormService.list(new LambdaQueryWrapper() .select(YxgqtyForm::getId, YxgqtyForm::getName, YxgqtyForm::getCollegeMajorClass, YxgqtyForm::getMainStory, YxgqtyForm::getCreateTime, YxgqtyForm::getUpdateTime) .eq(YxgqtyForm::getTenantId, getTenantId()) .like(YxgqtyForm::getName, keyword) .orderByDesc(YxgqtyForm::getUpdateTime) .orderByDesc(YxgqtyForm::getCreateTime)) .stream() .map(item -> toTopicSearchResult(module, item.getId(), item.getName(), item.getCollegeMajorClass(), item.getMainStory(), keyword)) .collect(Collectors.toList())); } if ("gxmu_tzbcy_form".equals(module)) { return finishTopicSearchResults(tzbcyFormService.list(new LambdaQueryWrapper() .select(TzbcyForm::getId, TzbcyForm::getProjectName, TzbcyForm::getProjectType, TzbcyForm::getProjectBrief, TzbcyForm::getCreateTime, TzbcyForm::getUpdateTime) .eq(TzbcyForm::getTenantId, getTenantId()) .like(TzbcyForm::getProjectName, keyword) .orderByDesc(TzbcyForm::getUpdateTime) .orderByDesc(TzbcyForm::getCreateTime)) .stream() .map(item -> toTopicSearchResult(module, item.getId(), item.getProjectName(), item.getProjectType(), item.getProjectBrief(), keyword)) .collect(Collectors.toList())); } if ("gxmu_qmgc_form".equals(module)) { return finishTopicSearchResults(qmgcFormService.list(new LambdaQueryWrapper() .select(QmgcForm::getId, QmgcForm::getName, QmgcForm::getSchoolInfo, QmgcForm::getResume, QmgcForm::getCreateTime, QmgcForm::getUpdateTime) .eq(QmgcForm::getTenantId, getTenantId()) .like(QmgcForm::getName, keyword) .orderByDesc(QmgcForm::getUpdateTime) .orderByDesc(QmgcForm::getCreateTime)) .stream() .map(item -> toTopicSearchResult(module, item.getId(), item.getName(), item.getSchoolInfo(), item.getResume(), keyword)) .collect(Collectors.toList())); } return finishTopicSearchResults(wxxzxFormService.list(new LambdaQueryWrapper() .select(WxxzxForm::getId, WxxzxForm::getTopicName, WxxzxForm::getTopicType, WxxzxForm::getRationale, WxxzxForm::getCreateTime, WxxzxForm::getUpdateTime) .eq(WxxzxForm::getTenantId, getTenantId()) .like(WxxzxForm::getTopicName, keyword) .orderByDesc(WxxzxForm::getUpdateTime) .orderByDesc(WxxzxForm::getCreateTime)) .stream() .map(item -> toTopicSearchResult(item, keyword)) .collect(Collectors.toList())); } private WxxzxTopicSemanticSearchResult toTopicSearchResult( String module, Integer id, String title, String type, String content, String keyword) { WxxzxTopicSemanticSearchResult result = new WxxzxTopicSemanticSearchResult(); result.setId(id); result.setModule(module); result.setModuleName(TOPIC_SEARCH_MODULE_NAME_MAP.get(module)); result.setTopicName(title); result.setTopicType(type); result.setRationale(content); result.setSnippet(buildSnippet(content, keyword)); result.setScore(calculateTitleScore(title, keyword)); return result; } private List finishTopicSearchResults(List results) { return results.stream() .filter(item -> item.getScore() != null && item.getScore() > 0) .sorted(Comparator.comparing(WxxzxTopicSemanticSearchResult::getScore, Comparator.nullsLast(Comparator.reverseOrder()))) .limit(MAX_TOPIC_SEARCH_RESULTS) .collect(Collectors.toList()); } private int calculateSemanticScore(WxxzxForm form, String keyword) { List terms = extractSearchTerms(keyword); int score = 0; score += calculateFieldScore(form.getTopicName(), keyword, terms, 140, 28, true); score += calculateFieldScore(form.getTopicType(), keyword, terms, 90, 20, false); score += calculateFieldScore(form.getRationale(), keyword, terms, 60, 12, false); return score; } private int calculateTitleScore(String title, String keyword) { return calculateFieldScore(title, keyword, extractSearchTerms(keyword), 140, 28, true); } private int calculateFieldScore(String fieldValue, String keyword, List terms, int exactScore, int termScore, boolean titleField) { String normalizedField = normalizeCheckText(fieldValue); if (StrUtil.isBlank(normalizedField)) { return 0; } String normalizedKeyword = normalizeCheckText(keyword); int score = 0; if (normalizedField.contains(normalizedKeyword)) { score += exactScore; if (normalizedField.startsWith(normalizedKeyword)) { score += exactScore / 2; } score += Math.min(countOccurrences(normalizedField, normalizedKeyword) * 10, 30); } int matchedTerms = 0; for (String term : terms) { String normalizedTerm = normalizeCheckText(term); if (StrUtil.isBlank(normalizedTerm) || normalizedTerm.length() < 2) { continue; } if (normalizedField.contains(normalizedTerm)) { matchedTerms++; score += termScore; } } if (titleField && matchedTerms == terms.size() && !terms.isEmpty()) { score += 30; } return score; } private List extractSearchTerms(String keyword) { String[] splits = normalizeDisplayText(keyword).split("[\\s,,;;。、】【()()]+"); List terms = new ArrayList<>(); for (String split : splits) { if (StrUtil.isNotBlank(split)) { terms.add(split.trim()); } } if (terms.isEmpty() && StrUtil.isNotBlank(keyword)) { terms.add(keyword.trim()); } return terms; } private int countOccurrences(String text, String pattern) { if (StrUtil.isBlank(text) || StrUtil.isBlank(pattern)) { return 0; } int count = 0; int index = 0; while ((index = text.indexOf(pattern, index)) >= 0) { count++; index += pattern.length(); } return count; } private String buildSnippet(String rationale, String keyword) { String content = normalizeDisplayText(rationale); if (StrUtil.isBlank(content)) { return "暂无立论依据"; } String normalizedKeyword = normalizeDisplayText(keyword); int index = content.indexOf(normalizedKeyword); if (index < 0) { for (String term : extractSearchTerms(keyword)) { index = content.indexOf(term); if (index >= 0) { break; } } } if (index < 0) { return abbreviate(content, 120); } int start = Math.max(index - 28, 0); int end = Math.min(index + normalizedKeyword.length() + 68, content.length()); String snippet = content.substring(start, end); if (start > 0) { snippet = "..." + snippet; } if (end < content.length()) { snippet = snippet + "..."; } return snippet; } private void buildValidationResult(XWPFDocument document, WxxzxDocxValidationResult result) { List paragraphs = document.getParagraphs(); List detailItems = new ArrayList<>(); boolean titleFound = false; boolean titlePassed = true; boolean bodyPassed = true; int bodyCount = 0; int issueCount = 0; for (int i = 0; i < paragraphs.size(); i++) { XWPFParagraph paragraph = paragraphs.get(i); String displayText = normalizeDisplayText(paragraph.getText()); if (StrUtil.isBlank(displayText)) { continue; } boolean isTitleParagraph = normalizeCheckText(displayText).contains(PROJECT_BOOK_KEYWORD); ValidationIssue issue = isTitleParagraph ? validateParagraph(paragraph, i + 1, TITLE_FONT_CN, TITLE_FONT_EN, TITLE_FONT_SIZE) : validateParagraph(paragraph, i + 1, BODY_FONT_CN, BODY_FONT_EN, BODY_FONT_SIZE); if (isTitleParagraph) { titleFound = true; if (issue != null) { titlePassed = false; issueCount++; addDetailItem(detailItems, "标题格式", false, issue.getDetail(), issue.getSuggestion(), issue.getParagraphIndex(), abbreviate(issue.getParagraphText())); } continue; } bodyCount++; if (issue != null) { bodyPassed = false; issueCount++; addDetailItem(detailItems, "正文格式", false, issue.getDetail(), issue.getSuggestion(), issue.getParagraphIndex(), abbreviate(issue.getParagraphText())); } } if (!titleFound) { titlePassed = false; issueCount++; addDetailItem(detailItems, "标题格式", false, "未检测到含“项目申报书”字样的标题段落", "请新增或修改标题段落,并设置为黑体一号,且标题中包含“项目申报书”字样", null, null); } if (bodyCount == 0) { bodyPassed = false; issueCount++; addDetailItem(detailItems, "正文格式", false, "未检测到可校验的正文段落", "请补充正文内容,并统一设置为宋体五号", null, null); } List finalItems = new ArrayList<>(); if (titlePassed) { addDetailItem(finalItems, "标题格式", true, "已检测到含“项目申报书”字样的标题,格式为黑体一号", "无需修改", null, null); } if (bodyPassed && bodyCount > 0) { addDetailItem(finalItems, "正文格式", true, String.format(Locale.ROOT, "已检测 %d 个正文段落,格式均为宋体五号", bodyCount), "无需修改", null, null); } finalItems.addAll(detailItems); if (issueCount > MAX_DETAIL_ITEMS) { addDetailItem(finalItems, "结果说明", false, String.format(Locale.ROOT, "问题较多,当前仅展示前 %d 处明细", MAX_DETAIL_ITEMS), "请优先按已展示的修改意见逐项调整后重新上传校验", null, null); } result.setPassed(titlePassed && bodyPassed); result.setTotalIssues(issueCount); result.setSummary(result.getPassed() ? "校验通过:标题和正文格式均符合要求" : String.format(Locale.ROOT, "校验不通过:共发现 %d 处问题", issueCount)); result.setItems(finalItems); } private ValidationIssue validateParagraph(XWPFParagraph paragraph, int paragraphIndex, String expectedFontCn, String expectedFontEn, double expectedSize) { List runs = paragraph.getRuns(); if (runs == null || runs.isEmpty()) { return new ValidationIssue(paragraphIndex, paragraph.getText(), String.format(Locale.ROOT, "未检测到文本样式信息,应为%s %s", expectedFontCn, formatExpectedSize(expectedSize)), buildSuggestion(expectedFontCn, expectedSize)); } for (XWPFRun run : runs) { String runText = normalizeDisplayText(run.text()); if (StrUtil.isBlank(runText)) { continue; } String actualFont = resolveFontFamily(run); Double actualSize = resolveFontSize(run); boolean fontMatched = matchesFont(actualFont, expectedFontCn, expectedFontEn); boolean sizeMatched = matchesSize(actualSize, expectedSize); if (fontMatched && sizeMatched) { continue; } String detail = String.format(Locale.ROOT, "应为%s %s,当前检测到字体“%s”、字号“%s”", expectedFontCn, formatExpectedSize(expectedSize), StrUtil.blankToDefault(actualFont, "未设置"), formatActualSize(actualSize)); return new ValidationIssue(paragraphIndex, paragraph.getText(), detail, buildSuggestion(expectedFontCn, expectedSize)); } return null; } private void addDetailItem(List items, String rule, boolean passed, String detail, String suggestion, Integer paragraphIndex, String paragraphText) { if (!passed && "结果说明".equals(rule)) { WxxzxDocxValidationResult.Item item = new WxxzxDocxValidationResult.Item(); item.setRule(rule); item.setPassed(false); item.setDetail(detail); item.setSuggestion(suggestion); items.add(item); return; } if (!passed) { long failedCount = items.stream().filter(item -> Boolean.FALSE.equals(item.getPassed())).count(); if (failedCount >= MAX_DETAIL_ITEMS) { return; } } WxxzxDocxValidationResult.Item item = new WxxzxDocxValidationResult.Item(); item.setRule(rule); item.setPassed(passed); item.setDetail(detail); item.setSuggestion(suggestion); item.setParagraphIndex(paragraphIndex); item.setParagraphText(paragraphText); items.add(item); } private String resolveFontFamily(XWPFRun run) { String[] candidates = new String[]{ run.getFontFamily(XWPFRun.FontCharRange.eastAsia), run.getFontFamily(XWPFRun.FontCharRange.ascii), run.getFontFamily(XWPFRun.FontCharRange.hAnsi), run.getFontFamily(), run.getFontName() }; for (String candidate : candidates) { if (StrUtil.isNotBlank(candidate)) { return candidate.trim(); } } return null; } private Double resolveFontSize(XWPFRun run) { Double fontSize = run.getFontSizeAsDouble(); if (fontSize != null && fontSize > 0) { return fontSize; } int fontSizeInt = run.getFontSize(); return fontSizeInt > 0 ? (double) fontSizeInt : null; } private boolean matchesFont(String actualFont, String expectedCn, String expectedEn) { if (StrUtil.isBlank(actualFont)) { return false; } String normalized = actualFont.replace(" ", "").trim().toLowerCase(Locale.ROOT); return normalized.equals(expectedCn.toLowerCase(Locale.ROOT)) || normalized.equals(expectedEn.toLowerCase(Locale.ROOT)); } private boolean matchesSize(Double actualSize, double expectedSize) { return actualSize != null && Math.abs(actualSize - expectedSize) < 0.11D; } private String normalizeCheckText(String text) { return text == null ? "" : text.replace("\u3000", "").replaceAll("\\s+", ""); } private String normalizeDisplayText(String text) { return text == null ? "" : text.replace('\u00A0', ' ').replaceAll("\\s+", " ").trim(); } private String abbreviate(String text) { String displayText = normalizeDisplayText(text); return abbreviate(displayText, 80); } private String abbreviate(String text, int maxLength) { String displayText = normalizeDisplayText(text); if (displayText.length() <= maxLength) { return displayText; } return displayText.substring(0, maxLength) + "..."; } private String formatExpectedSize(double size) { if (Math.abs(size - TITLE_FONT_SIZE) < 0.01D) { return "一号"; } if (Math.abs(size - BODY_FONT_SIZE) < 0.01D) { return "五号"; } return formatActualSize(size); } private String formatActualSize(Double size) { if (size == null) { return "未设置"; } if (Math.abs(size - Math.rint(size)) < 0.01D) { return String.format(Locale.ROOT, "%.0fpt", size); } return String.format(Locale.ROOT, "%.1fpt", size); } private String buildSuggestion(String expectedFontCn, double expectedSize) { return String.format(Locale.ROOT, "请将该段调整为%s%s", expectedFontCn, formatExpectedSize(expectedSize)); } private static class ValidationIssue { private final Integer paragraphIndex; private final String paragraphText; private final String detail; private final String suggestion; private ValidationIssue(Integer paragraphIndex, String paragraphText, String detail, String suggestion) { this.paragraphIndex = paragraphIndex; this.paragraphText = paragraphText; this.detail = detail; this.suggestion = suggestion; } public Integer getParagraphIndex() { return paragraphIndex; } public String getParagraphText() { return paragraphText; } public String getDetail() { return detail; } public String getSuggestion() { return suggestion; } } }