diff --git a/docs/后端重构计划-活动信息发布.md b/docs/后端重构计划-活动信息发布.md index 282d358..3ae6cfe 100644 --- a/docs/后端重构计划-活动信息发布.md +++ b/docs/后端重构计划-活动信息发布.md @@ -63,7 +63,7 @@ com.yunhai.familyactivity ├── entity/ │ ├── BaseEntity / SysDict / UpFile / ContactsGroup / ContactsGroupUser # 保留 │ ├── Activity # 新建:活动主表 -│ ├── ActivityRead # 新建:已读记录(班级粒度) +│ ├── ActivityRead # 新建:已读记录(学生粒度) │ ├── ActivitySignup # 新建:回执/报名记录 │ └── OperationLogRecord # 新建:操作日志表(参考 UserclassService) ├── enums/ # 新建:ActivityStatusEnum、ReceiptModeEnum、SignupStatusEnum @@ -175,11 +175,14 @@ com.yunhai.familyactivity | --- | --- | --- | | id | int 自增 | | | activityId | int | 活动 id | -| classId | varchar(50) | **班级粒度已读**(前端关键约定 4:`classRead`) | -| account | varchar(50) | 操作账号(家长=学生账号) | +| classId | varchar(50) | 班级 id | +| studentId | int | 阅读学生ID(家长端以学生身份打开;学生档案不可得时为 null) | | readTime | datetime | 阅读时间 | -唯一约束 `(activity_id, class_id)`;应收/已读人数按班级 `studentCount` 累加(班级与学生数取自 `ClassService`/基础平台接口)。 +唯一约束 `(activity_id, class_id, student_id)`(`uk_activity_class_student`;2026-09-23 由班级粒度 `(activity_id, class_id)` +迁移而来,SQL 见 `docs/sql/20260923_activity_read_per_student.sql`,`ddl-auto=update` 不会自动改唯一键,各环境需手动执行)。 +**已读人数 = 接收班级内的已读记录条数**(即实际打开过活动的家长数,每个家长一条,重复打开幂等); +班级「读/未读」= 该班是否存在任一已读记录;应读人数(allowCount)仍为接收班级学生数之和(取自 `ClassService`/基础平台接口)。 #### `ActivitySignup`(回执/报名记录,表 `fa_activity_signup`) @@ -210,8 +213,8 @@ com.yunhai.familyactivity 1. **发布**(Create):draft=true 存草稿;否则置已发;`targetClassIds()` 统一解析 `receiveList`(`"ALL"`=全校班级);发布时按班级×学生**预生成回执记录**(receiptMode≠none 时,status=none),保证「应收人数」「未回执名单」可查。 2. **状态机**:`actStatus` 推导规则 —— status=草稿→`draft`;now < signupDeadline→`signup`;signupDeadline ≤ now < endTime→`closed`;now ≥ endTime→`finished`(receiptMode=none 时无报名态语义,仅展示)。 -3. **进度统计**(`ActivityProgress`):`should`=应回执人数;`signed/confirmed/canceled` 按 signup 表 status 计数;`unsigned=should-signed-confirmed-canceled`;`usedQuota`=ΣjoinCount(canceled 不计);`full = quota>0 && usedQuota≥quota`;`signupRate`;`readCount`=已读人数、`readRate`=阅读率%(readCount/should 取整,仅 `/Signups` 汇总填充,口径同详情 `readnumcount`)。`receiptMode=none` 或 `draft` 时 `progress=null`。 -4. **已读**:`Detail(type=1)` / `MySignup` 标读(家长端打开即确认模式:`receiptMode=confirm` 且状态为 none 时同步置 `confirmed`);`InitializeRed` 一键全部已读;`ReadDetail` 按年级→班级分组返回读/未读。 +3. **进度统计**(`ActivityProgress`):`should`=应回执人数;`signed/confirmed/canceled` 按 signup 表 status 计数;`unsigned=should-signed-confirmed-canceled`;`usedQuota`=ΣjoinCount(canceled 不计);`full = quota>0 && usedQuota≥quota`;`signupRate`;`readCount`=已读人数(学生粒度:接收班级内已读记录条数=实际阅读家长数,仅 `/Signups` 汇总填充,口径同列表 `readCount` 与详情 `readnumcount`)、`readRate`=阅读率%(readCount/should 取整)。`receiptMode=none` 或 `draft` 时 `progress=null`。 +4. **已读**:学生粒度——`Detail(type=1)` / `MySignup` 标读(每个家长账号各记一条,重复打开幂等;打开即确认模式 `receiptMode=confirm` 且状态为 none 时同步置 `confirmed`);`InitializeRed` 一键全部已读(为当前学生逐活动补记录);家长端列表/收件箱的已读标志按「本人学号已读」判定;`ReadDetail` 按年级→班级分组返回读/未读(该班任一学生已读即记为读)。 5. **报名**(Signup/CancelSignup):校验活动状态(signup 中)、截止时间、名额(quota>0 时 usedQuota+joinCount≤quota)、随行(allowAccompany);成功/失败回执业务码。 6. **管理动作**:Edit(含 sourceId 复用)、Delete(批量 + 级联删 read/signup)、UserPull 撤回、SetTopmost、ReceiveClasses 补发(新班级追加并补回执记录)、Expedite/Remind 催办(本期日志占位)、ExportSignups 导出 CSV(easyexcel 已在 pom,CSV 直接拼文本返回)。 7. **家长端**:`MyActivities` 分桶(todo 待回执/signed 已报名/finished 已结束/all)+ 计数;`MySignup` 活动详情+我的回执(同时标读/自动确认)。 diff --git a/src/main/java/com/yunhai/familyactivity/entity/ActivityRead.java b/src/main/java/com/yunhai/familyactivity/entity/ActivityRead.java index 283a21f..e8cdcef 100644 --- a/src/main/java/com/yunhai/familyactivity/entity/ActivityRead.java +++ b/src/main/java/com/yunhai/familyactivity/entity/ActivityRead.java @@ -11,14 +11,15 @@ import java.time.LocalDateTime; /** * 活动已读记录(fa_activity_read)。 - * 已读按「班级」粒度(前端 classRead 约定):应收/已读人数按班级 studentCount 累加; - * 同一活动同一班级只有一条记录,由唯一约束兜底。 + * 已读按「学生(家庭)」粒度:每个家长账号打开一次记一条(activity+class+student 唯一), + * 已读人数 = 接收班级内的已读记录条数;班级的「读/未读」= 该班是否存在任一已读记录。 */ @Getter @Setter @Entity @Table(name = "fa_activity_read", - uniqueConstraints = @UniqueConstraint(columnNames = {"activity_id", "class_id"})) + uniqueConstraints = @UniqueConstraint(name = "uk_activity_class_student", + columnNames = {"activity_id", "class_id", "student_id"})) public class ActivityRead extends BaseEntity { /** 活动ID */ @@ -29,7 +30,7 @@ public class ActivityRead extends BaseEntity { @Column(name = "class_id", length = 50, nullable = false) private String classId; - /** 首读学生ID(家长端以学生身份打开,存学生ID) */ + /** 阅读学生ID(家长端以学生身份打开;学生档案不可得时为 null,此时退化为班级粒度) */ @Column(name = "student_id") private Integer studentId; diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/ActivityProgress.java b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityProgress.java index 5dd32dc..d4104f6 100644 --- a/src/main/java/com/yunhai/familyactivity/model/activity/ActivityProgress.java +++ b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityProgress.java @@ -43,7 +43,7 @@ public class ActivityProgress { /** 待处理回执(名额超额/待定),统计用,列表场景恒为 0 */ private int pendingReview; - /** 已读人数(fa_activity_read 班级粒度投影到学生,与详情 readnumcount 同口径) */ + /** 已读人数(学生粒度:接收班级内已读记录条数,即实际打开过的家长数,与列表 readCount / 详情 readnumcount 同口径) */ private int readCount; /** 阅读率(readCount / should,四舍五入取整 %) */ diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/SignupBrief.java b/src/main/java/com/yunhai/familyactivity/model/activity/SignupBrief.java index b600e4d..87cbebf 100644 --- a/src/main/java/com/yunhai/familyactivity/model/activity/SignupBrief.java +++ b/src/main/java/com/yunhai/familyactivity/model/activity/SignupBrief.java @@ -40,7 +40,7 @@ public class SignupBrief { /** 是否已回执/已报名 */ private boolean hasReceipt; - /** 阅读状态:该生所在班是否已读(fa_activity_read 班级粒度投影) */ + /** 阅读状态:该生本人是否已打开过活动(fa_activity_read 学生粒度) */ private boolean read; /** 备注(如过敏史、随行人数说明) */ diff --git a/src/main/java/com/yunhai/familyactivity/repository/ActivityReadRepository.java b/src/main/java/com/yunhai/familyactivity/repository/ActivityReadRepository.java index c4d1ca9..0224b31 100644 --- a/src/main/java/com/yunhai/familyactivity/repository/ActivityReadRepository.java +++ b/src/main/java/com/yunhai/familyactivity/repository/ActivityReadRepository.java @@ -8,12 +8,17 @@ import java.util.List; import java.util.Optional; /** - * 活动已读记录仓库(班级粒度)。 + * 活动已读记录仓库(学生粒度:activity + class + student 唯一)。 */ public interface ActivityReadRepository extends JpaRepository { Optional findByActivityIdAndClassId(Integer activityId, String classId); + /** 学生粒度幂等判定(studentId 为 null 时由调用方退化为班级粒度判定) */ + boolean existsByActivityIdAndClassIdAndStudentId(Integer activityId, String classId, Integer studentId); + + Optional findByActivityIdAndClassIdAndStudentId(Integer activityId, String classId, Integer studentId); + List findByActivityId(Integer activityId); List findByActivityIdIn(Collection activityIds); diff --git a/src/main/java/com/yunhai/familyactivity/service/impl/ActivityService.java b/src/main/java/com/yunhai/familyactivity/service/impl/ActivityService.java index 74e7f64..b37dd30 100644 --- a/src/main/java/com/yunhai/familyactivity/service/impl/ActivityService.java +++ b/src/main/java/com/yunhai/familyactivity/service/impl/ActivityService.java @@ -504,12 +504,14 @@ public class ActivityService implements IActivityService { .collect(Collectors.toList()); Map classes = schoolClasses(schId); + List targetIds = targetClassIds(activity, classes); + Set targetSet = new LinkedHashSet<>(targetIds); - // 已读统计:fa_activity_read 为班级粒度,投影到学生数并与接收班级求交(与详情 readnumcount 同口径); - // readClassIds 供名单行按"该生所在班是否已读"填充 read(与 SignupDetail 的 findRead 判定一致) + // 已读统计(学生粒度):readCount = 接收班级内的已读记录条数(即实际打开过的家长数), + // readRate = readCount/should 取整;readKeys 供名单行按「该生本人是否已读」填充 read(与 SignupDetail 判定一致) List reads = activityReadRepository.findByActivityId(serid); - Set readClassIds = reads.stream() - .map(ActivityRead::getClassId) + Set readKeys = reads.stream() + .map(r -> readKey(r.getClassId(), r.getStudentId())) .collect(Collectors.toSet()); Map> byClass = filtered.stream() @@ -518,17 +520,17 @@ public class ActivityService implements IActivityService { List groups = byClass.entrySet().stream() .map(e -> new SignupGroup(e.getKey(), e.getValue().stream() - .map(r -> toBrief(r, readClassIds)).collect(Collectors.toList()))) + .map(r -> toBrief(r, readKeys)).collect(Collectors.toList()))) .collect(Collectors.toList()); - Map> byClassStudents = studentsByClass(schId, targetClassIds(activity, classes)); + Map> byClassStudents = studentsByClass(schId, targetIds); int should = byClassStudents.values().stream().mapToInt(List::size).sum(); if (should == 0 && !records.isEmpty()) { should = records.size(); // 班级数据不可达时降级为回执表实有人数 } - int readCount = reads.stream() - .mapToInt(r -> byClassStudents.getOrDefault(parseSafe(r.getClassId()), Collections.emptyList()).size()) - .sum(); + int readCount = (int) reads.stream() + .filter(r -> targetSet.contains(parseSafe(r.getClassId()))) + .count(); SignupsResultVo vo = new SignupsResultVo(); vo.setActivityId(activity.getId()); @@ -556,7 +558,11 @@ public class ActivityService implements IActivityService { ActivitySignup record = activitySignupRepository.findById(signupId) .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "回执记录不存在")); Activity activity = requireActivity(record.getActivityId()); - ActivityRead read = findRead(record.getActivityId(), record.getClassId()); + // 已读按学生粒度:该生本人是否打开过(历史班级粒度数据不误标同班其他学生) + ActivityRead read = record.getStudentId() == null ? null + : activityReadRepository + .findByActivityIdAndClassIdAndStudentId(record.getActivityId(), record.getClassId(), record.getStudentId()) + .orElse(null); SignupDetailVo vo = new SignupDetailVo(); vo.setSignupId(record.getId()); @@ -908,9 +914,10 @@ public class ActivityService implements IActivityService { List targetIds = targetClassIds(a, classes); int allowCount = targetIds.stream().mapToInt(t -> classSize.getOrDefault(t, 0)).sum(); Set targetSet = new LinkedHashSet<>(targetIds); - int readCount = readMap.getOrDefault(a.getId(), Collections.emptyList()).stream() - .mapToInt(r -> targetSet.contains(parseSafe(r.getClassId())) - ? classSize.getOrDefault(parseSafe(r.getClassId()), 0) : 0).sum(); + // 已读人数 = 接收班级内的已读记录条数(学生粒度),与列表/详情同口径 + int readCount = (int) readMap.getOrDefault(a.getId(), Collections.emptyList()).stream() + .filter(r -> targetSet.contains(parseSafe(r.getClassId()))) + .count(); fillProgressItem(item, computeProgress(a, records, allowCount), readCount, allowCount); myItems.add(item); } @@ -1165,19 +1172,24 @@ public class ActivityService implements IActivityService { if (pc.myClassId == null || candidates.isEmpty()) { return Collections.emptySet(); } + Integer myStudentId = studentIdOf(pc); List ids = candidates.stream().map(Activity::getId).collect(Collectors.toList()); - // 已读是班级粒度(fa_activity_read 唯一键为 activity+class,studentId 只是首读学生), - // 不能按学生过滤,否则同班其他学生永远「未读」 + // 已读按学生粒度:本人(学生身份)打开过才算已读;studentId 缺失的存量数据退化为班级粒度 return activityReadRepository .findByActivityIdIn(ids).stream() .filter(r -> pc.myClassId.equals(r.getClassId())) + .filter(r -> myStudentId == null || myStudentId.equals(r.getStudentId())) .map(ActivityRead::getActivityId) .collect(Collectors.toSet()); } - /** 标读(班级粒度,幂等) */ + /** 标读(学生粒度,幂等:同一家长重复打开只记一条;学生档案不可得时退化为班级粒度) */ private void markRead(Integer activityId, String classId, Integer studentId) { - if (activityReadRepository.findByActivityIdAndClassId(activityId, classId).isPresent()) { + if (studentId == null) { + if (activityReadRepository.findByActivityIdAndClassId(activityId, classId).isPresent()) { + return; + } + } else if (activityReadRepository.existsByActivityIdAndClassIdAndStudentId(activityId, classId, studentId)) { return; } ActivityRead read = new ActivityRead(); @@ -1189,13 +1201,6 @@ public class ActivityService implements IActivityService { activityReadRepository.save(read); } - private ActivityRead findRead(Integer activityId, String classId) { - if (classId == null) { - return null; - } - return activityReadRepository.findByActivityIdAndClassId(activityId, classId).orElse(null); - } - /** confirm 模式:家长打开活动即自动确认(未回执 → 已确认) */ private void autoConfirmIfNeeded(Activity activity, Integer studentId) { if (!ReceiptModeEnum.CONFIRM.getCode().equalsIgnoreCase(activity.getReceiptMode())) { @@ -1481,10 +1486,10 @@ public class ActivityService implements IActivityService { List targetIds = targetClassIds(a, classes); Set targetSet = new LinkedHashSet<>(targetIds); int allowCount = targetIds.stream().mapToInt(t -> classSize.getOrDefault(t, 0)).sum(); - // 已读只按目标班级累计(收窄接收班级后的残留已读行不计入) - int readCount = readMap.getOrDefault(a.getId(), Collections.emptyList()).stream() - .mapToInt(r -> targetSet.contains(parseSafe(r.getClassId())) - ? classSize.getOrDefault(parseSafe(r.getClassId()), 0) : 0).sum(); + // 已读人数 = 接收班级内的已读记录条数(学生粒度,一人一条;收窄接收班级后的出界记录不计入) + int readCount = (int) readMap.getOrDefault(a.getId(), Collections.emptyList()).stream() + .filter(r -> targetSet.contains(parseSafe(r.getClassId()))) + .count(); vo.setAllowCount(allowCount); vo.setReadCount(readCount); // 管理端列表不填已读标志:fa_activity_read 是班级粒度且 studentId 只记首读学生, @@ -1593,9 +1598,10 @@ public class ActivityService implements IActivityService { List targetIds = targetClassIds(a, classes); Map> byClass = studentsByClass(schId, targetIds); int allowCount = byClass.values().stream().mapToInt(List::size).sum(); - int readCount = activityReadRepository.findByActivityId(a.getId()).stream() - .mapToInt(r -> byClass.getOrDefault(parseSafe(r.getClassId()), Collections.emptyList()).size()) - .sum(); + // 已读人数 = 接收班级内的已读记录条数(学生粒度),与列表 readCount 同口径 + int readCount = (int) activityReadRepository.findByActivityId(a.getId()).stream() + .filter(r -> byClass.containsKey(parseSafe(r.getClassId()))) + .count(); vo.setNumcount(allowCount); vo.setReadnumcount(readCount); if (a.isPublished() && !ReceiptModeEnum.NONE.getCode().equalsIgnoreCase(a.getReceiptMode())) { @@ -1606,7 +1612,7 @@ public class ActivityService implements IActivityService { return vo; } - private SignupBrief toBrief(ActivitySignup r, Set readClassIds) { + private SignupBrief toBrief(ActivitySignup r, Set readKeys) { SignupBrief brief = new SignupBrief(); brief.setSignupId(r.getId()); brief.setStudentId(r.getStudentId()); @@ -1620,11 +1626,16 @@ public class ActivityService implements IActivityService { brief.setSignedAt(fmt(r.getSignedAt())); boolean receipt = !SignupStatusEnum.NONE.getCode().equalsIgnoreCase(r.getStatus()); brief.setHasReceipt(receipt); - brief.setRead(readClassIds.contains(r.getClassId())); + brief.setRead(readKeys.contains(readKey(r.getClassId(), r.getStudentId()))); brief.setRemark(r.getRemark()); return brief; } + /** 学生粒度已读键:classId:studentId(studentId 为 null 的存量数据按 classId:null 匹配,互不误标) */ + private String readKey(String classId, Integer studentId) { + return classId + ":" + studentId; + } + /** 是否占用名额(已报名/已确认) */ private boolean isOccupying(ActivitySignup r) { return SignupStatusEnum.SIGNED.getCode().equalsIgnoreCase(r.getStatus())