阅读
build / build-push (push) Successful in 54s

This commit is contained in:
wanghu
2026-09-23 10:56:26 +08:00
parent 14b4e4c24f
commit 07c409c83c
4 changed files with 33 additions and 8 deletions
@@ -210,7 +210,7 @@ com.yunhai.familyactivity
1. **发布**(Create):draft=true 存草稿;否则置已发;`targetClassIds()` 统一解析 `receiveList`(`"ALL"`=全校班级);发布时按班级×学生**预生成回执记录**(receiptMode≠none 时,status=none),保证「应收人数」「未回执名单」可查。 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 时无报名态语义,仅展示)。 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`。`receiptMode=none` 或 `draft` 时 `progress=null`。 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` 按年级→班级分组返回读/未读。 4. **已读**:`Detail(type=1)` / `MySignup` 标读(家长端打开即确认模式:`receiptMode=confirm` 且状态为 none 时同步置 `confirmed`);`InitializeRed` 一键全部已读;`ReadDetail` 按年级→班级分组返回读/未读。
5. **报名**(Signup/CancelSignup):校验活动状态(signup 中)、截止时间、名额(quota>0 时 usedQuota+joinCount≤quota)、随行(allowAccompany);成功/失败回执业务码。 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 直接拼文本返回)。 6. **管理动作**:Edit(含 sourceId 复用)、Delete(批量 + 级联删 read/signup)、UserPull 撤回、SetTopmost、ReceiveClasses 补发(新班级追加并补回执记录)、Expedite/Remind 催办(本期日志占位)、ExportSignups 导出 CSV(easyexcel 已在 pom,CSV 直接拼文本返回)。
@@ -238,7 +238,7 @@ com.yunhai.familyactivity
| POST | `/SetTopmost` | serid, topmost | 置顶 | | POST | `/SetTopmost` | serid, topmost | 置顶 |
| POST | `/Expedite` | serid | 催办未读(日志占位) | | POST | `/Expedite` | serid | 催办未读(日志占位) |
| POST | `/ReceiveClasses` | serid, classes | 追加接收班级(补回执) | | POST | `/ReceiveClasses` | serid, classes | 追加接收班级(补回执) |
| GET | `/Signups` | serid, signupStatus | 回执名单(按班级分组+汇总) | | GET | `/Signups` | serid, signupStatus | 回执名单(按班级分组+汇总;summary 含 `readCount`/`readRate`,名单项含 `read` 已读/未读) |
| GET | `/SignupDetail` | signupId | 单条回执详情 | | GET | `/SignupDetail` | signupId | 单条回执详情 |
| POST | `/Review` | ReviewRequest | 处理回执 confirm/cancel/waitlist | | POST | `/Review` | ReviewRequest | 处理回执 confirm/cancel/waitlist |
| POST | `/Remind` | serid | 催报名(日志占位,返回 {reminded}) | | POST | `/Remind` | serid | 催报名(日志占位,返回 {reminded}) |
@@ -444,4 +444,4 @@ npm run dev # 首次需 npm install(Node≥20 建议 --ignore-scripts
- `ActivityVo.createBy/updateBy` 同步改 `Integer`,JSON 输出为数字;前端契约 `types/activity.ts` 改 `number | null`(mock 演示数据置 null)。 - `ActivityVo.createBy/updateBy` 同步改 `Integer`,JSON 输出为数字;前端契约 `types/activity.ts` 改 `number | null`(mock 演示数据置 null)。
- 历史行为账号文本,无法在本库映射为用户ID:迁移脚本先将非纯数字文本置 NULL,再把列 `VARCHAR(50)` 改为 `INT`(涉及继承 BaseEntity 的全部 7 张表),纯数字历史值直接转换保留。 - 历史行为账号文本,无法在本库映射为用户ID:迁移脚本先将非纯数字文本置 NULL,再把列 `VARCHAR(50)` 改为 `INT`(涉及继承 BaseEntity 的全部 7 张表),纯数字历史值直接转换保留。
验收:`mvn compile` BUILD SUCCESS。注意:`createBy/updateBy` 改 INT 后前端契约同步为 `number | null`,`fa_sys_dict_sch_id_and_audit.sql` 需由 DBA 执行(含审计列 VARCHAR→INT 转换)。 验收:`mvn compile` BUILD SUCCESS。注意:`createBy/updateBy` 改 INT 后前端契约同步为 `number | null`,`fa_sys_dict_sch_id_and_audit.sql` 需由 DBA 执行(含审计列 VARCHAR→INT 转换)。**本地库 `family_activity` 已于 2026-09-20 执行**(`sch_id` 列此前已由 ddl-auto 自动添加,脚本跳过加列直接归零回填;存量账号文本 `lijing` 置 NULL 后 14 个审计列转为 `int(11)`,并建 `idx_sys_dict_sch_type` 索引;因库中已有真实字典数据(发文部/运动,归入全局种子),未执行种子 INSERT 段)。
@@ -43,8 +43,14 @@ public class ActivityProgress {
/** 待处理回执(名额超额/待定),统计用,列表场景恒为 0 */ /** 待处理回执(名额超额/待定),统计用,列表场景恒为 0 */
private int pendingReview; private int pendingReview;
/** 已读人数(fa_activity_read 班级粒度投影到学生,与详情 readnumcount 同口径) */
private int readCount;
/** 阅读率(readCount / should,四舍五入取整 %) */
private int readRate;
public ActivityProgress(int should, int signed, int confirmed, int canceled, int unsigned, public ActivityProgress(int should, int signed, int confirmed, int canceled, int unsigned,
double signupRate, int quota, int usedQuota, boolean full) { double signupRate, int quota, int usedQuota, boolean full) {
this(should, signed, confirmed, canceled, unsigned, signupRate, quota, usedQuota, full, 0); this(should, signed, confirmed, canceled, unsigned, signupRate, quota, usedQuota, full, 0, 0, 0);
} }
} }
@@ -40,6 +40,9 @@ public class SignupBrief {
/** 是否已回执/已报名 */ /** 是否已回执/已报名 */
private boolean hasReceipt; private boolean hasReceipt;
/** 阅读状态:该生所在班是否已读(fa_activity_read 班级粒度投影) */
private boolean read;
/** 备注(如过敏史、随行人数说明) */ /** 备注(如过敏史、随行人数说明) */
private String remark; private String remark;
} }
@@ -504,13 +504,21 @@ public class ActivityService implements IActivityService {
.collect(Collectors.toList()); .collect(Collectors.toList());
Map<Integer, SchClassDto> classes = schoolClasses(schId); Map<Integer, SchClassDto> classes = schoolClasses(schId);
// 已读统计:fa_activity_read 为班级粒度,投影到学生数并与接收班级求交(与详情 readnumcount 同口径);
// readClassIds 供名单行按"该生所在班是否已读"填充 read(与 SignupDetail 的 findRead 判定一致)
List<ActivityRead> reads = activityReadRepository.findByActivityId(serid);
Set<String> readClassIds = reads.stream()
.map(ActivityRead::getClassId)
.collect(Collectors.toSet());
Map<String, List<ActivitySignup>> byClass = filtered.stream() Map<String, List<ActivitySignup>> byClass = filtered.stream()
.collect(Collectors.groupingBy(r -> r.getClassName() != null ? r.getClassName() .collect(Collectors.groupingBy(r -> r.getClassName() != null ? r.getClassName()
: classNameOf(classes, parseSafe(r.getClassId())), LinkedHashMap::new, Collectors.toList())); : classNameOf(classes, parseSafe(r.getClassId())), LinkedHashMap::new, Collectors.toList()));
List<SignupGroup> groups = byClass.entrySet().stream() List<SignupGroup> groups = byClass.entrySet().stream()
.map(e -> new SignupGroup(e.getKey(), e.getValue().stream() .map(e -> new SignupGroup(e.getKey(), e.getValue().stream()
.map(this::toBrief).collect(Collectors.toList()))) .map(r -> toBrief(r, readClassIds)).collect(Collectors.toList())))
.collect(Collectors.toList()); .collect(Collectors.toList());
Map<Integer, List<SchStudentDto>> byClassStudents = studentsByClass(schId, targetClassIds(activity, classes)); Map<Integer, List<SchStudentDto>> byClassStudents = studentsByClass(schId, targetClassIds(activity, classes));
@@ -518,6 +526,9 @@ public class ActivityService implements IActivityService {
if (should == 0 && !records.isEmpty()) { if (should == 0 && !records.isEmpty()) {
should = records.size(); // 班级数据不可达时降级为回执表实有人数 should = records.size(); // 班级数据不可达时降级为回执表实有人数
} }
int readCount = reads.stream()
.mapToInt(r -> byClassStudents.getOrDefault(parseSafe(r.getClassId()), Collections.emptyList()).size())
.sum();
SignupsResultVo vo = new SignupsResultVo(); SignupsResultVo vo = new SignupsResultVo();
vo.setActivityId(activity.getId()); vo.setActivityId(activity.getId());
@@ -532,7 +543,10 @@ public class ActivityService implements IActivityService {
ReceiptModeEnum mode = ReceiptModeEnum.of(activity.getReceiptMode()); ReceiptModeEnum mode = ReceiptModeEnum.of(activity.getReceiptMode());
vo.setReceiptMode(mode.getCode()); vo.setReceiptMode(mode.getCode());
vo.setReceiptModeDesc(mode.getDesc()); vo.setReceiptModeDesc(mode.getDesc());
vo.setSummary(computeProgress(activity, records, should)); ActivityProgress summary = computeProgress(activity, records, should);
summary.setReadCount(readCount);
summary.setReadRate(should > 0 ? (int) Math.round(readCount * 100.0 / should) : 0);
vo.setSummary(summary);
vo.setGroups(groups); vo.setGroups(groups);
return vo; return vo;
} }
@@ -1272,7 +1286,8 @@ public class ActivityService implements IActivityService {
boolean full = quota > 0 && usedQuota >= quota; boolean full = quota > 0 && usedQuota >= quota;
double rate = should > 0 ? Math.round((signed + confirmed) * 1000.0 / should) / 10.0 : 0; double rate = should > 0 ? Math.round((signed + confirmed) * 1000.0 / should) / 10.0 : 0;
int pending = quota > 0 ? Math.max(0, usedQuota - quota) : 0; int pending = quota > 0 ? Math.max(0, usedQuota - quota) : 0;
return new ActivityProgress(should, signed, confirmed, canceled, unsigned, rate, quota, usedQuota, full, pending); // readCount/readRate 由调用方按 fa_activity_read 投影填充(如 Signups 汇总),此处缺省 0
return new ActivityProgress(should, signed, confirmed, canceled, unsigned, rate, quota, usedQuota, full, pending, 0, 0);
} }
private int usedQuota(List<ActivitySignup> records) { private int usedQuota(List<ActivitySignup> records) {
@@ -1591,7 +1606,7 @@ public class ActivityService implements IActivityService {
return vo; return vo;
} }
private SignupBrief toBrief(ActivitySignup r) { private SignupBrief toBrief(ActivitySignup r, Set<String> readClassIds) {
SignupBrief brief = new SignupBrief(); SignupBrief brief = new SignupBrief();
brief.setSignupId(r.getId()); brief.setSignupId(r.getId());
brief.setStudentId(r.getStudentId()); brief.setStudentId(r.getStudentId());
@@ -1605,6 +1620,7 @@ public class ActivityService implements IActivityService {
brief.setSignedAt(fmt(r.getSignedAt())); brief.setSignedAt(fmt(r.getSignedAt()));
boolean receipt = !SignupStatusEnum.NONE.getCode().equalsIgnoreCase(r.getStatus()); boolean receipt = !SignupStatusEnum.NONE.getCode().equalsIgnoreCase(r.getStatus());
brief.setHasReceipt(receipt); brief.setHasReceipt(receipt);
brief.setRead(readClassIds.contains(r.getClassId()));
brief.setRemark(r.getRemark()); brief.setRemark(r.getRemark());
return brief; return brief;
} }