@@ -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,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())
|
||||
|
||||
Reference in New Issue
Block a user