Files
familyactivity-service/docs/后端重构计划-活动信息发布.md
wanghu 33398a7f8a
build / build-push (push) Successful in 1m30s
阅读
2026-09-23 11:28:00 +08:00

451 lines
43 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 活动信息发布模块 · 后端重构计划
> 项目:`E:\YHXT\java\FamilyActivity`(Spring Boot 2 + JPA,库 `family_activity`,`ddl-auto: update`)
> **基础包名已更名(✅ 已完成)**:`com.yunhai.notice` → `com.yunhai.familyactivity`,含目录树、全部 package/import、启动类 `NoticeApplication` → `FamilyActivityApplication`、切面表达式、`pom.xml` mainClass 等;`artifactId` 与 `spring.application.name` 保持 `notice` 不变(Nacos dataId 依赖,见 §七-0)。
> 前端契约:`E:\YHXT\view\FamilyActivity\src\types\activity.ts`、`src\api\module\activity.ts`、`docs\活动信息发布模块-功能与接口说明-v1.md`
> 参考项目:`E:\YHXT\java\UserclassService`(UserContext 用户上下文、操作日志表设计)
>
> 目标:将现有「通知公告(Notice)」后端改造为「活动信息发布(Activity)」后端 ——
> 删除旧业务实体,保留字典表与日志能力,按前端契约重建实体与业务逻辑,并引入 UserContext 统一获取当前用户。
---
## 一、现状盘点
> 执行状态(随实施同步更新):步骤 0 包更名 ✅;步骤 1 旧业务删除 ✅;步骤 2 UserContext+切面+操作日志 ✅;步骤 3 实体/仓库/枚举/班级门面 ✅;步骤 4 model 层 ✅;步骤 5 ActivityService ✅;步骤 6 ActivityController + GetClassTree/SetRole ✅;步骤 7 编译验证 ✅ **2026-09-17 已在工具链环境(便携 JDK 8.0_504 + Maven 3.8.8)执行 `mvn clean compile`,81 个源文件全量 BUILD SUCCESS**。建表脚本:`sql/fa_activity_tables.sql`。**前后端联查 bug 修复与优化见 §九(2026-09-17)**。
### 1.1 原代码清单与处置(更名前盘点;现基础包为 `com.yunhai.familyactivity`)
| 层 | 现有文件 | 处置 |
| --- | --- | --- |
| entity | `BaseEntity` | **保留**(公共基类:自增 id + createBy/createTime/updateBy/updateTime,`NoticeAuditListener` 填充) |
| entity | `SysDict`(sys_dict 字典表) | **保留**(主办部门下拉/管理,前端 `/api/dict/*` 依赖) |
| entity | `UpFile`(附件表) | **保留**(附件上传/下载/预览,前端依赖,属通用基础设施) |
| entity | `ContactsGroup` / `ContactsGroupUser`(联系组) | **保留**(应用设置页「自定义组管理」依赖,非公告业务) |
| entity | `Notice` / `NoticeRead` / `NoticeReceiver` / `NoticeTemplate` / `SmsModule` / `SmsSchoolConfig` / `TemplateType` | **删除** |
| repository | `NoticeRepository` 等 7 个 Notice/Sms/Template 仓库 | **删除** |
| repository | `SysDictRepository` / `UpFileRepository` / `ContactsGroupRepository` / `ContactsGroupUserRepository` | 保留 |
| controller | `NoticeController` / `SmsController` / `TemplateController` | **删除** |
| controller | `BaseApiController` / `DictController` / `ContactsController` / `FileController` | 保留(Base 需补 2 个接口,见 5.4) |
| service | `INoticeService`/`NoticeService`(957 行)、`ISmsService`/`SmsService`、`ITemplateService`/`TemplateService` | **删除** |
| service | `IActivityService`/`ActivityService` | **新建**(核心业务,见 §5) |
| service | `ClassService` / `ContactsService` / `DictService` / `FileService` / `UserCenterService` | 保留(按需适配新实体) |
| model | `model/notice/Notice*` 系列 VO/Query | **删除**(重建为 `model/activity/*`) |
| model | `UpFileResultVo` / `UpFileVo` / `UserModuleVo` / `SchTeaList`、`BasePageQuery` / `BasePageResult` | 保留(前端契约同名/分页外壳) |
| enums | `NoticeStatusEnum` / `NoticeTypeEnum` | **删除**(重建 Activity 枚举) |
| utils | `PageUtils` | 保留 |
| config | `Config` / `JacksonConfig` / `SwaggerConfig` | 保留 |
| resources | `sql/notice_*.sql`(3 个旧表脚本) | 归档至 `sql/archive/` |
> 说明:当前项目**没有**任何日志实体/日志表(已 grep 确认),日志表按参考项目新建(§4)。
### 1.2 现状缺口(对照前端契约)
1. 无 `Activity` 业务域代码(实体/服务/接口全部缺失)。
2. `BaseApiController` 缺少前端已调用的 `GET /api/base/GetClassTree`、`POST /api/base/SetRole`。
3. 无 UserContext 机制:当前各接口靠 `BoaUser` 方法参数绑定 / `UserContextHolder`(yunhai platform)取用户,Service 层无统一上下文。
4. 无操作日志表。
---
## 二、目标架构(包结构)
```
com.yunhai.familyactivity
├── aspect/ # 新增:UserContext 切面、操作日志切面
├── annotation/ # 新增:@OperationLog
├── common/ # 保留:validation(NoticeAuditListener 随实体保留)
├── config/ # 保留
├── controller/api/
│ ├── ActivityController # 新建:/api/activity/*(24 个接口)
│ ├── BaseApiController # 保留 + 补 GetClassTree / SetRole
│ ├── ContactsController / DictController / FileController # 保留
├── entity/
│ ├── BaseEntity / SysDict / UpFile / ContactsGroup / ContactsGroupUser # 保留
│ ├── Activity # 新建:活动主表
│ ├── ActivityRead # 新建:已读记录(学生粒度)
│ ├── ActivitySignup # 新建:回执/报名记录
│ └── OperationLogRecord # 新建:操作日志表(参考 UserclassService)
├── enums/ # 新建:ActivityStatusEnum、ReceiptModeEnum、SignupStatusEnum
├── model/
│ ├── activity/ # 新建:request + vo(严格对齐 src/types/activity.ts)
│ └── (BasePage*、UpFile*、UserModuleVo 保留)
├── repository/ # 保留 4 个 + 新建 3 个 Activity 仓库 + OperationLogRepository
├── service/
│ ├── IActivityService / impl/ActivityService # 新建
│ └── (其余保留)
└── utils/
├── PageUtils # 保留
└── UserContext # 新建(参考 UserclassService)
```
---
## 三、任务 1:删除旧业务实体
**删除清单**(实体 → 仓库 → 服务 → 控制器 → model → 枚举,一并清理引用):
- entity:`Notice`、`NoticeRead`、`NoticeReceiver`、`NoticeTemplate`、`SmsModule`、`SmsSchoolConfig`、`TemplateType`
- repository:`NoticeRepository`、`NoticeReadRepository`、`NoticeReceiverRepository`、`NoticeTemplateRepository`、`SmsModuleRepository`、`SmsSchoolConfigRepository`、`TemplateTypeRepository`
- service:`INoticeService`/`impl/NoticeService`、`ISmsService`/`impl/SmsService`、`ITemplateService`/`impl/TemplateService`
- controller:`NoticeController`、`SmsController`、`TemplateController`
- model:`model/notice/` 下 `NoticeVo`、`NoticeDetailVo`、`NoticePageResult`、`NoticeQuery`、`NoticeRequest`、`NoticeStatisticsVo`、`NoticeUserListResult`(`UpFileResultVo`、`UpFileVo`、`UserModuleVo`、`InitializeRedVo` 保留/迁出后删除整个 notice 子包)
- enums:`NoticeStatusEnum`、`NoticeTypeEnum`
- `service/sms/`(SmsSender、DefaultSmsSender)
- SQL 脚本归档:`sql/notice_*.sql` → `sql/archive/`
**注意事项**:
- `ddl-auto: update` 只增不删,删除实体后旧表 `notice_*` 仍留在库中;本次**不做数据迁移、不物理删表**(新业务建新表),旧表留待 DBA 统一清理,并提供 `sql/archive/drop_notice_tables.sql` 备查。
- 短信能力(Sms)删除后,`smsFlag` 字段仅作为活动上的标记位存储;`Expedite`/`Remind` 催办接口本期仅记录日志、返回 `{reminded: 0}`,短信通道后续按需接入。
---
## 四、任务 2:引入 UserContext(参考 `E:\YHXT\java\UserclassService`)
### 4.1 `utils/UserContext.java`
照搬参考项目实现:`ThreadLocal` 持有 `userId / userName / roleId / schId / ip / type`,
提供 `get/set`、`getCurrentUser()`(不可变 `UserInfo`)、`wrap(Runnable)`(异步任务传递)、`clear()`。
### 4.2 `aspect/ControllerUserContextAspect.java`
照搬参考项目实现(`@Aspect @Order(1)`,`@Around("execution(* com.yunhai.familyactivity.controller..*.*(..))")`):
1. 优先从方法参数提取 `BoaUser`(兼容现有写法);
2. 缺失时回退 `BoaUserContext.get()`(平台静态持有者)→ request attribute(`boaUser/currentUser/...`)→ `currentrole` Header/Query 解析;
3. 填充 UserContext(userId、userName、schId=学校 id、type;超管 type=1 强制 schId=0;clientIp);
4. `finally { UserContext.clear(); }`。
### 4.3 使用约定
- **Service 层一律通过 `UserContext` 取当前用户**(发布人账号/姓名、schId 数据隔离、日志记录),控制器不再逐层传 `BoaUser`;
- 控制器保留 `BoaUser` 参数绑定的接口(`GetUserInfo` 等平台代理接口)不动,切面同时兼容两种方式;
- `NoticeAuditListener`(BaseEntity 审计填充)同步切换为从 `UserContext` 取 createBy/updateBy。
### 4.4 操作日志(保留并新建日志表)
当前项目无日志表,参考 `UserclassService` 三件套引入业务操作日志:
| 组件 | 说明 |
| --- | --- |
| `entity/OperationLogRecord`(表 `operation_log`) | id、operatorId、operator、operateTime、ip、module、content、schId(照搬参考项目字段) |
| `repository/OperationLogRepository` | JPA 仓库 |
| `annotation/@OperationLog(module, content)` | 方法级注解 |
| `aspect/OperationLogAspect` | `@AfterReturning` 记录:从 `UserContext` 取操作人/IP/schId,异步落库(复用现有 AsyncConfig 风格或简单 `@Async`) |
覆盖点:`Create / Edit / Delete / UserPull / SetTopmost / ReceiveClasses / Review / Signup / CancelSignup / dict 增删改` 等写操作。
---
## 五、任务 3:新实体与业务逻辑(对齐前端契约)
### 5.1 实体设计
#### `Activity`(活动主表,表 `fa_activity`)
| 字段 | 类型 | 说明(对应前端 `ActivityVo`) |
| --- | --- | --- |
| id | int 自增(BaseEntity) | 前端 `id` / `serid` |
| schId | int | 学校隔离(参考 `notice_add_sch_id.sql` 先例) |
| title / content | varchar / text | 标题、富文本内容(详情 `Contents`) |
| category | varchar(50) | 活动分类(武术比赛/家长会/…,字典维护) |
| venue | varchar(200) | 活动地点 |
| startTime / endTime / signupDeadline | datetime | 开始/结束/报名截止 |
| receiptMode | varchar(16) | `none` 仅通知 / `signup` 需报名 / `confirm` 需确认 |
| quota | int | 名额上限,0 不限 |
| allowAccompany | tinyint | 是否允许家长随行 |
| cost | decimal(10,2) | 活动费用(元) |
| deptId / deptName | varchar | 主办部门(字典 code / 名称冗余) |
| publisherAccount / publisherName | varchar | 发布人账号/姓名 |
| status | tinyint | 发送状态(沿用原通知语义:草稿/已发/撤回等) |
| topmost | tinyint | 置顶 |
| isPulled | tinyint | 撤回(家长端不可见) |
| timingSendTime | datetime | 定时发送时间 |
| smsFlag | tinyint | 短信标记(仅存储) |
| receiveType / receiveList | varchar | `class` / `"ALL"` 或班级 id 逗号串 |
| sourceId | int | 转发/复用源活动 |
| createBy/createTime/updateBy/updateTime | BaseEntity | 审计 |
`actStatus`(draft/signup/closed/finished)**不落库**,查询时按 `status` + `signupDeadline`/`endTime` 与当前时间推导(枚举 `ActivityStatusEnum` 提供计算方法),避免定时任务维护状态。
#### `ActivityRead`(已读记录,表 `fa_activity_read`)
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | int 自增 | |
| activityId | int | 活动 id |
| classId | varchar(50) | 班级 id |
| studentId | int | 阅读学生ID(家长端以学生身份打开;学生档案不可得时为 null) |
| readTime | datetime | 阅读时间 |
唯一约束 `(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`)
| 字段 | 类型 | 说明(对应前端 `SignupBrief`) |
| --- | --- | --- |
| id | int 自增 | signupId |
| activityId | int | 活动 id |
| schId | int | 学校隔离 |
| studentId / studentName | int / varchar | 学生 |
| classId / className | varchar | 班级 |
| guardianAccount | varchar | 家长登录账号(=学生账号) |
| guardianName / guardianPhone | varchar | 家长姓名/电话 |
| joinCount | int | 参加人数(含学生本人) |
| status | varchar(16) | `none` 未回执 / `signed` 已报名 / `confirmed` 已确认 / `canceled` 已取消 |
| remark | varchar(500) | 备注(过敏史、随行说明) |
| signedAt | datetime | 回执时间 |
| createBy/createTime/... | BaseEntity | 审计 |
唯一约束 `(activity_id, student_id)`(一个学生对一个活动一条回执,可反复修改报名)。
### 5.2 model(严格对齐 `src/types/activity.ts`)
- **request**:`ActivityRequest`(@RequestBody JSON)、`ActivityQuery`(分页/筛选)、`SignupRequest`、`ReviewRequest`
- **vo**:`ActivityVo`、`ActivityDetailVo`(**@JsonProperty 保留旧字段名**:`Contents`/`DeptID`/`DeptName`/`TeaName`/`EditDate`/`DsfsTime`/`isDsfs`/`ChaoSend`/`numcount`/`readnumcount`/`tlist`)、`ActivityProgress`、`ActivityPageResult`(继承分页外壳 + `unreadCount`)、`SignupsResultVo`/`SignupGroup`/`SignupBrief`、`SignupDetailVo`、`ActivityStatisticsVo`、`MyActivitiesVo`/`MyActivityVo`/`ParentActivityVo`/`MySignupInfo`、`ReadGroup`/`ReadGroupUser`、`NextPrevVo`、`InitializeRedVo`
- 枚举:`ActivityStatusEnum`(draft/signup/closed/finished)、`ReceiptModeEnum`(none/signup/confirm)、`SignupStatusEnum`(none/signed/confirmed/canceled),各带 `desc` 字段输出 `xxxDesc`
### 5.3 `IActivityService` / `ActivityService` 核心业务规则
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`=已读人数(学生粒度:接收班级内已读记录条数=实际阅读家长数,仅 `/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` 活动详情+我的回执(同时标读/自动确认)。
8. **统计**(Statis):总量/已发布/我发布/累计报名人次/平均回执率/待处理回执、按部门(deptName)、按月(createTime)、按分类(category)、我发布活动明细(含 progress)。
9. **数据隔离**:全部查询带 `UserContext.getSchId()` 过滤;教师/管理端 `scope=user` 只看自己发布。
10. **视角(SetRole)**:`SetRole` 写入会话(header `currentrole` 或 session 属性),`GetUserInfo`/`List`/`MyActivities` 按 teacher/parent 视角返回(家长=学生账号,挂班级→活动过滤)。
### 5.4 接口清单(`ActivityController`,前缀 `Config.API_PATH + "/activity"`)
| 方法 | 路径 | 入参 | 说明 |
| --- | --- | --- | --- |
| GET | `/List` | ActivityQuery | 家长端收件箱(返回 ActivityPageResult,含 unreadCount) |
| GET | `/SendList` | ActivityQuery(scope=user) | 我发布的 / 全部活动 |
| GET | `/Detail` | serid, type | type=1 标读 |
| GET | `/GetDetail` | serid | 编辑回显,不标读 |
| GET | `/GetNextPrev` | serid, type | 上一篇/下一篇 |
| GET | `/ReadDetail` | serid | 阅读情况(年级→班级分组) |
| GET | `/Statis` | startTime?, endTime? | 活动统计 |
| POST | `/Create` | ActivityRequest | 新建,返回活动 id |
| POST | `/Edit` | ActivityRequest | 编辑 |
| POST | `/Delete` | number[] | 批量删除(级联清回执/已读) |
| POST | `/InitializeRed` | — | 一键全部已读 |
| POST | `/UserPull` | serid | 撤回 |
| POST | `/SetTopmost` | serid, topmost | 置顶 |
| POST | `/Expedite` | serid | 催办未读(日志占位) |
| POST | `/ReceiveClasses` | serid, classes | 追加接收班级(补回执) |
| GET | `/Signups` | serid, signupStatus | 回执名单(按班级分组+汇总;summary 含 `readCount`/`readRate`,名单项含 `read` 已读/未读) |
| GET | `/SignupDetail` | signupId | 单条回执详情 |
| POST | `/Review` | ReviewRequest | 处理回执 confirm/cancel/waitlist |
| POST | `/Remind` | serid | 催报名(日志占位,返回 {reminded}) |
| GET | `/ExportSignups` | serid | 导出 CSV 文本 |
| GET | `/MyActivities` | ActivityQuery | 家长端活动广场(分桶计数) |
| GET | `/MySignup` | serid | 家长端详情+我的回执(标读/自动确认) |
| POST | `/Signup` | SignupRequest | 报名/修改报名 |
| POST | `/CancelSignup` | serid | 取消报名 |
| GET | `/AllFileDown` | dataid | 全部附件打包下载(沿用 FileService) |
**BaseApiController 补充**:`GET /GetClassTree`(年级→班级树,叶子带 studentCount,走 ClassService/基础平台)、`POST /SetRole`(切换 teacher/parent 视角)。
---
## 六、实施步骤与验证(已全部执行)
| # | 步骤 | 产出 | 状态/验证 |
| --- | --- | --- | --- |
| 0 | **基础包更名** `com.yunhai.notice` → `com.yunhai.familyactivity`:目录树平移、全部 package/import、启动类改名 `NoticeApplication` → `FamilyActivityApplication`、`pom.xml` mainClass 同步 | ✅ 完成 | 全局 grep 无 `com.yunhai.notice` 残留 |
| 1 | 删除旧业务代码(§三清单),全局引用清理;旧 SQL 归档 `sql/archive/` | 编译通过的最小工程 | ✅ 完成(残留 grep 为空;`DictService.delDept` 引用校验已改接 `fa_activity`) |
| 2 | UserContext + `ControllerUserContextAspect` + `@OperationLog`/切面/`operation_log` 实体与仓库 | 用户上下文 + 操作日志三件套 | ✅ 完成(切点 `com.yunhai.familyactivity.controller..*`,写操作同步落库) |
| 3 | 实体 `Activity`/`ActivityRead`/`ActivitySignup` + 仓库 + 3 个枚举 + IClassService 扩展为 bschclass 完整门面(含 6 个 `model/bsch` DTO) | 活动域数据层 | ✅ 完成(建表脚本 `sql/fa_activity_tables.sql`) |
| 4 | model 层:`model/activity` 20 个 request/VO + 枚举 desc | 与 `types/activity.ts` 一一对齐 | ✅ 完成(详情 VO `@JsonProperty` 保留旧字段名) |
| 5 | `IActivityService`/`ActivityService` 业务实现 | 全部业务规则(§5.3) | ✅ 完成(静态核查:JPA 泛型、派生查询字段、括号配平) |
| 6 | `ActivityController` 25 端点 + BaseApiController 补 `GetClassTree`/`SetRole` | 接口层 | ✅ 完成(写操作带 `@OperationLog`) |
| 7 | 保留功能适配:Dict 删除保护接新表、File topicType 改 `activity`、Swagger/pom 名称更正 | 全链路可联调 | ✅ 完成 |
| 8 | 收尾:建表脚本归档、文档 As-Built 更新 | 交付物 | ✅ **2026-09-17 编译验证完成**:便携 JDK 8.0_504 + Maven 3.8.8(`E:\YHXT\tools\`)执行 `mvn clean compile`,81 个源文件全量 BUILD SUCCESS |
## 七、风险与决策点
0. **更名范围**:仅重命名 Java 基础包(`com.yunhai.familyactivity`);`pom.xml` 的 `artifactId=notice` 与 `bootstrap.yml` 的 `spring.application.name=notice` **保持不变**——后者决定 Nacos 配置 dataId(`notice.yaml`),改动会导致配置发现失败。如需连应用名一起更名,必须先在 Nacos 建好 `familyactivity.yaml` 再切换。
1. **旧表不迁移**:`notice_*` 历史数据不迁移到新表(新业务新表);如需迁移另行立项。
2. **家长视角识别**:家长=学生账号登录。✅ 已实现:`SetRole` 写会话属性 `activityViewRole`(`BaseApiController.SESSION_VIEW_ROLE`);家长身份按当前账号在 `GetSchStulist` 中匹配学生解析班级。联调时如网关/SSO 有独立视角下发,再对齐。
3. **班级与学生数据源**:✅ 已实现三级降级——主路径 `GetClaArrStulist` 按班批量;失败兜底 `GetSchStulist` 全校过滤;仍不可达时应收人数降级为回执表实有人数、`studentCount` 记 0,不阻塞主流程。
4. **催办短信**:本期占位(记录日志),`smsFlag` 仅存储;后续接回短信通道时恢复 SmsSender。
5. **actStatus 推导 vs 落库**:选择「查询时推导」,避免定时任务;列表排序/筛选按推导值在内存/SQL 中处理,数据量大时再优化为落库。
---
## 八、实施结果(As-Built)
> 与前端契约 `src/types/activity.ts` / `src/api/module/activity.ts` 逐字段对齐。以下为最终代码清单。
### 8.1 代码清单(`com.yunhai.familyactivity`)
```
annotation/ OperationLog # 操作日志注解(module/content)
aspect/ ControllerUserContextAspect # UserContext 填充/清理(四级身份回退)
OperationLogAspect # @OperationLog 拦截,operation_log 落库
controller/api/ActivityController # 25 端点(/api/activity/*)
BaseApiController # 平台代理 + GetClassTree + SetRole
DictController / ContactsController / FileController # 保留适配
entity/ Activity(fa_activity)ActivityRead(fa_activity_read)ActivitySignup(fa_activity_signup)
OperationLogRecord(operation_log)
BaseEntity / SysDict / UpFile / ContactsGroup / ContactsGroupUser # 保留
enums/ ActivityStatusEnum / ReceiptModeEnum / SignupStatusEnum
model/ BasePageQuery / BasePageResult / UpFileResultVo / UpFileVo / UserModuleVo / SchTeaList
model/activity/ ActivityRequest ActivityQuery SignupRequest ReviewRequest
ActivityVo ActivityDetailVo ActivityDetailFileItem ActivityProgress ActivityPageResult
SignupsResultVo SignupGroup SignupBrief SignupDetailVo ReadGroup(+ReadGroupUser)
NextPrevVo InitializeRedVo MyActivitiesVo(+Buckets) ParentActivityVo
MyActivityVo(+ActivityInfo) MySignupInfo ActivityStatisticsVo(+嵌套)
model/bsch/ SchInfoDto SchClassDto SchStudentDto SchGradeDto SchSystemDto SchTreeNodeDto
repository/ ActivityRepository(含 existsByDeptId 字典删除保护)
ActivityReadRepository ActivitySignupRepository OperationLogRepository
SysDict/UpFile/ContactsGroup 仓库(保留)
service/ IActivityService / impl/ActivityService # 核心业务
IClassService / impl/ClassService # bschclass 完整门面
IDictService(删除保护接新表) IFileService(topicType=activity) IContactsService IUserCenterService
utils/ UserContext # ThreadLocal:userId/userName/account/roleId/schId/ip/type + wrap()
resources/sql/ fa_activity_tables.sql(新表脚本);archive/notice_*.sql(旧脚本归档)
```
### 8.2 关键实现决策(与计划的差异)
1. **预生成回执覆盖定时发布**:定时发布的活动在创建/编辑时同样预生成回执记录,收件箱按 `timingSendTime` 过滤可见性(计划原文仅即时发布生成)。
2. **waitlist 待定**:无独立状态(前端枚举仅 none/signed/confirmed/canceled),落库为 `signed` + 备注 `[待定]` 前缀。
3. **pendingReview 口径**:名额超额人数 `max(0, usedQuota − quota)`(名额不限时为 0)。
4. **UserContext 增补 account**:参考项目仅 userId/userName/roleId/schId/ip/type,家校场景发布人/家长都以「账号」为键,故增补 account 字段。
5. **NoticeAuditListener 保持平台 `UserContextHolder` 取人**:与 UserContext 同源(BoaUser/LoginUser),无功能差异,未改动。
6. **家长身份解析**:`GetSchStulist` 按账号匹配学生 → 班级;每请求一次远端调用,列表页已做批量/去重优化(receiveText、名额占用均一次取数)。
7. **催办占位**:`Expedite`/`Remind` 落操作日志、返回 `{reminded: 0}`;`smsFlag` 仅存储。
8. **actStatus 筛选翻译**:`SendList` 将 draft/signup/closed/finished 翻译为 `status` + 截止/结束时间谓词,分页在数据库完成;家长端收件箱数据量小,接收班级匹配与未读过滤在内存完成。
### 8.3 遗留事项
| 事项 | 说明 |
| --- | --- |
| ~~`mvn compile`~~ | ✅ 已完成(2026-09-17,便携 JDK+Maven 全量编译 BUILD SUCCESS,详见 §六步骤 8 / §九) |
| 联调冒烟 | Swagger 链路:Create → Detail(type=1) → Signup → Signups → Review → Statis;前端 `npm run dev`(.env.dev + vite proxy 指向本地后端 8104) |
| 短信通道 | 催办/催报名短信后续接入时恢复 SmsSender(参考 git 历史或 CampusNotice 同名实现) |
| 旧表清理 | `notice_*` 旧表留在库中(ddl-auto 不删表),DBA 确认后手工 DROP |
---
## 九、前后端联查 Bug 修复与优化(2026-09-17)
> 背景:重构后代码从未经过编译验证与真实联调。本轮对前后端做全面静态联查(后端 25 项发现、前端 27 项发现),逐项修复并验证。工具链:便携 JDK 8.0_504 + Maven 3.8.8(`E:\YHXT\tools\`,永久可用);前端依赖重装(Node 24 下 node-sass 编译失败,以 `npm install --ignore-scripts` 安装,scss 实际由 dart-sass 编译,node-sass 为冗余废弃依赖可择机移除)。
### 9.1 后端修复(`P1` 功能/安全、`P2` 健壮性)
| # | 级别 | 问题 | 修复 |
| --- | --- | --- | --- |
| 1 | P1 | **越权删除**:`delete()` 无学校归属校验,可跨校按自增 id 枚举删除活动及回执/已读/附件 | 循环内补 `requireActivity(id)`,不存在/无权的 id 跳过 |
| 2 | P1 | **越权处理回执**:`review()` 未校验回执归属,跨校可 confirm/cancel 他人回执 | 取到记录后补 `requireActivity(record.getActivityId())` |
| 3 | P1 | **越权打包下载**:`allFileDown()` 无归属校验 | 补 `requireActivity(dataid)` |
| 4 | P1 | **导出契约断裂**:`ExportSignups` 返回裸 text/csv,但平台 `ApiResponseAdvice` 不包装 String 返回、前端拦截器统一 `response.json()` 解析 → 线上导出必挂 | 后端改返回 JSON 包装 `{csv}`;前端兼容 `{csv}`/字符串并统一补 BOM(§9.3-7) |
| 5 | P1 | **家长越权访问**:`detail(type=1)`/`mySignup` 对草稿/已撤回/定时未到点活动照常返回全文,且触发标读+自动确认污染数据 | 新增 `ensureParentVisible()`(已发布+未撤回+定时到点),不满足抛 FORBIDDEN |
| 6 | P1 | **定时未到可报名**:定时发布活动创建即 status=1,`signup()` 未校验 `timingSendTime` | signup 校验链补定时到点检查 |
| 7 | P1 | **名额并发超卖**:名额校验为读-算-写无锁 | 新增 `ActivityRepository.findByIdForUpdate`(`@Lock(PESSIMISTIC_WRITE)`),`signup()` 悲观锁加载活动行,串行化同一活动校验(顺带消除并发首报唯一约束 500) |
| 8 | P1 | **编辑清置顶回归**:`applyRequest` 中 topmost=null 强制置 0,而编辑提交从不带该字段 → 每次编辑保存静默取消置顶 | topmost 为 null 时保留原值 |
| 9 | P1 | **班级数据故障降级为全校可见**:收件箱 `myClassId=null` 时放行全校活动(跨班泄露+未读角标虚高) | 改为返回空列表并告警日志;未读计数同步归零 |
| 10 | P2 | `receiveClasses` 不校验补发班级归属,可把他校学生拉进回执名单 | 与 `schoolClasses(schId).keySet()` 求交,越界抛 FORBIDDEN |
| 11 | P2 | 超管(schId=0)创建活动落 schId=0,家长端永远看不到 | `create()` 对 schId≤0 抛 FORBIDDEN("未绑定学校") |
| 12 | P2 | `GetNextPrev` 忽略 type:管理视角从草稿进详情后翻页恒 null | 按 type 分流:type=1 家长收件箱序列;type=0 本校全量(`findBySchId`,含草稿/撤回) |
| 13 | P2 | `requireActivity` 对 `schId=null` 的异常数据不隔离 | null 一并拒绝(新表无历史数据,纯收紧) |
| 14 | P2 | 关键字 LIKE 未转义 `%`/`_`,输入通配符全表模糊匹配 | `likeEscape()` 转义 + `cb.like(..., '\\')` |
| 15 | P2 | CSV 公式注入:单元格以 `= + - @` 开头会被 Excel 当公式 | `csvCell()` 危险前缀加 `'` |
| 16 | P2 | `FileController` 上传/列表 topicType 残留 `"notice"`,与活动域 `"activity"` 不一致(改走该端点的附件会查不到) | 3 处统一改 `"activity"` |
| 17 | P2 | `signups(signupStatus)` 非法值静默按 none 过滤返回空分组 | `SignupStatusEnum.isValid()` 严格校验,非法值抛 PARAM_ERROR |
| 18 | P2 | 管理端 `SendList` 的 `read` 标志口径错误(班级粒度已读表中发布人账号永不出现,恒 false 的假数据) | 管理端不再填 read(家长端 list() 另行正确填充) |
| 19 | P2 | `markRead` 在同一事务内 catch 唯一约束异常(事务已 rollback-only,catch 无效反掩错误) | 移除 catch,保留前置查重 |
| 20 | P2 | 重复查询:`list()` 中 `readActivityIds` 查两次;`preGenerateSignups` 重复拉班级表 | readIds 一次计算透传 `filterInbox`;班级表参数化复用 |
### 9.2 决策与保留项(本轮不改)
1. **双 toast(全局 onBizError + 页面 catch 各弹一次)**:保留现状。页面 catch 同时兜本地异常,全局拦截兜未 catch 路径;后续可统一收敛为全局一处。
2. **切面身份兜底**:`ControllerUserContextAspect` 兜底分支(LoginUser/anonymous)无 schId 时业务层按超管口径放行(schId=0 不加过滤)。纵深防御依赖网关/SSO(`yh.sso.url-pattern=/api/*`)前置拦截未登录请求,维持现状;如需彻底收紧应在业务层显式区分 type=1 超管与无身份。
3. **报名名单下拉 pageSize=100**:活动多于 100 条时下拉缺项,属低频场景,暂不加搜索。
4. **统计 `pendingReview` 口径**:当前=名额超额人数(max(0, usedQuota−quota)),与文案"待处理回执"不完全一致,维持现状待产品确认。
5. **waitlist 待定落库为 signed+[待定] 前缀**:维持原设计。
### 9.3 前端修复(配合后端联动)
| # | 级别 | 问题 | 修复 |
| --- | --- | --- | --- |
| 1 | P0 | `AttachmentUpload.vue` 导入不存在的 `Homework`、`Notice` 未导入 → 发布/编辑页组件加载即崩(白屏) | 改 `import { Message, Notice }`,`Homework.info` → `Notice.info` |
| 2 | P1 | **表单校验被架空**:view-ui-plus `validate()` 失败时 resolve(false) 不 reject,`.then(() => true)` 把失败也映射成 true | `NewActivity` 定时发布弹窗:不选时间点确定会**立即发布**;`ParentActivities` 报名表单:空姓名/非法手机号可提交。两处改为 `await validate()` 后判 false |
| 3 | P1 | 详情页上一篇/下一篇无效:`router.replace` 异步未等待,`load()` 读到旧 serid,重复请求当前篇 | `await router.replace(...)` 后再 `load()` |
| 4 | P1 | **dev 代理配置自相矛盾**:`.env.dev` 配置 `VITE_SERVICE_HOST` 指向远端,浏览器直连被 CAS 过滤器(不回 CORS 头)拦截,全部请求失败;vite proxy target 端口注释(8101/8102)与实际(8104)不符 | `VITE_SERVICE_HOST` 置空(同源代理,符合 host.config 设计注释);proxy target 改 `http://localhost:8104` 并更正注释 |
| 5 | P1 | 详情页对任意状态开放"编辑",已截止/已结束活动保存后被打回"报名态" | 编辑按钮仅 `actStatus === 'draft'` 显示,非草稿走"复用再发" |
| 6 | P1 | 发布页:复用再发不回显接收班级/定时时间;`tlist` 非数组误判为"全部班级";复用不落 `sourceId`;报名截止时间无合理性校验;快速连点可重复创建 | 回显逻辑编辑/复用共用并兼容逗号串;复用提交带 `sourceId`;发布前校验 开始<结束、截止>now 且 ≤结束;`doAdd` 入口防重入 |
| 7 | P1 | 导出名单与后端新契约(`{csv}` JSON)联动,前端兼容字符串旧格式、统一补 BOM、导出按钮加 loading 防重复 | `Signups.vue exportList` 重写;mock 同步返回 `{csv}` |
| 8 | P2 | `alova-core` 并发计数 `onError` 置 0:多请求并发时一个网络错误导致全局 loading 永不结束;`beforeRequest` 内死代码 | 改 `Math.max(0, reqCount-1)`,删除死代码 |
| 9 | P2 | 家长端"我要报名"不看 `canSignup`:名额满/已截止仍可点,提交才被后端拒 | 按钮置灰并显示"名额已满"(`signupState` 联动列表项与详情) |
| 10 | P2 | 家长端报名/取消后列表-详情错位:活动移出当前 tab 后右侧仍显示其详情 | `syncAfterAction()`:目标活动不在当前 tab 时清空详情并落到第一条 |
| 11 | P2 | 报名弹窗人数预填可能超上限(程序赋值不触发 InputNumber 钳制) | `openSignup` 预填夹取 `min(旧值, allowAccompany?6:1)` |
| 12 | P2 | `Signups` 切换活动时同参双请求 + 响应乱序覆盖 | route watcher 只重置状态由 `watch(signupStatus)` 统一触发;`loadSeq` 序号守卫丢弃过期响应 |
| 13 | P2 | 列表页离开时(watch route.path flush:pre 早于卸载)多发一次必被丢弃的请求 | `ActivityList`/`ParentActivities` 路由守卫限定本组件路由前缀 |
| 14 | P2 | 撤回后无标记、可再次撤回/催报名;状态兜底 `actStatus || "signup"` 把未知状态误标"报名中";分页键 `customPageCount` 与应用设置页互相污染 | 标题加"已撤回"角标并对已撤回行隐藏撤回/催报名;状态兜底展示原值;分页键改 `activityListPageCount` |
| 15 | P2 | 统计页未用接口的 `startTime/endTime`;mock 定时判断硬编码 `"2026-09-26"` 随时间失效;`menu.ts` 引用不存在的 `IModule` 类型;favicon 相对路径 404;分组 `:key` 用索引 | 统计页补时间范围筛选;mock 改与 `nowStr()` 比较;`types/index.ts` 补 `IModule` 定义;favicon 改根路径;`:key` 改稳定值 |
### 9.4 本轮验证结果
| 项 | 结果 |
| --- | --- |
| 后端全量编译 | `mvn clean compile`(JDK 8.0_504 + Maven 3.8.8):**81 个源文件 BUILD SUCCESS**(含本轮全部修改) |
| 前端构建 | `vite build --mode base`:**✓ built in 16.94s**(含本轮全部修改;chunk 体积警告为既有问题) |
| 后端本地启动 | `mvn spring-boot:run`(8104)✅:Nacos/Rabbit 走关闭兜底配置;MySQL 5.7(localhost:3306/family_activity)**8 张表全部自动建成**(fa_activity / fa_activity_read / fa_activity_signup / operation_log + 保留表);`/api/dict/List` 302 跳 CAS(SSO 过滤器工作正常) |
| 浏览器冒烟(mock 模式,GUI 黑盒) | ① 发布页完整渲染(P0 坏导入修复生效,附件上传组件正常);② 定时发布弹窗**不选时间点确定被拦截**(内联红字"请选择定时发布时间",修复前会静默立即发布);③ **报名截止时间早于当前时间被拦截**(新增校验生效);④ **立即发布全链路打通**:发布成功 → 跳回列表 → 新活动数据正确;⑤ 家长端列表分桶计数正确(7/3/3/1)、详情打开正常、**我要报名按钮可用(canSignup)**;⑥ 报名表单**清空必填项提交被拦截**(内联红字 + 弹窗不关闭,修复前空表单可直接提交) |
| 真实环境登录联调 | ✅ **已完成(教师端全闭环,账号 lijing)**:① 此前"CAS 404"判断有误——CAS context path 已从 `/cas` 迁移为根路径(`/login`),本地后端经 Nacos 共享配置自动对齐,登录 → 回调 → SetRole → 重定向回前端全链路打通;② 登录后曾报 `NoClassDefFoundError: YschBaseOpenApi`——平台 9/16 新快照把该类从 `openapi.client` 迁至 `openapi.base`,已同步更新 `ContactsService`/`UserCenterService` 的 import(本地 .m2 的 yh-* SNAPSHOT 元数据缓存曾解析到 8 月旧包,已刷新至 9/16 最新并清理缓存元数据);③ **真实数据闭环**:发布活动(真实字典"运动/发文部"+ bschclass 真实班级高一~高三 29 班)→ 发布成功 → 按真实学生**预生成 36 条回执**(含学生"克格莫")→ 报名情况页按班级分组呈现 → 导出接口返回 `{csv}` JSON(BOM + 完整数据)契约正确;④ 家长端路由整页加载自动切 parent 视角,无学生班级账号显示空列表(可见性隔离生效) |
| 家长账号报名闭环 | ⚠️ 未完成:`kegemo/csxs1` 被 CAS 拒绝("用户名或密码错误",多次重试一致)——确认该家长账号有效凭据后即可走通(学生"克格莫"所在班级已在活动接收范围,回执记录已预生成) |
| 平台 Logout 已知行为 | `POST /api/base/Logout` 返回成功但**未失效 HttpSession**(平台实现仅清上下文),切换账号需 CAS logout + 后端重启(或等待 session 超时) |
### 9.5 本地联调启动方式
```bash
# 1. 后端(首次自动建表;JDK/Maven 已就位于 E:\YHXT\tools\)
cd /e/YHXT/java/FamilyActivity
JAVA_HOME="E:\\YHXT\\tools\\jdk8u504-b01" /e/YHXT/tools/apache-maven-3.8.8/bin/mvn spring-boot:run
# 2. 前端(8078,/api 代理 → localhost:8104)
cd /e/YHXT/view/FamilyActivity
npm run dev # 首次需 npm install(Node≥20 建议 --ignore-scripts,scss 由 dart-sass 编译)
# 3. 浏览器打开 http://localhost:8078 → CAS 登录(教师 lijing 验证通过;家长 kegemo 凭据待确认)
# 4. mock 演示模式:npm run dev:mock(无需后端与登录)
```
> 注意:`node-sass` 为废弃依赖(Node 24 下无法编译,`--ignore-scripts` 安装后需手动执行
> `node node_modules/esbuild/install.js` 补 esbuild 0.11.3 二进制,vite dev 才能启动);
> 项目 scss 实际由 dart-sass 编译,node-sass 可从 package.json 移除(待团队确认 Node 版本基线后处理)。
### 9.6 后续迭代同步说明(2026-09-17 下午)
§九修复落地后,项目所有者当日又做了一轮自主迭代(11:54~15:45),核心变更:**回执记录的家长标识从 `guardian_account`(账号)切换为 `student_id`(学生ID)体系**——`ActivitySignup` 实体改为 `(activity_id, student_id)` 唯一约束、仓库改用 `findByActivityIdAndStudentId / findByStudentIdAndActivityIdIn`、`ActivityService` 报名/预生成/我的回执全部按 `studentIdOf(pc)` 防重;同时清理了 `openapi.client` 旧包引用(与 §9.4 所述平台包迁移一致)。
经逐项核对,§九的关键修复在该迭代中**全部保留**:数据隔离(`delete/review/allFileDown` 补 `requireActivity`)、家长可见性 `ensureParentVisible`、报名悲观锁 `requireActivityForUpdate`、LIKE 转义、CSV 公式注入防护、receiveClasses 本校校验、超管 schId=0 拒绝发布、编辑保留置顶、`FileController` topicType=activity、导出 `{csv}` 契约等。迭代后已重新 `mvn compile` BUILD SUCCESS,并在真实环境(lijing 登录)完成 §9.4 所列闭环验证。
> 供核对:`fa_activity_signup` 实际唯一索引为 `uk_fa_signup_activity_student` 与 Hibernate 自动生成的同名约束(二者等价,冗余无害,可择机合并)。
### 9.7 字典学校隔离 + 审计列改存用户ID(2026-09-20)
两处收口变更,与 §9.6「账号 → ID」体系一脉相承:
1. **`sys_dict` 增加 `sch_id`(学校隔离)**
- 实体 `SysDict` 增 `schId`(默认 0);`SysDictRepository` 增 `findByDictTypeAndSchIdIn...` 系列(读口径为 `[当前学校, 0]`)。
- `DictService` 读写全部限「本校 + 全局种子(`sch_id=0`)」:下拉/管理列表按范围查;新增归属当前学校;改/删/排序经 `findOwned` 校验,仅允许操作本校行或全局种子行(历史 `sch_id` 为空按全局处理),跨校操作抛 `无权操作其他学校的字典`。
- `resolveDeptName` 先在本校范围内解析(本校行优先),未命中退回不区分学校的旧口径,保证历史活动仍能展示部门名。
- 迁移脚本:`src/main/resources/sql/fa_sys_dict_sch_id_and_audit.sql`(加列 + 存量置 0 + 索引 + 全局种子 + 验证查询);建表脚本 `fa_activity_tables.sql` 补 `sys_dict` DDL。
2. **`create_by` / `update_by` 由登录账号改为平台用户ID(INT 列)**
- `BaseEntity` 两字段改为 `Integer`,`NoticeAuditListener` 取 `UserContext.getUserId()`(`BoaUser.id`)直接落 INT;`UserContextHolder` 兜底仅接受纯数字 `userId`(平台默认 Provider 会放入登录名,非数字则留空)。
- `ActivityVo.createBy/updateBy` 同步改 `Integer`,JSON 输出为数字;前端契约 `types/activity.ts` 改 `number | null`(mock 演示数据置 null)。
- 历史行为账号文本,无法在本库映射为用户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 转换)。**本地库 `family_activity` 已于 2026-09-20 执行**(`sch_id` 列此前已由 ddl-auto 自动添加,脚本跳过加列直接归零回填;存量账号文本 `lijing` 置 NULL 后 14 个审计列转为 `int(11)`,并建 `idx_sys_dict_sch_type` 索引;因库中已有真实字典数据(发文部/运动,归入全局种子),未执行种子 INSERT 段)。