classNode = index.classNodes.get(classId);
+ node.setStudentCount(classNode == null ? 0 : countStudents(classNode));
+ return node;
+ }
+
+ private void requireHost() {
+ if (!StringUtils.hasText(bschClassHost)) {
+ throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口未配置(yh.open.bschclass)");
+ }
+ }
+
+ private Integer asInt(Object v) {
+ if (v == null) {
+ return null;
+ }
+ if (v instanceof Number) {
+ return ((Number) v).intValue();
+ }
+ try {
+ return Integer.valueOf(String.valueOf(v));
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ private String asStr(Object v) {
+ return v == null ? null : String.valueOf(v);
+ }
+}
diff --git a/src/main/java/com/yunhai/acasyllabu/service/impl/FileService.java b/src/main/java/com/yunhai/acasyllabu/service/impl/FileService.java
new file mode 100644
index 0000000..a0187dc
--- /dev/null
+++ b/src/main/java/com/yunhai/acasyllabu/service/impl/FileService.java
@@ -0,0 +1,309 @@
+package com.yunhai.acasyllabu.service.impl;
+
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import com.yunhai.acasyllabu.entity.UpFile;
+import com.yunhai.acasyllabu.model.file.UpFileResultVo;
+import com.yunhai.acasyllabu.model.file.UpFileVo;
+import com.yunhai.acasyllabu.repository.UpFileRepository;
+import com.yunhai.acasyllabu.service.IFileService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.FileInputStream;
+import java.io.OutputStream;
+import java.net.URLConnection;
+import java.net.URLEncoder;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.*;
+import java.util.stream.Collectors;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+/**
+ * 文件上传/下载/删除业务实现。
+ *
+ * 存储路径规则:{uploadPath}/{appType}/{yyyyMMdd}/{uuid}.{ext}
+ * 与 C# UpLoadFile.FileSave 逻辑等价,但去除分片上传和 Office 转 PDF(后续按需补充)。
+ */
+@Service
+public class FileService implements IFileService {
+
+ private static final Logger log = LoggerFactory.getLogger(FileService.class);
+
+ @Autowired
+ private UpFileRepository upFileRepository;
+
+ @Value("${FILE_PATH}")
+ private String uploadPath;
+
+ // ============================ 上传 ============================
+
+ @Override
+ @Transactional
+ public UpFileVo upload(MultipartFile file, String appType, Integer topicId) {
+ if (file == null || file.isEmpty()) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不得为空");
+ }
+ String originalName = file.getOriginalFilename();
+ if (!StringUtils.hasText(originalName)) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "文件名为空");
+ }
+ // 提取后缀
+ String ext = "";
+ int dotIdx = originalName.lastIndexOf('.');
+ if (dotIdx >= 0) {
+ ext = originalName.substring(dotIdx + 1).toLowerCase();
+ }
+
+ String savedName = UUID.randomUUID().toString().replace("-", "") + (ext.isEmpty() ? "" : "." + ext);
+
+ // 物理写入
+ Path fullPath = Paths.get(uploadPath, savedName);
+ try {
+ Files.createDirectories(fullPath.getParent());
+ file.transferTo(fullPath.toFile());
+ } catch (Exception e) {
+ log.error("[upload] 文件保存失败: {}", fullPath, e);
+ throw new BizException(ErrorCode.ERROR, "文件保存失败", e);
+ }
+
+ // 入库
+ UpFile entity = new UpFile();
+ entity.setTopicType(appType);
+ entity.setTopicId(topicId);
+ entity.setFileTrueName(originalName);
+ entity.setFileName(savedName);
+ entity.setFileSize((int) file.getSize());
+ entity.setFileType(ext);
+ upFileRepository.save(entity);
+
+ return toVo(entity);
+ }
+
+ // ============================ 绑定主题 ============================
+
+ @Override
+ @Transactional
+ public void bindToTopic(List fileIds, Integer topicId) {
+ if (fileIds == null || fileIds.isEmpty() || topicId == null) {
+ return;
+ }
+ for (Integer fileId : fileIds) {
+ upFileRepository.findById(fileId).ifPresent(f -> {
+ f.setTopicId(topicId);
+ upFileRepository.save(f);
+ });
+ }
+ }
+
+ // ============================ 查询附件列表 ============================
+
+ @Override
+ public List listByTopic(String appType, Integer topicId) {
+ return upFileRepository.findByTopicTypeAndTopicId(appType, topicId).stream()
+ .map(this::toVo)
+ .collect(Collectors.toList());
+ }
+
+ // ============================ 下载 ============================
+
+ @Override
+ public void download(Integer fileId, HttpServletResponse response) {
+ writeFile(fileId, response, false);
+ }
+
+ @Override
+ public void preview(Integer fileId, HttpServletResponse response) {
+ writeFile(fileId, response, true);
+ }
+
+ /**
+ * 文件输出。inline=true 时 Content-Disposition 用 inline 并按扩展名输出真实 Content-Type
+ * (浏览器/前端组件可直接渲染),否则 attachment 强制下载。
+ */
+ private void writeFile(Integer fileId, HttpServletResponse response, boolean inline) {
+ 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());
+ }
+ try (FileInputStream fis = new FileInputStream(fullPath.toFile());
+ OutputStream os = response.getOutputStream()) {
+ String encodedName = URLEncoder.encode(file.getFileTrueName(), "UTF-8")
+ .replaceAll("\\+", "%20");
+ if (inline) {
+ response.setContentType(resolveContentType(file.getFileName()));
+ response.setHeader("Content-Disposition", "inline; filename=\"" + encodedName + "\"");
+ } else {
+ response.setContentType("application/octet-stream");
+ response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedName + "\"");
+ }
+ response.setContentLengthLong(file.getFileSize());
+ byte[] buffer = new byte[4096];
+ int len;
+ while ((len = fis.read(buffer)) != -1) {
+ os.write(buffer, 0, len);
+ }
+ os.flush();
+ } catch (Exception e) {
+ log.error("[writeFile] 文件输出失败: {}", file.getFileName(), e);
+ throw new BizException(ErrorCode.ERROR, "文件下载失败", e);
+ }
+ }
+
+ /**
+ * 按扩展名推断 Content-Type(JDK 自带表 + 常见办公/图片格式兜底),未知类型回退流格式。
+ */
+ private String resolveContentType(String fileName) {
+ String name = String.valueOf(fileName).toLowerCase();
+ Map table = new HashMap<>();
+ table.put("pdf", "application/pdf");
+ table.put("png", "image/png");
+ table.put("jpg", "image/jpeg");
+ table.put("jpeg", "image/jpeg");
+ table.put("gif", "image/gif");
+ table.put("bmp", "image/bmp");
+ table.put("webp", "image/webp");
+ table.put("svg", "image/svg+xml");
+ table.put("txt", "text/plain;charset=UTF-8");
+ table.put("mp4", "video/mp4");
+ table.put("webm", "video/webm");
+ table.put("ogg", "video/ogg");
+ table.put("doc", "application/msword");
+ table.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
+ table.put("xls", "application/vnd.ms-excel");
+ table.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ table.put("ppt", "application/vnd.ms-powerpoint");
+ table.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation");
+ String type = table.get(name.substring(name.lastIndexOf('.') + 1));
+ if (type != null) {
+ return type;
+ }
+ String guessed = URLConnection.guessContentTypeFromName(name);
+ return guessed != null ? guessed : "application/octet-stream";
+ }
+
+ // ============================ 删除 ============================
+
+ @Override
+ @Transactional
+ public void delete(Integer fileId) {
+ UpFile file = upFileRepository.findById(fileId)
+ .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId));
+ // 删物理文件
+ Path fullPath = Paths.get(uploadPath, file.getFileName());
+ try {
+ Files.deleteIfExists(fullPath);
+ } catch (Exception e) {
+ log.warn("[delete] 物理文件删除失败: {}(数据库记录仍会删除)", fullPath, e);
+ }
+ // 删数据库记录
+ upFileRepository.delete(file);
+ }
+
+ // ============================ 打包下载 ZIP ============================
+
+ @Override
+ public void downloadZip(List files, String zipName, HttpServletResponse response) {
+ if (files == null || files.isEmpty()) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "无附件可下载");
+ }
+ try {
+ response.setContentType("application/zip");
+ String name = URLEncoder.encode(zipName == null ? "附件" : zipName, "UTF-8")
+ .replaceAll("\\+", "%20");
+ response.setHeader("Content-Disposition", "attachment; filename=\"" + name + ".zip\"");
+ try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) {
+ Set used = new HashSet<>();
+ for (UpFile f : files) {
+ Path p = Paths.get(uploadPath, f.getFileName());
+ if (!Files.exists(p)) {
+ log.warn("[downloadZip] 物理文件缺失,跳过: {}", f.getFileName());
+ continue;
+ }
+ String entryName = sanitizeEntryName(f.getFileTrueName());
+ if (used.contains(entryName)) {
+ entryName = f.getId() + "_" + entryName;
+ }
+ used.add(entryName);
+ zos.putNextEntry(new ZipEntry(entryName));
+ Files.copy(p, zos);
+ zos.closeEntry();
+ }
+ }
+ } catch (Exception e) {
+ log.error("[downloadZip] 打包下载失败", e);
+ throw new BizException(ErrorCode.ERROR, "打包下载失败", e);
+ }
+ }
+
+ /**
+ * 净化 ZIP 条目名:去除路径分隔符等非法字符,防止 Zip Slip。
+ */
+ private String sanitizeEntryName(String name) {
+ if (name == null || name.trim().isEmpty()) {
+ return "file";
+ }
+ return name.replaceAll("[\\\\/:*?\"<>|]", "_");
+ }
+
+ // ============================ 按主题打包下载 ============================
+
+ @Override
+ public void downloadZipByTopic(String topicType, Integer topicId, String zipName,
+ HttpServletResponse response) {
+ List files = upFileRepository.findByTopicTypeAndTopicId(topicType, topicId);
+ downloadZip(files, zipName, response);
+ }
+
+ // ============================ 批量上传图片 ============================
+
+ @Override
+ @Transactional
+ public List uploadImages(MultipartFile[] files, String appType) {
+ List results = new ArrayList<>();
+ if (files == null || files.length == 0) {
+ return results;
+ }
+ for (MultipartFile file : files) {
+ results.add(upload(file, appType, null));
+ }
+ return results;
+ }
+
+ // ============================ 上传(返回前端兼容 VO) ============================
+
+ @Override
+ @Transactional
+ public UpFileResultVo uploadToResult(MultipartFile file, String appType) {
+ UpFileVo vo = upload(file, appType, null);
+ UpFileResultVo result = new UpFileResultVo();
+ result.setFileid(vo.getId());
+ result.setName(vo.getFileTrueName());
+ result.setFileurl(vo.getFileName());
+ result.setHost("");
+ result.setType(vo.getFileType());
+ result.setSize(vo.getFileSize());
+ return result;
+ }
+
+ // ============================ 转换 ============================
+
+ private UpFileVo toVo(UpFile entity) {
+ UpFileVo vo = new UpFileVo();
+ BeanUtils.copyProperties(entity, vo);
+ return vo;
+ }
+}
diff --git a/src/main/java/com/yunhai/acasyllabu/service/impl/SettingService.java b/src/main/java/com/yunhai/acasyllabu/service/impl/SettingService.java
new file mode 100644
index 0000000..0ea6bc0
--- /dev/null
+++ b/src/main/java/com/yunhai/acasyllabu/service/impl/SettingService.java
@@ -0,0 +1,597 @@
+package com.yunhai.acasyllabu.service.impl;
+
+import com.yunhai.acasyllabu.entity.PeriodTemplate;
+import com.yunhai.acasyllabu.entity.TemplateRow;
+import com.yunhai.acasyllabu.entity.TimeSetting;
+import com.yunhai.acasyllabu.model.syllabus.EffectiveSettingVo;
+import com.yunhai.acasyllabu.model.syllabus.PeriodTemplateVo;
+import com.yunhai.acasyllabu.model.syllabus.SettingGetVo;
+import com.yunhai.acasyllabu.model.syllabus.TemplateRowVo;
+import com.yunhai.acasyllabu.model.syllabus.TemplateSaveRequest;
+import com.yunhai.acasyllabu.model.syllabus.TimeSettingVo;
+import com.yunhai.acasyllabu.repository.PeriodTemplateRepository;
+import com.yunhai.acasyllabu.repository.TemplateRowRepository;
+import com.yunhai.acasyllabu.repository.TimeSettingRepository;
+import com.yunhai.acasyllabu.service.ISettingService;
+import com.yunhai.acasyllabu.utils.UserContext;
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+/**
+ * 应用设置业务实现。
+ * 时间设置:每校一行(仅时段判定边界,首查自动建默认 07:00/12:30/13:30/18:30),四边界须依次递增;
+ * 节次模板:绑定学期(0=全年/1=上学期/2=下学期,全年与两学期互通视为重叠)与 1~N 个年级
+ * (同学期内名称唯一),行整存整取(period 节次 / break 空白行);
+ * 启用时段与节次/课间时长随模板保存(绑定模板ID),课表编辑页按生效模板的时段与时长渲染;
+ * 所属时段直接落在行上(band=es/am/noon/pm/ev,不再按开始时间判定),节次与空白行都落在时段里,
+ * 每个启用时段至少保留一条节次(停用时段的节次不在模板行列表中维护)。
+ * 学期重叠且年级有交集的同时只启用一套,学期专属模板优先于全年模板生效;
+ * 行时间 HH:mm 且结束晚于开始,节次数上限 30(与课表保存上限一致)。
+ */
+@Service
+public class SettingService implements ISettingService {
+
+ private static final Logger log = LoggerFactory.getLogger(SettingService.class);
+
+ private static final Pattern TIME_PATTERN = Pattern.compile("^([01]\\d|2[0-3]):[0-5]\\d$");
+
+ private static final String TYPE_PERIOD = "period";
+ private static final String TYPE_BREAK = "break";
+
+ /** 教学节次数上限(与课表保存 MAX_SLOTS 对齐) */
+ private static final int MAX_PERIODS = 30;
+ /** 模板总行数上限(节次 + 空白行) */
+ private static final int MAX_ROWS = 45;
+
+ /** 时段启用位串长度(顺序固定:早自习 上午 中午 下午 晚自习) */
+ private static final int BAND_COUNT = 5;
+ private static final String ALL_BANDS = "11111";
+ /** 时段名(下标与位串一致,报错提示用) */
+ private static final String[] BAND_LABELS = {"早自习", "上午", "中午", "下午", "晚自习"};
+ /** 时段键(下标与位串一致,前后端共用;落库到模板行的 band 列) */
+ private static final String[] BAND_KEYS = {"es", "am", "noon", "pm", "ev"};
+
+ /** 节次时长允许范围(分钟) */
+ private static final int MIN_PERIOD_MINUTES = 15;
+ private static final int MAX_PERIOD_MINUTES = 120;
+ private static final int DEFAULT_PERIOD_MINUTES = 45;
+ /** 课间时长允许范围(分钟) */
+ private static final int MIN_BREAK_MINUTES = 5;
+ private static final int MAX_BREAK_MINUTES = 60;
+ private static final int DEFAULT_BREAK_MINUTES = 10;
+ /** 年级串(逗号拼接)总长上限,与实体列长一致 */
+ private static final int MAX_GRADES_LENGTH = 64;
+
+ @Autowired
+ private TimeSettingRepository timeSettingRepository;
+
+ @Autowired
+ private PeriodTemplateRepository templateRepository;
+
+ @Autowired
+ private TemplateRowRepository rowRepository;
+
+ // ============================ 查询 ============================
+
+ @Override
+ public SettingGetVo get() {
+ Integer schId = currentSchId();
+ SettingGetVo vo = new SettingGetVo();
+ vo.setTime(toTimeVo(ensureTimeSetting(schId)));
+ for (PeriodTemplate tpl : templateRepository.findBySchIdOrderByIdAsc(schId)) {
+ vo.getTemplates().add(toTemplateVo(tpl));
+ }
+ return vo;
+ }
+
+ @Override
+ public EffectiveSettingVo effective(String gradeName, Integer term) {
+ Integer schId = currentSchId();
+ EffectiveSettingVo vo = new EffectiveSettingVo();
+ vo.setTime(toTimeVo(ensureTimeSetting(schId)));
+ if (!StringUtils.hasText(gradeName)) {
+ return vo;
+ }
+ String grade = gradeName.trim();
+ // 请求学期只可能是 1/2(课表页按当前学年学期查询);学期专属模板优先于全年模板
+ Integer reqTerm = Integer.valueOf(2).equals(term) ? 2 : 1;
+ templateRepository.findBySchIdAndEnabledTrueOrderByIdAsc(schId).stream()
+ .filter(tpl -> gradeNamesOf(tpl).contains(grade))
+ .filter(tpl -> termOverlaps(reqTerm, tpl.getTerm()))
+ .sorted(Comparator.comparingInt(tpl -> Objects.equals(tpl.getTerm(), reqTerm) ? 0 : 1))
+ .findFirst()
+ .ifPresent(tpl -> {
+ vo.setTeachDays(tpl.getTeachDays());
+ vo.setTemplateName(tpl.getName());
+ // 启用时段与节次/课间时长绑定模板:课表编辑页按生效模板的时段判定与展示
+ vo.getTime().setEnabledBands(sanitizeBands(tpl.getEnabledBands()));
+ if (tpl.getPeriodMinutes() != null) {
+ vo.getTime().setPeriodMinutes(tpl.getPeriodMinutes());
+ }
+ if (tpl.getBreakMinutes() != null) {
+ vo.getTime().setBreakMinutes(tpl.getBreakMinutes());
+ }
+ for (TemplateRow row : rowRepository.findByTemplateIdOrderBySortOrderAsc(tpl.getId())) {
+ vo.getRows().add(toRowVo(row));
+ }
+ });
+ return vo;
+ }
+
+ // ============================ 时间设置 ============================
+
+ @Override
+ @Transactional
+ public void saveTime(TimeSettingVo time) {
+ if (time == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "时间设置不能为空");
+ }
+ String es = orNull(time.getMorningSelfEnd());
+ String am = orNull(time.getForenoonEnd());
+ String pm = orNull(time.getAfternoonStart());
+ String ev = orNull(time.getEveningStart());
+ if (!isTime(es) || !isTime(am) || !isTime(pm) || !isTime(ev)) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "时间格式应为 HH:mm");
+ }
+ if (!(toMinutes(es) < toMinutes(am) && toMinutes(am) <= toMinutes(pm) && toMinutes(pm) < toMinutes(ev))) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "时间边界须依次递增:早自习 < 上午 ≤ 下午开始 < 晚自习");
+ }
+ TimeSetting setting = ensureTimeSetting(currentSchId());
+ setting.setMorningSelfEnd(es);
+ setting.setForenoonEnd(am);
+ setting.setAfternoonStart(pm);
+ setting.setEveningStart(ev);
+ setting.setEnabledBands(normalizeBands(time.getEnabledBands(), ALL_BANDS));
+ setting.setPeriodMinutes(normalizeMinutes(time.getPeriodMinutes(),
+ MIN_PERIOD_MINUTES, MAX_PERIOD_MINUTES, setting.getPeriodMinutes(), "节次时间"));
+ setting.setBreakMinutes(normalizeMinutes(time.getBreakMinutes(),
+ MIN_BREAK_MINUTES, MAX_BREAK_MINUTES, setting.getBreakMinutes(), "课间时间"));
+ timeSettingRepository.save(setting);
+ log.info("[saveTime] schId={}, 早自习<{}, 上午<{}, 下午>={}, 晚自习>={}, 时段[{}], 节次{}分钟/课间{}分钟",
+ setting.getSchId(), es, am, pm, ev, setting.getEnabledBands(),
+ setting.getPeriodMinutes(), setting.getBreakMinutes());
+ }
+
+ // ============================ 节次模板 ============================
+
+ @Override
+ @Transactional
+ public Integer saveTemplate(TemplateSaveRequest request) {
+ Integer schId = currentSchId();
+ if (request == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "模板不能为空");
+ }
+ String name = trimmed(request.getName(), "模板名称");
+ if (name.length() > 16) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "模板名称不能超过 16 字");
+ }
+ Integer term = normalizeTerm(request.getTerm());
+ String gradeName = normalizeGrades(request.getGradeNames());
+ boolean enabled = Boolean.TRUE.equals(request.getEnabled());
+ String teachDays = normalizeTeachDays(request.getTeachDays());
+
+ PeriodTemplate tpl;
+ if (request.getId() == null) {
+ if (templateRepository.findBySchIdAndTermAndName(schId, term, name).isPresent()) {
+ throw new BizException(ErrorCode.PARAM_ERROR, termName(term) + "已有同名模板「" + name + "」");
+ }
+ tpl = new PeriodTemplate();
+ tpl.setSchId(schId);
+ } else {
+ tpl = templateRepository.findById(request.getId())
+ .filter(t -> Objects.equals(t.getSchId(), schId))
+ .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "模板不存在: " + request.getId()));
+ final PeriodTemplate existing = tpl;
+ boolean duplicated = templateRepository
+ .findBySchIdAndTermAndName(schId, term, name)
+ .filter(t -> !Objects.equals(t.getId(), existing.getId()))
+ .isPresent();
+ if (duplicated) {
+ throw new BizException(ErrorCode.PARAM_ERROR, termName(term) + "已有同名模板「" + name + "」");
+ }
+ }
+ // 启用时段 / 节次与课间时长:随模板保存(空则沿用现值,新建回退全启用与默认时长)
+ String bands = normalizeBands(request.getEnabledBands(), tpl.getEnabledBands());
+ int periodMinutes = normalizeMinutes(request.getPeriodMinutes(),
+ MIN_PERIOD_MINUTES, MAX_PERIOD_MINUTES, orDefault(tpl.getPeriodMinutes(), DEFAULT_PERIOD_MINUTES), "节次时间");
+ int breakMinutes = normalizeMinutes(request.getBreakMinutes(),
+ MIN_BREAK_MINUTES, MAX_BREAK_MINUTES, orDefault(tpl.getBreakMinutes(), DEFAULT_BREAK_MINUTES), "课间时间");
+ List rows = validateRows(request.getRows(), bands);
+
+ tpl.setName(name);
+ tpl.setTerm(term);
+ tpl.setGradeName(gradeName);
+ tpl.setEnabled(enabled);
+ tpl.setTeachDays(teachDays);
+ tpl.setEnabledBands(bands);
+ tpl.setPeriodMinutes(periodMinutes);
+ tpl.setBreakMinutes(breakMinutes);
+ tpl = templateRepository.save(tpl);
+
+ if (enabled) {
+ // 学期重叠(全年与上/下学期互通)且年级有交集的其它启用模板自动停用
+ List grades = gradeNamesOf(tpl);
+ for (PeriodTemplate other : templateRepository.findBySchIdAndEnabledTrueOrderByIdAsc(schId)) {
+ if (!Objects.equals(other.getId(), tpl.getId())
+ && termOverlaps(tpl.getTerm(), other.getTerm())
+ && !Collections.disjoint(gradeNamesOf(other), grades)) {
+ other.setEnabled(Boolean.FALSE);
+ templateRepository.save(other);
+ }
+ }
+ }
+
+ rowRepository.deleteByTemplateId(tpl.getId());
+ rowRepository.flush();
+ int order = 1;
+ for (TemplateRowVo rowVo : rows) {
+ TemplateRow row = new TemplateRow();
+ row.setTemplateId(tpl.getId());
+ row.setSortOrder(order++);
+ row.setRowType(rowVo.getRowType());
+ row.setName(rowVo.getName());
+ row.setRemark(rowVo.getRemark());
+ row.setBand(rowVo.getBand());
+ row.setStartTime(rowVo.getStart());
+ row.setEndTime(rowVo.getEnd());
+ rowRepository.save(row);
+ }
+ log.info("[saveTemplate] schId={}, id={}, {}·{}·年级[{}], enabled={}, 行 {} 条",
+ schId, tpl.getId(), termName(term), name, gradeName, enabled, rows.size());
+ return tpl.getId();
+ }
+
+ @Override
+ @Transactional
+ public void deleteTemplate(Integer templateId) {
+ Integer schId = currentSchId();
+ PeriodTemplate tpl = templateRepository.findById(templateId)
+ .filter(t -> Objects.equals(t.getSchId(), schId))
+ .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "模板不存在: " + templateId));
+ rowRepository.deleteByTemplateId(tpl.getId());
+ templateRepository.delete(tpl);
+ log.info("[deleteTemplate] schId={}, id={}, {}·{}", schId, tpl.getId(), tpl.getGradeName(), tpl.getName());
+ }
+
+ // ============================ 校验与辅助 ============================
+
+ /**
+ * 行校验:类型合法、时间 HH:mm 且结束晚于开始、节次数/总行数受限,
+ * 且节次行的所属时段(band)须为有效时段且处于启用状态——时段不再按开始时间反推,
+ * 每个启用时段至少保留一条节次;空白行同样落在时段里(band 缺省时按相邻行补齐);
+ * 返回规整后的行。
+ */
+ private List validateRows(List rows, String enabledBands) {
+ if (rows == null) {
+ rows = new ArrayList<>();
+ }
+ if (rows.isEmpty()) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "模板至少需要一行节次");
+ }
+ if (rows.size() > MAX_ROWS) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "模板行数不得超过 " + MAX_ROWS);
+ }
+ int periods = 0;
+ // 每个时段承载的行数(节次与空白行都算):时段可以只剩一条空白行,但不能一行都没有
+ int[] bandRows = new int[BAND_COUNT];
+ String lastBand = null;
+ for (int i = 0; i < rows.size(); i++) {
+ TemplateRowVo row = rows.get(i);
+ String type = TYPE_PERIOD.equals(row.getRowType()) || TYPE_BREAK.equals(row.getRowType())
+ ? row.getRowType() : null;
+ if (type == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "第 " + (i + 1) + " 行类型不合法");
+ }
+ int band = bandIndexOfKey(row.getBand());
+ if (band >= 0 && enabledBands.charAt(band) == '0') {
+ // 落在停用时段的行:节次报错,空白行按相邻行补齐
+ if (TYPE_PERIOD.equals(type)) {
+ throw new BizException(ErrorCode.PARAM_ERROR,
+ "第 " + (i + 1) + " 行节次属于已停用的「" + BAND_LABELS[band] + "」时段,请调整时段或先启用该时段");
+ }
+ band = -1;
+ }
+ if (band < 0) {
+ // 缺时段/停在停用时段:沿用相邻行所属的启用时段,兜底首个启用时段
+ band = firstEnabledBand(enabledBands, lastBand, rows, i);
+ }
+ row.setBand(BAND_KEYS[band]);
+ lastBand = BAND_KEYS[band];
+ bandRows[band]++;
+
+ if (TYPE_PERIOD.equals(type)) {
+ periods++;
+ // 节次名称可自定义,留空由前端显示“第X节”;备注仅空白行可填
+ row.setName(trimName(row.getName(), i));
+ row.setRemark(null);
+ } else {
+ // 空白行名称可自定义,缺省“课间”;备注仅空白行可填
+ String name = trimName(row.getName(), i);
+ row.setName(name != null ? name : "课间");
+ row.setRemark(trimRemark(row.getRemark(), i));
+ }
+ if (!isTime(row.getStart()) || !isTime(row.getEnd())) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "第 " + (i + 1) + " 行时间格式应为 HH:mm");
+ }
+ if (toMinutes(row.getEnd()) <= toMinutes(row.getStart())) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "第 " + (i + 1) + " 行结束时间须晚于开始时间");
+ }
+ }
+ if (periods > MAX_PERIODS) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "节次数不得超过 " + MAX_PERIODS);
+ }
+ if (periods == 0) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "模板至少需要一节教学节次");
+ }
+ // 启用哪些时段就维护哪些时段的行:每个启用时段至少一行(节次或空白行)
+ for (int i = 0; i < BAND_COUNT; i++) {
+ if (enabledBands.charAt(i) == '1' && bandRows[i] == 0) {
+ throw new BizException(ErrorCode.PARAM_ERROR,
+ "「" + BAND_LABELS[i] + "」时段至少需要一行(节次或空白行),不需要该时段请先停用");
+ }
+ }
+ return rows;
+ }
+
+ /**
+ * 未指定(或已停用)时段时的兜底归属:优先上一行所属时段,其次下一行所属时段,
+ * 都没有则取首个启用时段。
+ */
+ private int firstEnabledBand(String enabledBands, String lastBand, List rows, int at) {
+ int last = bandIndexOfKey(lastBand);
+ if (last >= 0 && enabledBands.charAt(last) == '1') {
+ return last;
+ }
+ for (int i = at + 1; i < rows.size(); i++) {
+ int next = bandIndexOfKey(rows.get(i).getBand());
+ if (next >= 0 && enabledBands.charAt(next) == '1') {
+ return next;
+ }
+ }
+ for (int i = 0; i < BAND_COUNT; i++) {
+ if (enabledBands.charAt(i) == '1') {
+ return i;
+ }
+ }
+ throw new BizException(ErrorCode.PARAM_ERROR, "请至少启用一个时段");
+ }
+
+ /** 时段键(es/am/noon/pm/ev)转下标;非有效键返回 -1 */
+ private int bandIndexOfKey(String band) {
+ if (!StringUtils.hasText(band)) {
+ return -1;
+ }
+ String v = band.trim();
+ for (int i = 0; i < BAND_KEYS.length; i++) {
+ if (BAND_KEYS[i].equalsIgnoreCase(v)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ /** 教学日串规整:空=7 天全开;须为 7 位 0/1 */
+ private String normalizeTeachDays(String teachDays) {
+ if (!StringUtils.hasText(teachDays)) {
+ return "1111111";
+ }
+ if (!teachDays.matches("^[01]{7}$")) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "教学日格式不合法");
+ }
+ return teachDays;
+ }
+
+ /** 行名称规整:去空白,超长报错,空返回 null */
+ private String trimName(String name, int rowNo) {
+ if (!StringUtils.hasText(name)) {
+ return null;
+ }
+ String v = name.trim();
+ if (v.length() > 32) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "第 " + (rowNo + 1) + " 行名称过长:最多 32 字");
+ }
+ return v;
+ }
+
+ /** 行备注规整:去空白,超长报错,空返回 null */
+ private String trimRemark(String remark, int rowNo) {
+ if (!StringUtils.hasText(remark)) {
+ return null;
+ }
+ String v = remark.trim();
+ if (v.length() > 200) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "第 " + (rowNo + 1) + " 行备注过长:最多 200 字");
+ }
+ return v;
+ }
+
+ /** 学期规整:0=全年 1=上学期 2=下学期,其余(含空)按上学期处理 */
+ private Integer normalizeTerm(Integer term) {
+ if (Integer.valueOf(0).equals(term)) {
+ return 0;
+ }
+ return Integer.valueOf(2).equals(term) ? 2 : 1;
+ }
+
+ /** 学期显示名(日志/报错用) */
+ private String termName(Integer term) {
+ Integer t = normalizeTerm(term);
+ if (t == 0) {
+ return "全年";
+ }
+ return t == 2 ? "下学期" : "上学期";
+ }
+
+ /** 学期是否重叠:全年(0)与任意学期重叠 */
+ private boolean termOverlaps(Integer a, Integer b) {
+ return a == null || b == null || a == 0 || b == 0 || a.equals(b);
+ }
+
+ /** 年级多选规整:逐个去空白、去重、丢弃空项,逗号拼接;至少一个且总长受限 */
+ private String normalizeGrades(List gradeNames) {
+ if (gradeNames == null || gradeNames.isEmpty()) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "请至少选择一个适用年级");
+ }
+ LinkedHashSet grades = new LinkedHashSet<>();
+ for (String raw : gradeNames) {
+ if (!StringUtils.hasText(raw)) {
+ continue;
+ }
+ String g = raw.trim();
+ if (g.length() > 16) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "年级名称不能超过 16 字: " + g);
+ }
+ grades.add(g);
+ }
+ if (grades.isEmpty()) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "请至少选择一个适用年级");
+ }
+ String joined = String.join(",", grades);
+ if (joined.length() > MAX_GRADES_LENGTH) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "适用年级过多");
+ }
+ return joined;
+ }
+
+ /** 实体 grade_name 逗号串拆为列表 */
+ private List gradeNamesOf(PeriodTemplate tpl) {
+ if (!StringUtils.hasText(tpl.getGradeName())) {
+ return Collections.emptyList();
+ }
+ return Arrays.stream(tpl.getGradeName().split(","))
+ .map(String::trim)
+ .filter(StringUtils::hasText)
+ .collect(Collectors.toList());
+ }
+
+ /** 时长规整:空沿用现值,超范围报错 */
+ private int normalizeMinutes(Integer value, int min, int max, int fallback, String field) {
+ if (value == null) {
+ return fallback;
+ }
+ if (value < min || value > max) {
+ throw new BizException(ErrorCode.PARAM_ERROR, field + "须在 " + min + " ~ " + max + " 分钟之间");
+ }
+ return value;
+ }
+
+ /** 启用时段位串规整:空=沿用 fallback(再空则全启用);须为 5 位 0/1 且至少启用一个时段 */
+ private String normalizeBands(String bands, String fallback) {
+ if (!StringUtils.hasText(bands)) {
+ return sanitizeBands(fallback);
+ }
+ String v = bands.trim();
+ if (!v.matches("^[01]{" + BAND_COUNT + "}$")) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "启用时段格式不合法");
+ }
+ if (v.indexOf('1') < 0) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "请至少启用一个时段");
+ }
+ return v;
+ }
+
+ /** 位串兜底(读库/回退用):非 5 位 0/1 或缺省时按全部启用 */
+ private String sanitizeBands(String bands) {
+ return bands != null && bands.matches("^[01]{" + BAND_COUNT + "}$") ? bands : ALL_BANDS;
+ }
+
+ /** 整数兜底:空取 fallback */
+ private int orDefault(Integer value, int fallback) {
+ return value != null ? value : fallback;
+ }
+
+ /** 本校时间设置行,无则建默认值 */
+ private TimeSetting ensureTimeSetting(Integer schId) {
+ return timeSettingRepository.findBySchId(schId).orElseGet(() -> {
+ TimeSetting created = new TimeSetting();
+ created.setSchId(schId);
+ return timeSettingRepository.save(created);
+ });
+ }
+
+ private TimeSettingVo toTimeVo(TimeSetting setting) {
+ TimeSettingVo vo = new TimeSettingVo();
+ vo.setMorningSelfEnd(setting.getMorningSelfEnd());
+ vo.setForenoonEnd(setting.getForenoonEnd());
+ vo.setAfternoonStart(setting.getAfternoonStart());
+ vo.setEveningStart(setting.getEveningStart());
+ // 旧数据/空值兜底为全部启用
+ String bands = setting.getEnabledBands();
+ vo.setEnabledBands(bands != null && bands.matches("^[01]{" + BAND_COUNT + "}$") ? bands : ALL_BANDS);
+ vo.setPeriodMinutes(setting.getPeriodMinutes());
+ vo.setBreakMinutes(setting.getBreakMinutes());
+ return vo;
+ }
+
+ private PeriodTemplateVo toTemplateVo(PeriodTemplate tpl) {
+ PeriodTemplateVo vo = new PeriodTemplateVo();
+ vo.setId(tpl.getId());
+ vo.setName(tpl.getName());
+ vo.setTerm(tpl.getTerm());
+ vo.setGradeNames(gradeNamesOf(tpl));
+ vo.setEnabled(tpl.getEnabled());
+ vo.setTeachDays(tpl.getTeachDays());
+ vo.setEnabledBands(sanitizeBands(tpl.getEnabledBands()));
+ vo.setPeriodMinutes(orDefault(tpl.getPeriodMinutes(), DEFAULT_PERIOD_MINUTES));
+ vo.setBreakMinutes(orDefault(tpl.getBreakMinutes(), DEFAULT_BREAK_MINUTES));
+ for (TemplateRow row : rowRepository.findByTemplateIdOrderBySortOrderAsc(tpl.getId())) {
+ vo.getRows().add(toRowVo(row));
+ }
+ return vo;
+ }
+
+ private TemplateRowVo toRowVo(TemplateRow row) {
+ TemplateRowVo vo = new TemplateRowVo();
+ vo.setRowType(row.getRowType());
+ vo.setBand(row.getBand());
+ vo.setName(row.getName());
+ vo.setRemark(row.getRemark());
+ vo.setStart(row.getStartTime());
+ vo.setEnd(row.getEndTime());
+ return vo;
+ }
+
+ private Integer currentSchId() {
+ Integer schId = UserContext.getSchId();
+ if (schId == null) {
+ throw new BizException(ErrorCode.FORBIDDEN, "未获取到当前用户学校信息");
+ }
+ return schId;
+ }
+
+ private boolean isTime(String time) {
+ return time != null && TIME_PATTERN.matcher(time).matches();
+ }
+
+ private static int toMinutes(String time) {
+ String[] parts = time.split(":");
+ return Integer.parseInt(parts[0]) * 60 + Integer.parseInt(parts[1]);
+ }
+
+ private String trimmed(String value, String field) {
+ if (!StringUtils.hasText(value)) {
+ throw new BizException(ErrorCode.PARAM_ERROR, field + "不能为空");
+ }
+ return value.trim();
+ }
+
+ private String orNull(String v) {
+ return v == null ? null : v.trim();
+ }
+}
diff --git a/src/main/java/com/yunhai/acasyllabu/service/impl/SysOperationLogService.java b/src/main/java/com/yunhai/acasyllabu/service/impl/SysOperationLogService.java
new file mode 100644
index 0000000..fd6074e
--- /dev/null
+++ b/src/main/java/com/yunhai/acasyllabu/service/impl/SysOperationLogService.java
@@ -0,0 +1,73 @@
+package com.yunhai.acasyllabu.service.impl;
+
+import com.yunhai.acasyllabu.aspect.SysOperationLogAspect;
+import com.yunhai.acasyllabu.entity.SysOperationLog;
+import com.yunhai.acasyllabu.model.BasePageQuery;
+import com.yunhai.acasyllabu.model.BasePageResult;
+import com.yunhai.acasyllabu.repository.SysOperationLogRepository;
+import com.yunhai.acasyllabu.service.ISysOperationLogService;
+import com.yunhai.acasyllabu.utils.PageUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.jpa.domain.Specification;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import javax.persistence.criteria.Predicate;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 系统审计日志业务实现:@Async 异步落库,失败仅记日志不抛出。
+ */
+@Service
+public class SysOperationLogService implements ISysOperationLogService {
+
+ private static final Logger log = LoggerFactory.getLogger(SysOperationLogService.class);
+
+ @Autowired
+ private SysOperationLogRepository repository;
+
+ @Override
+ @Async
+ public void saveAsync(SysOperationLog entity) {
+ // 不加 @Transactional:save() 单条自原子;若外层事务在异常后提交 rollback-only
+ // 会抛 UnexpectedRollbackException 逃出 try/catch,被异步线程兜底处理器当成未捕获异常
+ try {
+ repository.save(entity);
+ } catch (Exception e) {
+ log.warn("[saveAsync] 审计日志落库失败, uri={}", entity == null ? null : entity.getRequestUri());
+ }
+ }
+
+ @Override
+ public BasePageResult page(BasePageQuery query, String operationType, Integer statusCode) {
+ Specification spec = (root, cq, cb) -> {
+ List ps = new ArrayList<>();
+ if (StringUtils.hasText(operationType)) {
+ ps.add(cb.equal(root.get("operationType"), operationType));
+ }
+ if (statusCode != null) {
+ ps.add(cb.equal(root.get("statusCode"), statusCode));
+ }
+ if (StringUtils.hasText(query.getKeyword())) {
+ String kw = "%" + query.getKeyword().trim() + "%";
+ Predicate uriLike = cb.like(root.get("requestUri"), kw);
+ Predicate nameLike = cb.like(root.get("userName"), kw);
+ Predicate methodLike = cb.like(root.get("classMethod"), kw);
+ ps.add(cb.or(uriLike, nameLike, methodLike));
+ }
+ return cb.and(ps.toArray(new Predicate[0]));
+ };
+ Pageable pageable = PageUtils.toPageable(query, Sort.by(Sort.Direction.DESC, "createTime"));
+ Page page = repository.findAll(spec, pageable);
+ page.getContent().forEach(e -> e.setResponseData(null));
+ return PageUtils.toResult(page);
+ }
+}
diff --git a/src/main/java/com/yunhai/acasyllabu/service/impl/TimetableService.java b/src/main/java/com/yunhai/acasyllabu/service/impl/TimetableService.java
new file mode 100644
index 0000000..590ea95
--- /dev/null
+++ b/src/main/java/com/yunhai/acasyllabu/service/impl/TimetableService.java
@@ -0,0 +1,615 @@
+package com.yunhai.acasyllabu.service.impl;
+
+import com.yunhai.acasyllabu.entity.Timetable;
+import com.yunhai.acasyllabu.entity.TimetableSlot;
+import com.yunhai.acasyllabu.entity.UpFile;
+import com.yunhai.acasyllabu.model.bsch.ClassTreeNodeVo;
+import com.yunhai.acasyllabu.model.bsch.NowYearTermVo;
+import com.yunhai.acasyllabu.model.bsch.SubjectVo;
+import com.yunhai.acasyllabu.model.syllabus.MyClassVo;
+import com.yunhai.acasyllabu.model.syllabus.MyTimetableVo;
+import com.yunhai.acasyllabu.model.syllabus.OapiTimetableClassVo;
+import com.yunhai.acasyllabu.model.syllabus.TimetableSaveRequest;
+import com.yunhai.acasyllabu.model.syllabus.TimetableSlotVo;
+import com.yunhai.acasyllabu.model.syllabus.TimetableVo;
+import com.yunhai.acasyllabu.repository.TimetableRepository;
+import com.yunhai.acasyllabu.repository.TimetableSlotRepository;
+import com.yunhai.acasyllabu.repository.UpFileRepository;
+import com.yunhai.acasyllabu.service.IClassService;
+import com.yunhai.acasyllabu.service.IFileService;
+import com.yunhai.acasyllabu.service.ITimetableService;
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import com.yunhai.acasyllabu.utils.UserContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * 课程表业务实现。
+ * 课表按学年学期维护(每班每学年学期一条):读写都定位到班级服务当前学年学期,
+ * 历史学年学期数据保留但不再返回;跨学年/学期后首查为空骨架,重新编排即新学期课表。
+ * 保存校验:班级属于本校(班级服务);时间为 HH:mm 且结束晚于开始;
+ * 学科名须在本校学科列表内(学科服务不可用时放行并告警)。
+ */
+@Service
+public class TimetableService implements ITimetableService {
+
+ private static final Logger log = LoggerFactory.getLogger(TimetableService.class);
+
+ private static final Pattern TIME_PATTERN = Pattern.compile("^([01]\\d|2[0-3]):[0-5]\\d$");
+
+ /** 节次上限(前端默认 10,允许增删) */
+ private static final int MAX_SLOTS = 30;
+
+ /** 校领导角色(UserRole.SCH_LEADER_USER) */
+ private static final int ROLE_SCH_LEADER = 2;
+
+ /** 管理员角色(UserRole.AREAADMIN) */
+ private static final int ROLE_ADMIN = 7;
+
+ /** 学生角色(UserRole.STU_USER) */
+ private static final int ROLE_STUDENT = 3;
+
+ @Autowired
+ private TimetableRepository timetableRepository;
+
+ @Autowired
+ private TimetableSlotRepository slotRepository;
+
+ @Autowired
+ private UpFileRepository upFileRepository;
+
+ @Autowired
+ private IClassService classService;
+
+ @Autowired
+ private IFileService fileService;
+
+ // ============================ 查询 ============================
+
+ @Override
+ public TimetableVo get(Integer classId) {
+ return get(classId, currentSchId());
+ }
+
+ @Override
+ public TimetableVo get(Integer classId, Integer schId) {
+ requireClassId(classId);
+ NowYearTermVo yearTerm = currentYearTerm();
+ Timetable timetable = timetableRepository
+ .findBySchIdAndClassIdAndYearAndTerm(schId, classId, yearTerm.getYear(), yearTerm.getTerm())
+ .orElse(null);
+ TimetableVo vo = new TimetableVo();
+ vo.setClassId(classId);
+ vo.setYear(yearTerm.getYear());
+ vo.setTerm(yearTerm.getTerm());
+ if (timetable == null) {
+ // 本学年学期未建过课表:返回班级骨架,前端补默认节次
+ fillClassInfo(vo, classId, schId);
+ return vo;
+ }
+ vo.setClassName(timetable.getClassName());
+ vo.setGradeName(timetable.getGradeName());
+ vo.setTeacherId(timetable.getTeacherId());
+ vo.setPhotoFileId(timetable.getPhotoFileId());
+ if (timetable.getPhotoFileId() != null) {
+ upFileRepository.findById(timetable.getPhotoFileId())
+ .ifPresent(f -> {
+ vo.setPhotoName(f.getFileTrueName());
+ vo.setPhotoSize(f.getFileSize());
+ });
+ }
+ for (TimetableSlot slot : slotRepository.findByTimetableIdOrderBySortOrderAsc(timetable.getId())) {
+ TimetableSlotVo slotVo = new TimetableSlotVo();
+ slotVo.setSlotNo(slot.getSortOrder());
+ slotVo.setStart(slot.getStartTime());
+ slotVo.setEnd(slot.getEndTime());
+ slotVo.setSubjects(Arrays.asList(
+ orEmpty(slot.getDay1()), orEmpty(slot.getDay2()), orEmpty(slot.getDay3()),
+ orEmpty(slot.getDay4()), orEmpty(slot.getDay5()), orEmpty(slot.getDay6()),
+ orEmpty(slot.getDay7())));
+ vo.getSlots().add(slotVo);
+ }
+ return vo;
+ }
+
+ // ============================ 保存(电子版) ============================
+
+ @Override
+ @Transactional
+ public Integer save(TimetableSaveRequest request) {
+ if (request == null || request.getClassId() == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "班级不能为空");
+ }
+ Integer classId = request.getClassId();
+ List slots = request.getSlots() == null ? new ArrayList<>() : request.getSlots();
+ if (slots.size() > MAX_SLOTS) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "节次数不得超过 " + MAX_SLOTS);
+ }
+ validateSlots(slots);
+ Integer schId = currentSchId();
+ String className = classService.classNameOf(schId, classId);
+ if (!StringUtils.hasText(className)) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "班级不存在或不属于当前学校");
+ }
+
+ NowYearTermVo yearTerm = currentYearTerm();
+ Timetable timetable = timetableRepository
+ .findBySchIdAndClassIdAndYearAndTerm(schId, classId, yearTerm.getYear(), yearTerm.getTerm())
+ .orElseGet(Timetable::new);
+ timetable.setSchId(schId);
+ timetable.setYear(yearTerm.getYear());
+ timetable.setTerm(yearTerm.getTerm());
+ timetable.setClassId(classId);
+ timetable.setClassName(className);
+ timetable.setGradeName(classService.gradeNameOf(schId, classId));
+ timetable.setTeacherId(UserContext.getUserId());
+ timetable = timetableRepository.save(timetable);
+
+ slotRepository.deleteByTimetableId(timetable.getId());
+ slotRepository.flush();
+ int order = 1;
+ for (TimetableSlotVo slotVo : slots) {
+ TimetableSlot slot = new TimetableSlot();
+ slot.setTimetableId(timetable.getId());
+ slot.setSortOrder(order++);
+ slot.setStartTime(slotVo.getStart());
+ slot.setEndTime(slotVo.getEnd());
+ List subjects = slotVo.getSubjects();
+ slot.setDay1(at(subjects, 0));
+ slot.setDay2(at(subjects, 1));
+ slot.setDay3(at(subjects, 2));
+ slot.setDay4(at(subjects, 3));
+ slot.setDay5(at(subjects, 4));
+ slot.setDay6(at(subjects, 5));
+ slot.setDay7(at(subjects, 6));
+ slotRepository.save(slot);
+ }
+ log.info("[save] schId={}, classId={}, teacherId={}, 节次 {} 个",
+ currentSchId(), classId, UserContext.getUserId(), slots.size());
+ return timetable.getId();
+ }
+
+ // ============================ 照片版 ============================
+
+ @Override
+ @Transactional
+ public void savePhoto(Integer classId, Integer fileId) {
+ requireClassId(classId);
+ if (fileId == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "附件不能为空");
+ }
+ UpFile file = upFileRepository.findById(fileId)
+ .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "附件不存在: " + fileId));
+ Timetable timetable = requireTimetable(classId);
+ Integer oldFileId = timetable.getPhotoFileId();
+ timetable.setPhotoFileId(fileId);
+ timetable.setTeacherId(UserContext.getUserId());
+ timetableRepository.save(timetable);
+ file.setTopicId(timetable.getId());
+ upFileRepository.save(file);
+ if (oldFileId != null && !oldFileId.equals(fileId)) {
+ deleteFileQuietly(oldFileId);
+ }
+ log.info("[savePhoto] schId={}, classId={}, fileId={}(替换旧附件 {})",
+ currentSchId(), classId, fileId, oldFileId);
+ }
+
+ @Override
+ @Transactional
+ public void removePhoto(Integer classId) {
+ requireClassId(classId);
+ Timetable timetable = currentTermTimetable(classId).orElse(null);
+ if (timetable == null || timetable.getPhotoFileId() == null) {
+ return;
+ }
+ Integer oldFileId = timetable.getPhotoFileId();
+ timetable.setPhotoFileId(null);
+ timetableRepository.save(timetable);
+ deleteFileQuietly(oldFileId);
+ log.info("[removePhoto] schId={}, classId={}, fileId={}", currentSchId(), classId, oldFileId);
+ }
+
+ // ============================ 我的课表 ============================
+
+ @Override
+ public List myClasses() {
+ return myClasses(UserContext.getUserId(), UserContext.getRoleId(), currentSchId());
+ }
+
+ @Override
+ public List myClasses(Integer userId, Integer roleId, Integer schId) {
+ List