bug修改
build / build-push (push) Successful in 2m35s

This commit is contained in:
wanghu
2026-10-08 18:29:46 +08:00
parent 3b3eeb9f87
commit 305f1c6940
11 changed files with 362 additions and 284 deletions
@@ -63,37 +63,8 @@ public class BaseApiController {
* 返回 {id, name, role: "teacher", profilePicture, school: {id, name}, currentRole}
*/
@GetMapping("GetUserInfo")
public Map<String, Object> getUserInfo(BoaUser authUserWithDept) {
// 开发联调兜底:无 CAS 会话时从 UserContext(currentrole)取身份;生产 CAS 下 BoaUser 非空
Integer uid = authUserWithDept != null && authUserWithDept.getId() != null
? authUserWithDept.getId() : UserContext.getUserId();
Integer schId = schIdOf(authUserWithDept);
String teacherName = uid != null ? teacherNameOf(uid) : "";
Map<String, Object> result = new LinkedHashMap<>();
result.put("id", uid);
result.put("name", authUserWithDept != null && authUserWithDept.getName() != null
? authUserWithDept.getName() : teacherName);
result.put("role", "teacher");
result.put("profilePicture", "");
Map<String, Object> school = new LinkedHashMap<>();
if (authUserWithDept != null && authUserWithDept.getSchool() != null) {
school.put("id", authUserWithDept.getSchool().getId());
school.put("name", authUserWithDept.getSchool().getName());
} else if (schId != null) {
school.put("id", schId);
school.put("name", "测试学校");
}
result.put("school", school);
UserSysRole role = new UserSysRole();
role.setSchId(schId);
role.setUserId(uid);
if (authUserWithDept != null && authUserWithDept.getRoleIds() != null && !authUserWithDept.getRoleIds().isEmpty()) {
role.setRoleId(authUserWithDept.getRoleIds().get(0));
}
Integer childId = UserContext.getChildId();
role.setChildId(childId == null ? 0 : childId);
result.put("currentRole", role);
return result;
public BoaUser getUserInfo(BoaUser authUserWithDept) {
return authUserWithDept;
}
// ============================ GetUserMod ============================
@@ -5,7 +5,6 @@ import com.yunhai.profession.service.IAreaService;
import com.yunhai.profession.service.IProfessionService;
import com.yunhai.profession.service.IStatsService;
import com.yunhai.profession.utils.ProfessionContext;
import com.yunhai.profession.utils.ProfessionDicts;
import com.yunhai.platform.utils.model.BoaUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
@@ -74,10 +73,10 @@ public class ProfessionController {
@RequestParam(value = "PageIndex", defaultValue = "1") int pageIndex,
@RequestParam(value = "PageSize", defaultValue = "10") int pageSize) {
return professionService.pageRecords(currentSchId(authUserWithDept), mouid, userid, null, subid, grade, searchname,
starttime, endtime, pageIndex, pageSize, false);
starttime, endtime, pageIndex, pageSize, false, null);
}
/** 记录详情(阅读数 +1,含字段定义/附件/点赞态) */
/** 记录详情(纯读;阅读数在 VisitCreate 记足迹时 +1,含字段定义/附件/点赞态) */
@GetMapping("ProfessionDetail")
public Map<String, Object> professionDetail(BoaUser authUserWithDept, @RequestParam("SerID") Integer serId) {
return professionService.recordDetail(currentSchId(authUserWithDept), serId, teacherId(authUserWithDept));
@@ -97,7 +96,7 @@ public class ProfessionController {
@RequestParam(value = "PageIndex", defaultValue = "1") int pageIndex,
@RequestParam(value = "PageSize", defaultValue = "10") int pageSize) {
return professionService.pageRecords(currentSchId(authUserWithDept), mouid, userid, grouid, subject, grade,
searchname, starttime, endtime, pageIndex, pageSize, true);
searchname, starttime, endtime, pageIndex, pageSize, true, teacherId(authUserWithDept));
}
/** 某教师某栏目文章分页(个人空间) */
@@ -110,21 +109,21 @@ public class ProfessionController {
@RequestParam(value = "PageIndex", defaultValue = "1") int pageIndex,
@RequestParam(value = "PageSize", defaultValue = "10") int pageSize) {
return professionService.pageRecords(currentSchId(authUserWithDept), mouid, userid, null, null, grade, searchname,
null, null, pageIndex, pageSize, false);
null, null, pageIndex, pageSize, false, teacherId(authUserWithDept));
}
/** 首页各栏目最新记录块 */
@GetMapping("HomeModuleRecords")
public List<Map<String, Object>> homeModuleRecords(BoaUser authUserWithDept,
@RequestParam(value = "size", defaultValue = "6") int size) {
return professionService.homeModuleRecords(currentSchId(authUserWithDept), size);
return professionService.homeModuleRecords(currentSchId(authUserWithDept), size, teacherId(authUserWithDept));
}
/** 个人空间按年时间轴 */
@GetMapping("MyTimeline")
public List<Map<String, Object>> myTimeline(BoaUser authUserWithDept,
@RequestParam("userid") Integer userid) {
return professionService.myTimeline(currentSchId(authUserWithDept), userid);
return professionService.myTimeline(currentSchId(authUserWithDept), userid, teacherId(authUserWithDept));
}
// ==================== 评论 ====================
@@ -193,6 +192,17 @@ public class ProfessionController {
return professionService.myPoints(currentSchId(authUserWithDept), teacherId, teacherName);
}
/** 积分来源明细(发布/评论逐条带分值,分页;传 userid = 查看该教师空间,明细按登录人可见性过滤) */
@GetMapping("PointsSourceList")
public Map<String, Object> pointsSourceList(BoaUser authUserWithDept,
@RequestParam(value = "userid", required = false) Integer userid,
@RequestParam(value = "PageIndex", defaultValue = "1") int pageIndex,
@RequestParam(value = "PageSize", defaultValue = "10") int pageSize) {
Integer teacherId = userid != null ? userid : teacherId(authUserWithDept);
return professionService.pointsSourceList(currentSchId(authUserWithDept), teacherId,
teacherId(authUserWithDept), pageIndex, pageSize);
}
/** 本校发布排行(前 N) */
@GetMapping("ReleaseRanking")
public List<Map<String, Object>> releaseRanking(BoaUser authUserWithDept,
@@ -242,16 +252,16 @@ public class ProfessionController {
return rows.subList(0, Math.min(Math.max(top, 1), rows.size()));
}
/** 年级段字典(对齐老 GetGradeList) */
/** 年级段字典(按当前学校过滤:班级管理该校学制的年级;不可用时回退全量) */
@GetMapping("GetGradeList")
public List<Map<String, Object>> gradeList() {
return ProfessionDicts.gradeList();
public List<Map<String, Object>> gradeList(BoaUser authUserWithDept) {
return professionService.schoolGradeList(currentSchId(authUserWithDept));
}
/** 学科字典([{subId,subName}],subId 对齐班级管理 SUB01 编码) */
/** 学科字典(按当前学校过滤:班级管理该校启用学科,[{subId,subName}];不可用时回退全量) */
@GetMapping("GetSubList")
public List<Map<String, Object>> subjectList() {
return ProfessionDicts.subjectList();
public List<Map<String, Object>> subjectList(BoaUser authUserWithDept) {
return professionService.schoolSubjectList(currentSchId(authUserWithDept));
}
/** 行政区划子级(省市级联懒加载,parentCode 不传默认取省;对齐自定义字段 province_city) */
@@ -15,6 +15,9 @@ public interface ProfessionDiscussRepository extends JpaRepository<ProfessionDis
List<ProfessionDiscuss> findByRecordIdOrderByCreateTimeAsc(Integer recordId);
/** 某教师的全部评论(积分来源明细用,新在前) */
List<ProfessionDiscuss> findBySchIdAndTeacherIdOrderByCreateTimeDescIdDesc(Integer schId, Integer teacherId);
long deleteByRecordId(Integer recordId);
// ==================== 校级聚合 ====================
@@ -70,8 +73,9 @@ public interface ProfessionDiscussRepository extends JpaRepository<ProfessionDis
// ===== 教师维度聚合(个人空间概览) =====
/** 教师在时间段内的评论数 */
@Query("select count(d) from ProfessionDiscuss d where d.schId = :schId and d.teacherId = :uid " +
/** 教师新获评论数:他人在该教师的记录下的评论(排除本人自评),时间边界传 null 表示不限 */
@Query("select count(d) from ProfessionDiscuss d where d.schId = :schId and d.teacherId <> :uid " +
"and d.recordId in (select r.id from ProfessionRecord r where r.schId = :schId and r.teacherId = :uid) " +
"and (:start is null or d.createTime >= :start) and (:end is null or d.createTime < :end)")
long countByTeacherRange(@Param("schId") Integer schId, @Param("uid") Integer uid,
@Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
@@ -56,6 +56,16 @@ public interface ProfessionRecordRepository extends JpaRepository<ProfessionReco
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/** 按栏目聚合(仅查看者可见:公开记录 + 本人发布的记录):栏目id、记录数 */
@Query("select r.moduleId, count(r) from ProfessionRecord r "
+ "where r.schId = :schId and (:start is null or r.createTime >= :start) and (:end is null or r.createTime < :end) "
+ "and (r.ifOpen = 1 or r.teacherId = :viewerId) "
+ "group by r.moduleId")
List<Object[]> countGroupByModuleVisible(@Param("schId") Integer schId,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end,
@Param("viewerId") Integer viewerId);
/** 按学科聚合:学科值、记录数 */
@Query("select r.subject, r.subjectName, count(r) from ProfessionRecord r "
+ "where r.schId = :schId and (:start is null or r.createTime >= :start) and (:end is null or r.createTime < :end) "
@@ -21,13 +21,18 @@ public interface ProfessionVisitRepository extends JpaRepository<ProfessionVisit
List<ProfessionVisit> findByRecordId(Integer recordId);
// ===== 聚合计数(个人空间概览) =====
// ===== 聚合计数(个人空间概览,按访客+记录去重:刷新/回看不重复计数) =====
/** 被访问总次数(作为被访问者) */
long countByAuthorId(Integer authorId);
/** 同一访客是否已看过该记录(看过即不再新增足迹/阅读数) */
boolean existsByVisitorIdAndRecordId(Integer visitorId, Integer recordId);
/** 时间段内被访问次数(start/end 传 null 表示不限) */
@Query("select count(v) from ProfessionVisit v where v.authorId = :uid " +
/** 被访问总数(同一访客看同一篇只算一次;历史刷新产生的重复足迹不计入) */
@Query("select count(distinct concat(v.visitorId, ':', v.recordId)) from ProfessionVisit v " +
"where v.authorId = :uid")
long countDistinctVisitsByAuthor(@Param("uid") Integer uid);
/** 时间段内被访问总数,按访客+记录去重(start/end 传 null 表示不限) */
@Query("select count(distinct concat(v.visitorId, ':', v.recordId)) from ProfessionVisit v where v.authorId = :uid " +
"and (:start is null or v.createTime >= :start) and (:end is null or v.createTime < :end)")
long countVisitsByAuthor(@Param("uid") Integer uid,
@Param("start") java.time.LocalDateTime start,
@@ -26,20 +26,21 @@ public interface IProfessionService {
* @param teacherId 按教师过滤(可空)
* @param groupId 按教师分组过滤(可空;组员=组长+组员教师集合)
* @param withOtherModules true 时附带同校其他启用栏目(对齐老契约 OtherData)
* @param viewerId 当前登录教师 id;非空时过滤掉他人不公开记录(ifOpen=0 仅发布人可见),空则不过滤(后台)
*/
Map<String, Object> pageRecords(Integer schId, Integer moduleId, Integer teacherId, Integer groupId,
String subject, String grade,
String keyword, String startTime, String endTime,
int pageIndex, int pageSize, boolean withOtherModules);
int pageIndex, int pageSize, boolean withOtherModules, Integer viewerId);
/** 记录详情(阅读数 +1;含栏目绑定字段定义、附件、动态字段值原文、点赞数与当前用户点赞态) */
/** 记录详情(阅读数 +1;含栏目绑定字段定义、附件、动态字段值原文、点赞数与当前用户点赞态;不公开记录仅发布人可看) */
Map<String, Object> recordDetail(Integer schId, Integer recordId, Integer currentUserId);
/** 首页各栏目最新记录块 */
List<Map<String, Object>> homeModuleRecords(Integer schId, int sizePerModule);
/** 首页各栏目最新记录块(过滤他人不公开记录,计数同口径) */
List<Map<String, Object>> homeModuleRecords(Integer schId, int sizePerModule, Integer viewerId);
/** 个人空间按年时间轴 */
List<Map<String, Object>> myTimeline(Integer schId, Integer teacherId);
/** 个人空间按年时间轴(viewerId 非空时过滤掉他人不公开记录) */
List<Map<String, Object>> myTimeline(Integer schId, Integer teacherId, Integer viewerId);
// ==================== 评论 ====================
@@ -70,6 +71,9 @@ public interface IProfessionService {
/** 我的积分(发布 + 评论) */
Map<String, Object> myPoints(Integer schId, Integer teacherId, String teacherName);
/** 积分来源明细分页:发布/评论逐条带分值(合计口径同 myPoints;明细按查看者可见性过滤) */
Map<String, Object> pointsSourceList(Integer schId, Integer teacherId, Integer viewerId, Integer pageIndex, Integer pageSize);
/** 发布排行(默认前 10) */
List<Map<String, Object>> releaseRanking(Integer schId, Integer topN);
@@ -84,4 +88,10 @@ public interface IProfessionService {
/** 个人空间聚合:姓名/积分/累计记录/获赞/被访问/各栏目篇数/本月概览(发布/评论/阅读/访客) */
Map<String, Object> spaceOverview(Integer schId, Integer userid);
/** 年级段字典(按校过滤:取班级管理该校学制的年级并映射回本字典;班级管理不可用/无数据时回退全量) */
List<Map<String, Object>> schoolGradeList(Integer schId);
/** 学科字典(按校过滤:取班级管理该校启用学科并按名映射回本字典;班级管理不可用/无数据时回退全量) */
List<Map<String, Object>> schoolSubjectList(Integer schId);
}
@@ -1,8 +1,8 @@
package com.yunhai.profession.service.impl;
import com.yunhai.profession.entity.UpFile;
import com.yunhai.profession.model.file.UpFileVo;
import com.yunhai.profession.model.file.UpFileResultVo;
import com.yunhai.profession.model.file.UpFileVo;
import com.yunhai.profession.repository.UpFileRepository;
import com.yunhai.profession.service.IFileService;
import com.yunhai.platform.apistd.BizException;
@@ -18,30 +18,14 @@ import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.URLConnection;
import java.net.URLEncoder;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.*;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -50,7 +34,7 @@ import java.util.zip.ZipOutputStream;
* 文件上传/下载/删除业务实现。
* <p>
* 存储路径规则:{uploadPath}/{appType}/{yyyyMMdd}/{uuid}.{ext}
* 与 C# UpLoadFile.FileSave 逻辑等价,但去除分片上传(预览走本服务转 PDF,见 preview)。
* 与 C# UpLoadFile.FileSave 逻辑等价,但去除分片上传和 Office 转 PDF(后续按需补充)。
*/
@Service
public class FileService implements IFileService {
@@ -60,20 +44,9 @@ public class FileService implements IFileService {
@Autowired
private UpFileRepository upFileRepository;
@Value("${file.upload-path}")
@Value("${FILE_PATH}")
private String uploadPath;
private static final String DEFAULT_APP_TYPE = "profession";
/** Office 类型扩展名:预览前需转 PDF(浏览器无法内联渲染 msword 等原始格式) */
private static final Set<String> OFFICE_EXTS = new HashSet<>(Arrays.asList(
"doc", "docx", "wps", "xls", "xlsx", "et", "ppt", "pptx", "dps"));
/** Office→PDF 全局锁:COM 自动化不宜并发,转换一次后复用同名 .pdf 缓存 */
private static final Object PDF_CONVERT_LOCK = new Object();
private static volatile Path wpsScript;
// ============================ 上传 ============================
@Override
@@ -93,13 +66,10 @@ public class FileService implements IFileService {
ext = originalName.substring(dotIdx + 1).toLowerCase();
}
// 构造存储子路径:{appType}/{yyyyMMdd}/{uuid}.{ext}
String dateDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String savedName = UUID.randomUUID().toString().replace("-", "") + (ext.isEmpty() ? "" : "." + ext);
String relativePath = (appType == null ? DEFAULT_APP_TYPE : appType) + "/" + dateDir + "/" + savedName;
// 物理写入
Path fullPath = Paths.get(uploadPath, relativePath);
Path fullPath = Paths.get(uploadPath, savedName);
try {
Files.createDirectories(fullPath.getParent());
file.transferTo(fullPath.toFile());
@@ -110,10 +80,10 @@ public class FileService implements IFileService {
// 入库
UpFile entity = new UpFile();
entity.setTopicType(appType == null ? DEFAULT_APP_TYPE : appType);
entity.setTopicType(appType);
entity.setTopicId(topicId);
entity.setFileTrueName(originalName);
entity.setFileName(relativePath);
entity.setFileName(savedName);
entity.setFileSize((int) file.getSize());
entity.setFileType(ext);
upFileRepository.save(entity);
@@ -141,8 +111,7 @@ public class FileService implements IFileService {
@Override
public List<UpFileVo> listByTopic(String appType, Integer topicId) {
String type = appType == null ? DEFAULT_APP_TYPE : appType;
return upFileRepository.findByTopicTypeAndTopicId(type, topicId).stream()
return upFileRepository.findByTopicTypeAndTopicId(appType, topicId).stream()
.map(this::toVo)
.collect(Collectors.toList());
}
@@ -156,174 +125,9 @@ public class FileService implements IFileService {
@Override
public void preview(Integer fileId, HttpServletResponse response) {
UpFile file = upFileRepository.findById(fileId)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId));
Path fullPath = Paths.get(uploadPath, file.getFileName());
if (!Files.exists(fullPath)) {
throw new BizException(ErrorCode.FORBIDDEN, "物理文件不存在: " + file.getFileName());
}
if (OFFICE_EXTS.contains(extOf(file.getFileName()))) {
// Office 原始格式浏览器无法内联渲染:转 PDF 后输出(与老系统 Aspose 转 PDF 链路对齐)
Path pdfPath = siblingPdf(fullPath);
try {
ensureOfficePdf(fullPath, pdfPath);
streamFile(pdfPath, "application/pdf", pdfDisplayName(file.getFileTrueName()), response);
} catch (Exception e) {
log.warn("[preview] Office 转 PDF 失败,输出提示页: {}", file.getFileName(), e);
writeUnsupportedHtml(response);
}
return;
}
writeFile(fileId, response, true);
}
// ============================ Office 转 PDF ============================
/** 转换并缓存:{uuid}.doc → 同目录 {uuid}.pdf,已存在直接复用 */
private void ensureOfficePdf(Path officePath, Path pdfPath) throws Exception {
synchronized (PDF_CONVERT_LOCK) {
if (Files.exists(pdfPath) && Files.size(pdfPath) > 0) {
return;
}
Files.deleteIfExists(pdfPath);
runWpsConvert(officePath, pdfPath);
if (!Files.exists(pdfPath) || Files.size(pdfPath) == 0) {
throw new IllegalStateException("转换未产出 PDF: " + pdfPath);
}
}
}
/**
* 通过 PowerShell 调用 WPS 的 Office COM 接口导出 PDF(本机/服务器需安装 WPS,
* ProgID:KWPS=文字 KET=表格 KWPP=演示,API 兼容 MS Office)。
* 未安装 WPS 或转换失败时由调用方降级为提示页。
*/
private static final String WPS_CONVERT_SCRIPT =
"param([string]$Src,[string]$Dst)\n"
+ "$ErrorActionPreference='Stop'\n"
+ "$ext=[IO.Path]::GetExtension($Src).ToLowerInvariant()\n"
+ "$app=$null; $doc=$null\n"
+ "try {\n"
+ " if($ext -eq '.doc' -or $ext -eq '.docx' -or $ext -eq '.wps') {\n"
+ " $app=New-Object -ComObject KWPS.Application\n"
+ " $doc=$app.Documents.Open($Src,$false,$true)\n"
+ " try { $doc.ExportAsFixedFormat($Dst,17) } catch { $doc.SaveAs2($Dst,17) }\n"
+ " } elseif($ext -eq '.xls' -or $ext -eq '.xlsx' -or $ext -eq '.et') {\n"
+ " $app=New-Object -ComObject KET.Application\n"
+ " $doc=$app.Workbooks.Open($Src,0,$true)\n"
+ " $doc.ExportAsFixedFormat(0,$Dst)\n"
+ " } elseif($ext -eq '.ppt' -or $ext -eq '.pptx' -or $ext -eq '.dps') {\n"
+ " $app=New-Object -ComObject KWPP.Application\n"
+ " $doc=$app.Presentations.Open($Src,$true,$false,$false)\n"
+ " $doc.SaveAs($Dst,32)\n"
+ " } else { throw ('unsupported ext: '+$ext) }\n"
+ " if(-not (Test-Path -LiteralPath $Dst)) { throw 'pdf not produced' }\n"
+ " exit 0\n"
+ "} finally {\n"
+ " if($doc){ try{ $doc.Close($false) }catch{} }\n"
+ " if($app){ try{ $app.Quit() }catch{} }\n"
+ "}\n";
private Path wpsScript() throws Exception {
Path script = wpsScript;
if (script != null && Files.exists(script)) {
return script;
}
synchronized (PDF_CONVERT_LOCK) {
if (wpsScript == null || !Files.exists(wpsScript)) {
Path f = Files.createTempFile("wps2pdf", ".ps1");
Files.write(f, WPS_CONVERT_SCRIPT.getBytes(StandardCharsets.US_ASCII));
f.toFile().deleteOnExit();
wpsScript = f;
}
return wpsScript;
}
}
private void runWpsConvert(Path officePath, Path pdfPath) throws Exception {
Path script = wpsScript();
ProcessBuilder pb = new ProcessBuilder("powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass",
"-File", script.toString(), "-Src", officePath.toString(), "-Dst", pdfPath.toString());
pb.redirectErrorStream(true);
Process p = pb.start();
StringBuilder output = new StringBuilder();
Thread drain = new Thread(() -> {
try (BufferedReader r = new BufferedReader(
new InputStreamReader(p.getInputStream(), Charset.defaultCharset()))) {
String line;
while ((line = r.readLine()) != null) {
output.append(line).append('\n');
}
} catch (Exception ignored) {
}
});
drain.setDaemon(true);
drain.start();
if (!p.waitFor(120, TimeUnit.SECONDS)) {
p.destroyForcibly();
throw new IllegalStateException("WPS 转换超时: " + officePath);
}
drain.join(3000);
if (p.exitValue() != 0) {
throw new IllegalStateException("WPS 转换失败 exit=" + p.exitValue() + " output=" + output);
}
}
private String extOf(String fileName) {
String n = String.valueOf(fileName).toLowerCase();
int dot = n.lastIndexOf('.');
return dot >= 0 ? n.substring(dot + 1) : "";
}
private Path siblingPdf(Path officePath) {
String name = officePath.getFileName().toString();
int dot = name.lastIndexOf('.');
String base = dot > 0 ? name.substring(0, dot) : name;
return officePath.resolveSibling(base + ".pdf");
}
private String pdfDisplayName(String trueName) {
String n = String.valueOf(trueName);
int dot = n.lastIndexOf('.');
return (dot > 0 ? n.substring(0, dot) : n) + ".pdf";
}
/** 内联输出物理文件(预览用) */
private void streamFile(Path path, String contentType, String displayName, HttpServletResponse response) {
try (InputStream is = Files.newInputStream(path);
OutputStream os = response.getOutputStream()) {
String encodedName = URLEncoder.encode(displayName, "UTF-8").replaceAll("\\+", "%20");
response.setContentType(contentType);
response.setHeader("Content-Disposition", "inline; filename=\"" + encodedName + "\"");
response.setContentLengthLong(Files.size(path));
byte[] buffer = new byte[8192];
int len;
while ((len = is.read(buffer)) != -1) {
os.write(buffer, 0, len);
}
os.flush();
} catch (Exception e) {
log.error("[streamFile] 文件输出失败: {}", path, e);
throw new BizException(ErrorCode.ERROR, "文件下载失败", e);
}
}
/** 转换不可用时返回给 iframe 的提示页,避免整块空白 */
private void writeUnsupportedHtml(HttpServletResponse response) {
try {
response.setContentType("text/html;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(
"<!doctype html><html><head><meta charset=\"utf-8\">"
+ "<style>body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center;"
+ "background:#f7f8fa;color:#8fa0b6;font:14px/1.8 'Microsoft YaHei',sans-serif}</style></head>"
+ "<body>该文件暂不支持在线预览,请点击右上角\u201c下载\u201d按钮查看</body></html>");
response.getWriter().flush();
} catch (Exception e) {
log.warn("[preview] 提示页输出失败", e);
}
}
/**
* 文件输出。inline=true 时 Content-Disposition 用 inline 并按扩展名输出真实 Content-Type
* (浏览器/前端组件可直接渲染),否则 attachment 强制下载。
@@ -398,11 +202,10 @@ public class FileService implements IFileService {
public void delete(Integer fileId) {
UpFile file = upFileRepository.findById(fileId)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId));
// 删物理文件(连同同名预览 PDF 缓存)
// 删物理文件
Path fullPath = Paths.get(uploadPath, file.getFileName());
try {
Files.deleteIfExists(fullPath);
Files.deleteIfExists(siblingPdf(fullPath));
} catch (Exception e) {
log.warn("[delete] 物理文件删除失败: {}(数据库记录仍会删除)", fullPath, e);
}
@@ -461,8 +264,7 @@ public class FileService implements IFileService {
@Override
public void downloadZipByTopic(String topicType, Integer topicId, String zipName,
HttpServletResponse response) {
String type = topicType == null ? DEFAULT_APP_TYPE : topicType;
List<UpFile> files = upFileRepository.findByTopicTypeAndTopicId(type, topicId);
List<UpFile> files = upFileRepository.findByTopicTypeAndTopicId(topicType, topicId);
downloadZip(files, zipName, response);
}
@@ -475,9 +277,8 @@ public class FileService implements IFileService {
if (files == null || files.length == 0) {
return results;
}
String type = appType != null ? appType : DEFAULT_APP_TYPE;
for (MultipartFile file : files) {
results.add(upload(file, type, null));
results.add(upload(file, appType, null));
}
return results;
}
@@ -112,6 +112,11 @@ public class MouduleService implements IMouduleService {
@Override
public List<Map<String, Object>> myModuleList(Integer currentSchId, Integer teacherId) {
// 行内带上上级栏目 id+名称:栏目条按上级栏目归组做悬停下拉(无上级时 topModuleName 为 null)
Map<Integer, String> topNames = new HashMap<>();
for (TopModule t : topModuleRepository.findBySchIdOrderBySortOrderAscIdAsc(currentSchId)) {
topNames.put(t.getId(), t.getName());
}
List<Map<String, Object>> rows = new ArrayList<>();
if (teacherId != null) {
// 指定教师:各启用栏目下该教师的篇数(含 0,对齐老 MouduleMyList)
@@ -126,6 +131,7 @@ public class MouduleService implements IMouduleService {
row.put("id", m.getId());
row.put("name", m.getName());
row.put("recordCount", countByModule.getOrDefault(m.getId(), 0L));
putTopModuleInfo(row, m, topNames);
rows.add(row);
}
return rows;
@@ -136,11 +142,20 @@ public class MouduleService implements IMouduleService {
row.put("id", m.getId());
row.put("name", m.getName());
row.put("recordCount", countByModule.getOrDefault(m.getId(), 0L));
putTopModuleInfo(row, m, topNames);
rows.add(row);
}
return rows;
}
/** 栏目行补充上级栏目信息(topModuleId=0/null 视为无上级,名称置 null) */
private void putTopModuleInfo(Map<String, Object> row, ProfessionModule m, Map<Integer, String> topNames) {
Integer topId = m.getTopModuleId();
boolean hasTop = topId != null && topId != 0;
row.put("topModuleId", hasTop ? topId : null);
row.put("topModuleName", hasTop ? topNames.get(topId) : null);
}
@Override
@Transactional
public Integer createModule(Integer currentSchId, boolean districtAccount, Map<String, Object> req) {
@@ -236,9 +251,12 @@ public class MouduleService implements IMouduleService {
result.put("openUserIds", m.getOpenUserIds());
result.put("remark", m.getRemark());
result.put("fieldIds", m.getFieldIds());
// 发布表单只渲染已绑定字段(按绑定顺序,对齐老 Getdatelist 的 itemlist)
List<Map<String, Object>> fields = new ArrayList<>();
for (ProfessionField f : fieldRepository.findBySchIdOrderByFieldSortAscIdAsc(m.getSchId())) {
fields.add(fieldRow(f, boundIds.contains(f.getId())));
for (Integer fieldId : boundIds) {
fieldRepository.findById(fieldId)
.filter(f -> f.getSchId().equals(m.getSchId()))
.ifPresent(f -> fields.add(fieldRow(f, true)));
}
result.put("fields", fields);
return result;
@@ -7,6 +7,9 @@ import com.yunhai.profession.entity.ProfessionModule;
import com.yunhai.profession.entity.ProfessionRecord;
import com.yunhai.profession.entity.ProfessionVisit;
import com.yunhai.profession.entity.UpFile;
import com.yunhai.profession.model.bsch.SchGradeDto;
import com.yunhai.profession.model.bsch.SchInfoDto;
import com.yunhai.profession.model.bsch.SchSubjectDto;
import com.yunhai.profession.model.bsch.TeacherCustomGroupDto;
import com.yunhai.profession.repository.ProfessionDiscussRepository;
import com.yunhai.profession.repository.ProfessionFieldRepository;
@@ -20,6 +23,7 @@ import com.yunhai.profession.service.IPointsService;
import com.yunhai.profession.service.IProfessionService;
import com.yunhai.profession.service.IClassService;
import com.yunhai.profession.utils.ProfessionContext;
import com.yunhai.profession.utils.ProfessionDicts;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -170,10 +174,10 @@ public class ProfessionService implements IProfessionService {
public Map<String, Object> pageRecords(Integer schId, Integer moduleId, Integer teacherId, Integer groupId,
String subject, String grade,
String keyword, String startTime, String endTime,
int pageIndex, int pageSize, boolean withOtherModules) {
int pageIndex, int pageSize, boolean withOtherModules, Integer viewerId) {
Specification<ProfessionRecord> spec = recordSpec(schId, moduleId, teacherId,
groupId != null ? groupTeacherIds(schId, groupId) : null,
subject, grade, keyword, startTime, endTime);
subject, grade, keyword, startTime, endTime, viewerId);
Page<ProfessionRecord> page = recordRepository.findAll(spec,
PageRequest.of(Math.max(pageIndex - 1, 0), pageSize <= 0 ? 10 : pageSize,
Sort.by(Sort.Direction.DESC, "createTime", "id")));
@@ -210,7 +214,11 @@ public class ProfessionService implements IProfessionService {
if (!r.getSchId().equals(schId)) {
throw new IllegalArgumentException("无权查看该记录");
}
recordRepository.increaseReadCount(recordId);
// 不公开记录仅发布人本人可看
if (Integer.valueOf(0).equals(r.getIfOpen()) && !r.getTeacherId().equals(currentUserId)) {
throw new IllegalArgumentException("该记录不公开,仅发布人可见");
}
// 纯读:阅读数统一在 visitCreate(浏览足迹)时 +1,避免详情+足迹各加一次导致重复计数
Map<String, Object> row = recordRow(r, moduleNameMap(schId).get(r.getModuleId()), null);
row.put("remark", r.getRemark());
row.put("contents", r.getContents());
@@ -250,15 +258,15 @@ public class ProfessionService implements IProfessionService {
}
@Override
public List<Map<String, Object>> homeModuleRecords(Integer schId, int sizePerModule) {
public List<Map<String, Object>> homeModuleRecords(Integer schId, int sizePerModule, Integer viewerId) {
Map<Integer, Long> countByModule = new HashMap<>();
for (Object[] row : recordRepository.countGroupByModule(schId, null, null)) {
for (Object[] row : recordRepository.countGroupByModuleVisible(schId, null, null, viewerId)) {
countByModule.put((Integer) row[0], (Long) row[1]);
}
List<Map<String, Object>> blocks = new ArrayList<>();
for (ProfessionModule m : moduleRepository.findVisibleEnabled(schId)) {
Page<ProfessionRecord> page = recordRepository.findAll(
recordSpec(schId, m.getId(), null, null, null, null, null, null, null),
recordSpec(schId, m.getId(), null, null, null, null, null, null, null, viewerId),
PageRequest.of(0, Math.max(sizePerModule, 1), Sort.by(Sort.Direction.DESC, "createTime", "id")));
List<Map<String, Object>> items = new ArrayList<>();
for (ProfessionRecord r : page.getContent()) {
@@ -281,8 +289,9 @@ public class ProfessionService implements IProfessionService {
}
@Override
public List<Map<String, Object>> myTimeline(Integer schId, Integer teacherId) {
Specification<ProfessionRecord> spec = recordSpec(schId, null, teacherId, null, null, null, null, null, null);
public List<Map<String, Object>> myTimeline(Integer schId, Integer teacherId, Integer viewerId) {
Specification<ProfessionRecord> spec = recordSpec(schId, null, teacherId, null, null, null, null, null, null,
viewerId);
List<ProfessionRecord> records = recordRepository.findAll(spec,
PageRequest.of(0, 500, Sort.by(Sort.Direction.DESC, "createTime", "id"))).getContent();
Map<Integer, String> moduleName = moduleNameMap(schId);
@@ -395,6 +404,10 @@ public class ProfessionService implements IProfessionService {
@Override
@Transactional
public void visitCreate(Integer schId, Integer visitorId, String visitorName, Integer recordId) {
// 同一访客同一篇记录只记一次足迹:刷新/回看不新增足迹、不加阅读数(防刷新刷访问量)
if (visitRepository.existsByVisitorIdAndRecordId(visitorId, recordId)) {
return;
}
ProfessionVisit v = new ProfessionVisit();
v.setSchId(schId);
// 开发兜底名(teacher-xxx)替换为该教师记录冗余的真实姓名
@@ -508,6 +521,77 @@ public class ProfessionService implements IProfessionService {
return result;
}
@Override
public Map<String, Object> pointsSourceList(Integer schId, Integer teacherId, Integer viewerId,
Integer pageIndex, Integer pageSize) {
// 合计口径与 myPoints 完全一致(含未公开记录),明细行按查看者可见性过滤
Map<String, Object> totals = myPoints(schId, teacherId, null);
List<Map<String, Object>> merged = new ArrayList<>();
// 发布来源:本人记录(可见性与时间轴一致:ifOpen=0 仅发布人本人可见)
Map<Integer, String> moduleName = moduleNameMap(schId);
List<ProfessionRecord> records = recordRepository.findAll(
recordSpec(schId, null, teacherId, null, null, null, null, null, null, viewerId),
Sort.by(Sort.Direction.DESC, "createTime", "id"));
for (ProfessionRecord r : records) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("type", "publish");
row.put("id", r.getId());
row.put("recordId", r.getId());
row.put("title", r.getTitle());
row.put("moduleName", moduleName.get(r.getModuleId()));
row.put("points", r.getSendPoints());
row.put("createTime", r.getCreateTime());
merged.add(row);
}
// 评论来源:本人评论 → 关联记录标题;记录对查看者不可见时跳过(避免借标题泄露未公开记录)
List<ProfessionDiscuss> discusses =
discussRepository.findBySchIdAndTeacherIdOrderByCreateTimeDescIdDesc(schId, teacherId);
Map<Integer, ProfessionRecord> ref = new HashMap<>();
if (!discusses.isEmpty()) {
Set<Integer> recordIds = new HashSet<>();
for (ProfessionDiscuss d : discusses) {
recordIds.add(d.getRecordId());
}
for (ProfessionRecord r : recordRepository.findAllById(recordIds)) {
ref.put(r.getId(), r);
}
}
for (ProfessionDiscuss d : discusses) {
ProfessionRecord r = ref.get(d.getRecordId());
if (r == null || (Integer.valueOf(0).equals(r.getIfOpen()) && !r.getTeacherId().equals(viewerId))) {
continue;
}
Map<String, Object> row = new LinkedHashMap<>();
row.put("type", "comment");
row.put("id", d.getId());
row.put("recordId", r.getId());
row.put("title", r.getTitle());
row.put("contents", d.getContents());
row.put("points", d.getCommPoints());
row.put("createTime", d.getCreateTime());
merged.add(row);
}
// 新在前(createTime/id 双键,与记录列表排序同规);内存分页(明细量级小,免两条 offset 查询)
merged.sort(Comparator.comparing((Map<String, Object> row) -> (LocalDateTime) row.get("createTime"),
Comparator.nullsLast(Comparator.reverseOrder())));
int total = merged.size();
int size = pageSize == null || pageSize <= 0 ? 10 : Math.min(pageSize, 100);
int pages = (total + size - 1) / size;
int idx = pageIndex == null || pageIndex < 1 ? 1 : Math.min(pageIndex, Math.max(pages, 1));
int from = Math.min((idx - 1) * size, total);
int to = Math.min(from + size, total);
Map<String, Object> data = new LinkedHashMap<>();
data.put("teacherId", teacherId);
data.put("sendPoints", totals.get("sendPoints"));
data.put("commPoints", totals.get("commPoints"));
data.put("pointsnum", totals.get("pointsnum"));
data.put("total", total);
data.put("pageIndex", idx);
data.put("pageSize", size);
data.put("rows", merged.subList(from, to));
return data;
}
@Override
public List<Map<String, Object>> releaseRanking(Integer schId, Integer topN) {
List<Map<String, Object>> rows = new ArrayList<>();
@@ -702,7 +786,7 @@ public class ProfessionService implements IProfessionService {
private Specification<ProfessionRecord> recordSpec(Integer schId, Integer moduleId, Integer teacherId,
Collection<Integer> teacherIds,
String subject, String grade, String keyword,
String startTime, String endTime) {
String startTime, String endTime, Integer viewerId) {
final LocalDateTime start = parseStart(startTime);
final LocalDateTime end = parseEnd(endTime);
return (root, cq, cb) -> {
@@ -714,6 +798,10 @@ public class ProfessionService implements IProfessionService {
if (teacherId != null) {
ps.add(cb.equal(root.get("teacherId"), teacherId));
}
if (viewerId != null) {
// 不公开记录(ifOpen=0)仅发布人本人可见
ps.add(cb.or(cb.equal(root.get("ifOpen"), 1), cb.equal(root.get("teacherId"), viewerId)));
}
if (teacherIds != null) {
// 组过滤:空集合(组不存在/组服务不可用)时恒假,避免退化为不过滤
ps.add(teacherIds.isEmpty() ? cb.disjunction() : root.get("teacherId").in(teacherIds));
@@ -896,7 +984,7 @@ public class ProfessionService implements IProfessionService {
}
m.put("recordTotal", recordTotal);
m.put("likeTotal", recordRepository.sumLikesByTeacher(schId, userid));
m.put("visitTotal", visitRepository.countByAuthorId(userid));
m.put("visitTotal", visitRepository.countDistinctVisitsByAuthor(userid));
// 各栏目篇数(启用栏目,含 0)
Map<Integer, Long> byModule = new HashMap<>();
for (Object[] row : recordRepository.countGroupByTeacherModule(schId, null, null)) {
@@ -924,5 +1012,92 @@ public class ProfessionService implements IProfessionService {
return m;
}
// ==================== 按校字典 ====================
/** 按校字典缓存 TTL:班级管理数据改动低频,60 秒内直接复用 */
private static final long DICT_CACHE_MS = 60_000L;
/** 缓存键(kind:schId)→ 快照 */
private final Map<String, CachedDict> dictCache = new HashMap<>();
@Override
public List<Map<String, Object>> schoolGradeList(Integer schId) {
return schoolDict("grade", schId, () -> {
Set<String> values = new LinkedHashSet<>();
for (SchGradeDto g : classService.getSchGradeList(schoolSerId(schId))) {
String v = ProfessionDicts.gradeValueByNickname(g.getName());
if (v != null) {
values.add(v);
}
}
return ProfessionDicts.gradeListOf(values);
});
}
@Override
public List<Map<String, Object>> schoolSubjectList(Integer schId) {
return schoolDict("subject", schId, () -> {
Set<String> subIds = new LinkedHashSet<>();
for (SchSubjectDto s : classService.getSchSubList(schoolSerId(schId))) {
String id = ProfessionDicts.subjectIdByName(s.getSubName());
if (id != null) {
subIds.add(id);
}
}
return ProfessionDicts.subjectListOf(subIds);
});
}
/**
* 按校范围取字典:loader 调班级管理并映射回本字典编码;
* schId 为空、班级管理不可用或映射结果为空(如局端账号无校)时回退全量字典(下拉不空)。
*/
private List<Map<String, Object>> schoolDict(String kind, Integer schId, java.util.function.Supplier<List<Map<String, Object>>> loader) {
if (schId == null) {
return fullDict(kind);
}
String key = kind + ":" + schId;
synchronized (dictCache) {
CachedDict c = dictCache.get(key);
if (c != null && System.currentTimeMillis() - c.at < DICT_CACHE_MS) {
return c.data;
}
}
List<Map<String, Object>> data;
try {
data = loader.get();
} catch (Exception e) {
log.warn("[schoolDict] 班级管理取{}失败, schId={}", kind, schId, e);
data = new ArrayList<>();
}
if (data == null || data.isEmpty()) {
data = fullDict(kind);
}
synchronized (dictCache) {
dictCache.put(key, new CachedDict(System.currentTimeMillis(), data));
}
return data;
}
private List<Map<String, Object>> fullDict(String kind) {
return "grade".equals(kind) ? ProfessionDicts.gradeList() : ProfessionDicts.subjectList();
}
/** 学校 GUID(班级管理侧 SchSerId);查不到返回 null(loader 得空列表 → 回退全量) */
private String schoolSerId(Integer schId) {
SchInfoDto info = classService.getSchFirst(schId);
return info == null ? null : info.getSchSerId();
}
private static final class CachedDict {
final long at;
final List<Map<String, Object>> data;
CachedDict(long at, List<Map<String, Object>> data) {
this.at = at;
this.data = data;
}
}
// ==================== 聚合辅助 ====================
}
@@ -71,6 +71,76 @@ public class ProfessionDicts {
return value;
}
/**
* 班级管理年级昵称 → 本字典 value(如「初一」→ 8),无对应项(幼儿园/大学)返回 null。
* 班级管理 gradeId 与本字典错位一位(6=预备/7=初一…),故按昵称映射而非按 Id。
*/
public static String gradeValueByNickname(String nickname) {
if (nickname == null) {
return null;
}
String n = nickname.trim();
String[][] grades = {
{"小一", "一年级", "1"}, {"小二", "二年级", "2"}, {"小三", "三年级", "3"},
{"小四", "四年级", "4"}, {"小五", "五年级", "5"}, {"小六", "六年级", "6"},
{"预备", "7"}, {"初一", "8"}, {"初二", "9"}, {"初三", "10"},
{"高一", "11"}, {"高二", "12"}, {"高三", "13"},
};
for (String[] g : grades) {
if (n.equals(g[0]) || (g.length > 2 && n.equals(g[1]))) {
return g[g.length - 1];
}
}
return null;
}
/**
* 班级管理学科名 → 本字典 subId(如「道法」→ SUB06 道德与法治),无对应项返回 null。
* 班级管理学科编码与本字典自 SUB06 起不一致(SUB06=生物),故按名称映射而非按编码。
*/
public static String subjectIdByName(String name) {
if (name == null) {
return null;
}
String n = name.trim();
String[][] subs = {
{"语文", "SUB01"}, {"数学", "SUB02"}, {"英语", "SUB03"}, {"物理", "SUB04"},
{"化学", "SUB05"}, {"道德与法治", "SUB06"}, {"道法", "SUB06"}, {"政治", "SUB06"},
{"思想政治", "SUB06"}, {"历史", "SUB07"}, {"地理", "SUB08"}, {"生物", "SUB09"},
{"音乐", "SUB10"}, {"体育与健康", "SUB11"}, {"体育", "SUB11"},
{"美术", "SUB12"}, {"信息技术", "SUB13"}, {"信息", "SUB13"},
{"劳动", "SUB14"}, {"劳技", "SUB14"}, {"劳动与技术", "SUB14"},
};
for (String[] s : subs) {
if (n.equals(s[0])) {
return s[1];
}
}
return null;
}
/** 按字典顺序取 value 子集的年级段(去重;用于按校范围过滤) */
public static List<Map<String, Object>> gradeListOf(java.util.Collection<String> values) {
List<Map<String, Object>> list = new ArrayList<>();
for (Map<String, Object> it : gradeList()) {
if (values != null && values.contains(it.get("value"))) {
list.add(it);
}
}
return list;
}
/** 按字典顺序取 subId 子集的学科(去重;用于按校范围过滤) */
public static List<Map<String, Object>> subjectListOf(java.util.Collection<String> subIds) {
List<Map<String, Object>> list = new ArrayList<>();
for (Map<String, Object> it : subjectList()) {
if (subIds != null && subIds.contains(it.get("subId"))) {
list.add(it);
}
}
return list;
}
private static Map<String, Object> item(String value, String label) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("value", value);
+9 -5
View File
@@ -46,9 +46,7 @@ spring:
simple:
auto-startup: false
# 文件上传存储路径(Docker 部署时与 volume 挂载一致)
file:
upload-path: D:/data/upload
# 教师专业发展应用配置
profession:
@@ -63,7 +61,9 @@ sms:
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
yh:
sso:
server-url-prefix: http://sso.yunhaixingtong.com
# 单点登录服务地址:本地运行 CasService(E:\YHXT\java\user-cas-service\CasService,8443 根路径)。
# 远程地址为 http://sso.yunhaixingtong.com(生产/联调环境)
server-url-prefix: http://localhost:8443
client-host-url: http://localhost:8107
allow-headers: all
allow-origins: all
@@ -78,5 +78,9 @@ yh:
enabled: true
base-host: http://base.yunhaixingtong.com
user-center-url: http://base.yunhaixingtong.com/userbase
# 班级管理(bschclass)服务基址
# 班级管理(bschclass)服务基址:本地运行 UserclassService(E:\YHXT\java\UserclassService,8080 根路径,
# IDEA 启动;/oapi/** 免签名免登录)。远程为 http://base.yunhaixingtong.com/userclass
bschclass: http://base.yunhaixingtong.com/userclass
FILE_PATH: D:/data/upload
FILE_WEB_PATH: http://base.yunhaixingtong.com/userbase/upload/