阅读
build / build-push (push) Successful in 1m30s

This commit is contained in:
wanghu
2026-09-23 11:28:00 +08:00
parent 07c409c83c
commit 33398a7f8a
6 changed files with 66 additions and 46 deletions
@@ -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;
@@ -43,7 +43,7 @@ public class ActivityProgress {
/** 待处理回执(名额超额/待定),统计用,列表场景恒为 0 */
private int pendingReview;
/** 已读人数(fa_activity_read 班级粒度投影到学生,与详情 readnumcount 同口径) */
/** 已读人数(学生粒度:接收班级内已读记录条数,即实际打开过的家长数,与列表 readCount / 详情 readnumcount 同口径) */
private int readCount;
/** 阅读率(readCount / should,四舍五入取整 %) */
@@ -40,7 +40,7 @@ public class SignupBrief {
/** 是否已回执/已报名 */
private boolean hasReceipt;
/** 阅读状态:该生所在班是否已读(fa_activity_read 班级粒度投影) */
/** 阅读状态:该生本人是否已打开过活动(fa_activity_read 学生粒度) */
private boolean read;
/** 备注(如过敏史、随行人数说明) */
@@ -8,12 +8,17 @@ import java.util.List;
import java.util.Optional;
/**
* 活动已读记录仓库(班级粒度)。
* 活动已读记录仓库(学生粒度:activity + class + student 唯一)。
*/
public interface ActivityReadRepository extends JpaRepository<ActivityRead, Integer> {
Optional<ActivityRead> findByActivityIdAndClassId(Integer activityId, String classId);
/** 学生粒度幂等判定(studentId 为 null 时由调用方退化为班级粒度判定) */
boolean existsByActivityIdAndClassIdAndStudentId(Integer activityId, String classId, Integer studentId);
Optional<ActivityRead> findByActivityIdAndClassIdAndStudentId(Integer activityId, String classId, Integer studentId);
List<ActivityRead> findByActivityId(Integer activityId);
List<ActivityRead> findByActivityIdIn(Collection<Integer> activityIds);
@@ -504,12 +504,14 @@ public class ActivityService implements IActivityService {
.collect(Collectors.toList());
Map<Integer, SchClassDto> classes = schoolClasses(schId);
List<Integer> targetIds = targetClassIds(activity, classes);
Set<Integer> targetSet = new LinkedHashSet<>(targetIds);
// 已读统计:fa_activity_read 为班级粒度,投影到学生数并与接收班级求交(与详情 readnumcount 同口径);
// readClassIds 供名单行按"该生所在班是否已读"填充 read(与 SignupDetail 的 findRead 判定一致)
// 已读统计(学生粒度):readCount = 接收班级内的已读记录条数(即实际打开过的家长数),
// readRate = readCount/should 取整;readKeys 供名单行按「该生本人是否已读」填充 read(与 SignupDetail 判定一致)
List<ActivityRead> reads = activityReadRepository.findByActivityId(serid);
Set<String> readClassIds = reads.stream()
.map(ActivityRead::getClassId)
Set<String> readKeys = reads.stream()
.map(r -> readKey(r.getClassId(), r.getStudentId()))
.collect(Collectors.toSet());
Map<String, List<ActivitySignup>> byClass = filtered.stream()
@@ -518,17 +520,17 @@ public class ActivityService implements IActivityService {
List<SignupGroup> 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<Integer, List<SchStudentDto>> byClassStudents = studentsByClass(schId, targetClassIds(activity, classes));
Map<Integer, List<SchStudentDto>> 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<Integer> targetIds = targetClassIds(a, classes);
int allowCount = targetIds.stream().mapToInt(t -> classSize.getOrDefault(t, 0)).sum();
Set<Integer> 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<Integer> 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<Integer> targetIds = targetClassIds(a, classes);
Set<Integer> 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<Integer> targetIds = targetClassIds(a, classes);
Map<Integer, List<SchStudentDto>> 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<String> readClassIds) {
private SignupBrief toBrief(ActivitySignup r, Set<String> 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())