阅读
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
@@ -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` 活动详情+我的回执(同时标读/自动确认)。
@@ -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,21 +1172,26 @@ 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 (studentId == null) {
if (activityReadRepository.findByActivityIdAndClassId(activityId, classId).isPresent()) {
return;
}
} else if (activityReadRepository.existsByActivityIdAndClassIdAndStudentId(activityId, classId, studentId)) {
return;
}
ActivityRead read = new ActivityRead();
read.setActivityId(activityId);
read.setClassId(classId);
@@ -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())