diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a46b5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +.idea/ +*.iml +*.log +.DS_Store diff --git a/docker/application-prod.yml b/docker/application-prod.yml new file mode 100644 index 0000000..3b608cc --- /dev/null +++ b/docker/application-prod.yml @@ -0,0 +1,52 @@ +# Notice 生产配置(线上以 nacos 的 notice.yaml 为准,本文件是 nacos 不可达时的兜底) +# 真正上线请在 Nacos 新建 dataId=notice.yaml、group=NOTICE 的配置覆盖以下项 +# (尤其 datasource 的库名/账号、yh.sso.client-host-url)。 +server: + port: 8070 + servlet: + # 对外经 caddy 暴露为 http://base.yunhaixingtong.com/notice + context-path: /notice + +spring: + mvc: + date-format: yyyy-MM-dd HH:mm:ss + jackson: + time-zone: GMT+8 + date-format: yyyy-MM-dd HH:mm:ss + datasource: + # 与 UserBase 共用同一台 mysql8;库名为 family_activity(需先在 mysql8 上建库) + url: jdbc:mysql://mysql8:3306/family_activity?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: root + password: 'CVf^x$sW*b6CGHxh' + driver-class-name: com.mysql.cj.jdbc.Driver + min-idle: 10 + max-active: 30 + jpa: + database: MySQL + show-sql: false + hibernate: + ddl-auto: update + database-platform: org.hibernate.dialect.MySQL5InnoDBDialect + properties: + hibernate.order_by.default_null_ordering: last + open-in-view: true + +# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) +yh: + sso: + server-url-prefix: http://sso.yunhaixingtong.com + client-host-url: http://base.yunhaixingtong.com/notice + allow-headers: all + allow-origins: all + url-pattern: /api/* + app-auth: + enabled: true + # 本应用自身身份(客户端签名用) + app-key: 100617 + app-secret: 2a3b4c5d6e7f8091a2b3c4d5e6f70819 + # 受信应用(服务端验签用):appKey -> appSecret + trusted-apps: + 100617: 2a3b4c5d6e7f8091a2b3c4d5e6f70819 + +# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data +FILE_PATH: /data/upload diff --git a/docker/run-app.sh b/docker/run-app.sh new file mode 100644 index 0000000..1da625f --- /dev/null +++ b/docker/run-app.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Notice 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml) +# 不再需要手动 curl 拉取;若 nacos 不可达,bootstrap 中 fail-fast=false 会自动回退到 +# 镜像内烤好的 application-prod.yml 兜底默认。 +set -e + +echo "[run-app] starting Notice (profile=prod, nacos=${NACOS_ADDR:-nacos.yunhaixingtong.com:8848}) ..." +exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/notice.jar \ + --spring.profiles.active=prod diff --git a/docs/后端重构计划-活动信息发布.md b/docs/后端重构计划-活动信息发布.md new file mode 100644 index 0000000..bbdcbbc --- /dev/null +++ b/docs/后端重构计划-活动信息发布.md @@ -0,0 +1,431 @@ +# 活动信息发布模块 · 后端重构计划 + +> 项目:`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) | **班级粒度已读**(前端关键约定 4:`classRead`) | +| account | varchar(50) | 操作账号(家长=学生账号) | +| readTime | datetime | 阅读时间 | + +唯一约束 `(activity_id, class_id)`;应收/已读人数按班级 `studentCount` 累加(班级与学生数取自 `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`。`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 | 回执名单(按班级分组+汇总) | +| 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 自动生成的同名约束(二者等价,冗余无害,可择机合并)。 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..3df013b --- /dev/null +++ b/pom.xml @@ -0,0 +1,198 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.yunhai + notice + 0.0.1 + FamilyActivity + 武校家校信息发布 · 活动信息发布子应用 + + 1.8 + + + + + + + com.yunhai.platform + yh-platform-dependencies + 1.0.0-SNAPSHOT + pom + import + + + + com.alibaba.cloud + spring-cloud-alibaba-dependencies + 2021.0.5.0 + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.security + spring-security-crypto + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + + org.springframework.cloud + spring-cloud-starter-bootstrap + 3.1.8 + + + + com.mysql + mysql-connector-j + runtime + + + + org.projectlombok + lombok + true + + + + + com.alibaba + fastjson + 1.2.80 + + + + + org.springdoc + springdoc-openapi-ui + 1.7.0 + + + + + com.yunhai.platform + yh-spring-boot-starter + + + + + org.jasig.cas.client + cas-client-core + 3.5.1 + + + + + com.alibaba + easyexcel + 3.3.2 + + + + + + + + notice + + + org.apache.maven.plugins + maven-compiler-plugin + + + false + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.yunhai.familyactivity.FamilyActivityApplication + + + + + repackage + + + + + + + + + + + nexus-public + bsch/nexus public + http://nexus.yunhaixingtong.com/repository/maven-public/ + true + true + + + + + + ci + + C:/notice-build/notice + + + + + diff --git a/src/main/java/com/yunhai/familyactivity/FamilyActivityApplication.java b/src/main/java/com/yunhai/familyactivity/FamilyActivityApplication.java new file mode 100644 index 0000000..f601ac2 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/FamilyActivityApplication.java @@ -0,0 +1,18 @@ +package com.yunhai.familyactivity; + +import com.alibaba.fastjson.parser.ParserConfig; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +@SpringBootApplication(scanBasePackages = "com.yunhai.familyactivity") +@EnableTransactionManagement +public class FamilyActivityApplication { + + public static void main(String[] args) { + // 开启 fastjson 安全模式 + ParserConfig.getGlobalInstance().setSafeMode(true); + SpringApplication.run(FamilyActivityApplication.class, args); + } + +} diff --git a/src/main/java/com/yunhai/familyactivity/annotation/OperationLog.java b/src/main/java/com/yunhai/familyactivity/annotation/OperationLog.java new file mode 100644 index 0000000..ddd2a47 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/annotation/OperationLog.java @@ -0,0 +1,24 @@ +package com.yunhai.familyactivity.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 业务操作日志注解:标注在控制器/服务写操作方法上,由 + * {@link com.yunhai.familyactivity.aspect.OperationLogAspect} 落库到 operation_log。 + * 参考 UserclassService 的 @OperationLog。 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface OperationLog { + + /** 模块名称,如 "活动管理" */ + String module() default ""; + + /** 操作内容摘要(支持静态文案;动态细节由切面附加请求参数) */ + String content() default ""; +} diff --git a/src/main/java/com/yunhai/familyactivity/aspect/ControllerUserContextAspect.java b/src/main/java/com/yunhai/familyactivity/aspect/ControllerUserContextAspect.java new file mode 100644 index 0000000..e9337c0 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/aspect/ControllerUserContextAspect.java @@ -0,0 +1,158 @@ +package com.yunhai.familyactivity.aspect; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.familyactivity.utils.UserContext; +import com.yunhai.platform.auth.BoaUserContext; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import com.yunhai.platform.utils.model.BoaUser; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.List; + +/** + * 控制器切面:请求进入前把当前登录用户填充到 {@link UserContext},请求结束后清理。 + *
身份来源优先级(参考 UserclassService 的 ControllerUserContextAspect):
返回裸对象,由平台 ApiResponseAdvice 自动包装为 {code, msg, data}; + * 写操作标注 {@link OperationLog} 落库 operation_log。
路径前缀:{@code Config.API_PATH + "/base/"}(原硬编码已修正)。 + * 承载平台代理接口:GetUserInfo / GetUserMod / GetSchoolAll / GetClassTree / SetRole / GetGroupUser / Logout, + * 业务逻辑已下沉 {@link IUserCenterService},本控制器仅做"取参 → 调 Service → 返回"。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。
路径前缀:{@code Config.API_PATH + "/contacts/"}(原硬编码已修正为 Config 拼接)。 + * 方法名已为 PascalCase 且写操作已是 POST,保持不动。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。
路径前缀:{@code Config.API_PATH + "/dict/"}(原 base/ 前缀已修正)。 + * 写操作一律 POST(原 DelDept/OrdbyDicIds 为 GET 写操作,已 POST 化)。 + *
type 参数:dept=主办部门(默认,兼容旧前端)、category=活动分类。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/dict/") +public class DictController { + + @Autowired + private IDictService dictService; + + /** + * 获取系统字典(下拉框用,type=1 主办部门 / type=2 活动分类)。 + */ + @GetMapping("List") + public List> list(@RequestParam(value = "type", defaultValue = "0") Integer type) { + return dictService.getSysDic(type); + } + + /** + * 获取系统字典管理列表(type=dept 主办部门 / type=category 活动分类)。 + */ + @GetMapping("ManageList") + public List> manageList(@RequestParam(value = "type", defaultValue = "dept") String type) { + return dictService.getDictMangeList(type); + } + + /** + * 添加字典项(type 缺省为主办部门)。 + */ + @PostMapping("Add") + public void add(@RequestParam("name") String name, + @RequestParam(value = "type", defaultValue = "dept") String type) { + dictService.addDict(type, name); + } + + /** + * 更新字典项。 + */ + @PostMapping("Update") + public void update( + @RequestParam("SerID") Integer serID, + @RequestParam("Name") String name, + @RequestParam(value = "Enable", required = false, defaultValue = "1") Integer enable, + @RequestParam(value = "type", defaultValue = "dept") String type) { + dictService.updateDict(type, serID, name, enable); + } + + /** + * 删除字典项(原 GET DelDept,已 POST 化)。 + */ + @PostMapping("Delete") + public void delete(@RequestParam("serid") Integer serID, + @RequestParam(value = "type", defaultValue = "dept") String type) { + dictService.delDict(type, serID); + } + + /** + * 字典项排序(原 GET OrdbyDicIds,已 POST 化)。 + */ + @PostMapping("Sort") + public void sort(@RequestParam("ids") String ids, + @RequestParam(value = "type", defaultValue = "dept") String type) { + dictService.sortDict(type, ids); + } +} diff --git a/src/main/java/com/yunhai/familyactivity/controller/api/FileController.java b/src/main/java/com/yunhai/familyactivity/controller/api/FileController.java new file mode 100644 index 0000000..6a5737f --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/controller/api/FileController.java @@ -0,0 +1,88 @@ +package com.yunhai.familyactivity.controller.api; + +import com.yunhai.familyactivity.config.Config; +import com.yunhai.familyactivity.model.UpFileResultVo; +import com.yunhai.familyactivity.model.UpFileVo; +import com.yunhai.familyactivity.service.IFileService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; + +/** + * 附件域接口(file/)。 + * 路径前缀:{@code Config.API_PATH + "/file/"}(原 base/ 前缀已修正为独立文件域)。 + * 方法名统一 PascalCase;写操作一律 POST。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/file/") +public class FileController { + + @Autowired + private IFileService fileService; + + /** + * 附件上传(单文件)。 + * 前端: POST file/Upload (form-data) + * 返回: { fileid, host, fileurl, name, type, size } + */ + @PostMapping("Upload") + public UpFileResultVo upload(@RequestParam("file") MultipartFile file) { + return fileService.uploadToResult(file, "activity"); + } + + /** + * 图片上传(多文件,返回数组)。 + * 前端: POST file/UploadImage (form-data, accept=image/*) + */ + @PostMapping("UploadImage") + public List uploadImage(@RequestParam("file") MultipartFile[] files) { + return fileService.uploadImages(files, "activity"); + } + /** + * 文件下载(浏览器直接 location.href 跳转)。 + */ + @GetMapping("Down") + public void down(@RequestParam("id") Integer id, HttpServletResponse response) { + fileService.download(id, response); + } + + /** + * 文件在线预览(inline 输出,前端取 Blob 交给 open-file-viewer 渲染)。 + */ + @GetMapping("Preview") + public void preview(@RequestParam("id") Integer id, + @RequestParam(value = "type", required = false) Integer type, + HttpServletResponse response) { + fileService.preview(id, response); + } + + /** + * 删除文件。 + */ + @PostMapping("Delete") + public void delete(@RequestParam("fileId") Integer fileId) { + fileService.delete(fileId); + } + + /** + * 查询某通知下的附件列表。 + */ + @GetMapping("List") + public List list(@RequestParam("topicId") Integer topicId) { + return fileService.listByTopic("activity", topicId); + } + + /** + * 批量绑定附件到通知。 + */ + @PostMapping("Bind") + public void bind(@RequestParam("fileIds") List fileIds, + @RequestParam("topicId") Integer topicId) { + fileService.bindToTopic(fileIds, topicId); + } + +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/familyactivity/entity/Activity.java b/src/main/java/com/yunhai/familyactivity/entity/Activity.java new file mode 100644 index 0000000..f5ae047 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/entity/Activity.java @@ -0,0 +1,122 @@ +package com.yunhai.familyactivity.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 活动主表(fa_activity)。对齐前端 ActivityVo / ActivityRequest: + * 发布对象到班级一级(receiveList="ALL" 或班级 id 逗号串),回执方式 receiptMode + * (none 仅通知 / signup 需报名 / confirm 需确认)。流程状态 actStatus 按时间推导,不落库。 + */ +@Getter +@Setter +@Entity +@Table(name = "fa_activity") +public class Activity extends BaseEntity { + + /** 学校ID(数据隔离) */ + @Column(name = "sch_id") + private Integer schId; + + /** 标题 */ + @Column(name = "title", length = 200) + private String title; + + /** 活动内容(富文本) */ + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + /** 活动分类:武术比赛 / 家长会 / 文艺汇演 / 体检 / 放假通知 / 研学实践 / 安全演练 / 其他 */ + @Column(name = "category", length = 50) + private String category; + + /** 活动地点 */ + @Column(name = "venue", length = 200) + private String venue; + + /** 活动开始时间 */ + @Column(name = "start_time") + private LocalDateTime startTime; + + /** 活动结束时间 */ + @Column(name = "end_time") + private LocalDateTime endTime; + + /** 报名截止时间 */ + @Column(name = "signup_deadline") + private LocalDateTime signupDeadline; + + /** 回执方式:none / signup / confirm */ + @Column(name = "receipt_mode", length = 16) + private String receiptMode = "none"; + + /** 名额上限(0 不限) */ + @Column(name = "quota") + private Integer quota = 0; + + /** 是否允许家长随行:0 否 / 1 是 */ + @Column(name = "allow_accompany") + private Integer allowAccompany = 0; + + /** 活动费用(元) */ + @Column(name = "cost", precision = 10, scale = 2) + private BigDecimal cost = BigDecimal.ZERO; + + /** 主办部门ID(sys_dict.dict_key) */ + @Column(name = "dept_id", length = 50) + private String deptId; + + /** 主办部门名称(冗余) */ + @Column(name = "dept_name", length = 100) + private String deptName; + + /** 发布人ID(平台用户ID;家长端=学生ID) */ + @Column(name = "publisher_id") + private Integer publisherId; + + /** 发布人姓名 */ + @Column(name = "publisher_name", length = 50) + private String publisherName; + + /** 发送状态:0 草稿 / 1 已发布 */ + @Column(name = "status") + private Integer status = 0; + + /** 置顶:0 否 / 1 是 */ + @Column(name = "topmost") + private Integer topmost = 0; + + /** 撤回:0 否 / 1 是(撤回后家长端不可见) */ + @Column(name = "is_pulled") + private Integer isPulled = 0; + + /** 定时发送时间 */ + @Column(name = "timing_send_time") + private LocalDateTime timingSendTime; + + /** 短信发送标记:0 否 / 1 是(仅存储,催办通道本期占位) */ + @Column(name = "sms_flag") + private Integer smsFlag = 0; + + /** 接收对象类型:class=班级 */ + @Column(name = "receive_type", length = 16) + private String receiveType = "class"; + + /** 接收列表:"ALL" 或班级 id 逗号串 */ + @Column(name = "receive_list", columnDefinition = "TEXT") + private String receiveList; + + /** 转发/复用的源活动ID */ + @Column(name = "source_id") + private Integer sourceId; + + public boolean isPublished() { + return status != null && status == 1; + } +} diff --git a/src/main/java/com/yunhai/familyactivity/entity/ActivityRead.java b/src/main/java/com/yunhai/familyactivity/entity/ActivityRead.java new file mode 100644 index 0000000..283a21f --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/entity/ActivityRead.java @@ -0,0 +1,39 @@ +package com.yunhai.familyactivity.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +/** + * 活动已读记录(fa_activity_read)。 + * 已读按「班级」粒度(前端 classRead 约定):应收/已读人数按班级 studentCount 累加; + * 同一活动同一班级只有一条记录,由唯一约束兜底。 + */ +@Getter +@Setter +@Entity +@Table(name = "fa_activity_read", + uniqueConstraints = @UniqueConstraint(columnNames = {"activity_id", "class_id"})) +public class ActivityRead extends BaseEntity { + + /** 活动ID */ + @Column(name = "activity_id", nullable = false) + private Integer activityId; + + /** 班级ID */ + @Column(name = "class_id", length = 50, nullable = false) + private String classId; + + /** 首读学生ID(家长端以学生身份打开,存学生ID) */ + @Column(name = "student_id") + private Integer studentId; + + /** 阅读时间 */ + @Column(name = "read_time") + private LocalDateTime readTime; +} diff --git a/src/main/java/com/yunhai/familyactivity/entity/ActivitySignup.java b/src/main/java/com/yunhai/familyactivity/entity/ActivitySignup.java new file mode 100644 index 0000000..7d2a571 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/entity/ActivitySignup.java @@ -0,0 +1,71 @@ +package com.yunhai.familyactivity.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +/** + * 活动回执/报名记录(fa_activity_signup)。对齐前端 SignupBrief / SignupDetailVo: + * 一个学生对一个活动一条回执(可反复修改),唯一约束 (activity_id, student_id) 兜底。 + * 发布时按接收班级×学生预生成(status=none),保证应收人数、未回执名单可查。 + */ +@Getter +@Setter +@Entity +@Table(name = "fa_activity_signup", + uniqueConstraints = @UniqueConstraint(columnNames = {"activity_id", "student_id"})) +public class ActivitySignup extends BaseEntity { + + /** 活动ID */ + @Column(name = "activity_id", nullable = false) + private Integer activityId; + + /** 学校ID(数据隔离) */ + @Column(name = "sch_id") + private Integer schId; + + /** 学生ID */ + @Column(name = "student_id") + private Integer studentId; + + /** 学生姓名 */ + @Column(name = "student_name", length = 50) + private String studentName; + + /** 班级ID */ + @Column(name = "class_id", length = 50) + private String classId; + + /** 班级名称 */ + @Column(name = "class_name", length = 100) + private String className; + + /** 家长姓名 */ + @Column(name = "guardian_name", length = 50) + private String guardianName; + + /** 联系电话 */ + @Column(name = "guardian_phone", length = 20) + private String guardianPhone; + + /** 参加人数(含学生本人) */ + @Column(name = "join_count") + private Integer joinCount = 1; + + /** 回执状态:none / signed / confirmed / canceled */ + @Column(name = "status", length = 16) + private String status = "none"; + + /** 备注(过敏史、随行人数说明等) */ + @Column(name = "remark", length = 500) + private String remark; + + /** 回执时间 */ + @Column(name = "signed_at") + private LocalDateTime signedAt; +} diff --git a/src/main/java/com/yunhai/familyactivity/entity/BaseEntity.java b/src/main/java/com/yunhai/familyactivity/entity/BaseEntity.java new file mode 100644 index 0000000..81646dd --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/entity/BaseEntity.java @@ -0,0 +1,49 @@ +package com.yunhai.familyactivity.entity; + +import com.yunhai.familyactivity.common.listener.NoticeAuditListener; +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.*; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 所有实体的公共基类:自增主键 + 创建人/时间 + 更新人/时间(由 {@link NoticeAuditListener} 自动填充)。 + */ +@MappedSuperclass +@Getter +@Setter +@EntityListeners(NoticeAuditListener.class) +public abstract class BaseEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + /** + * 创建人登录账号 + */ + @Column(name = "create_by", updatable = false) + private String createBy; + + /** + * 创建时间 + */ + @Column(name = "create_time", updatable = false) + private LocalDateTime createTime; + + /** + * 更新人登录账号 + */ + @Column(name = "update_by") + private String updateBy; + + /** + * 更新时间 + */ + @Column(name = "update_time") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/yunhai/familyactivity/entity/ContactsGroup.java b/src/main/java/com/yunhai/familyactivity/entity/ContactsGroup.java new file mode 100644 index 0000000..e37fadd --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/entity/ContactsGroup.java @@ -0,0 +1,28 @@ +package com.yunhai.familyactivity.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 联系组。对应原 .NET Contacts_Group 表。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "contacts_group") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class ContactsGroup extends BaseEntity { + + /** 组名称 */ + @Column(name = "group_name", length = 200) + private String groupName; + + /** 创建人ID(平台用户ID) */ + @Column(name = "creator_id") + private Integer creatorId; +} diff --git a/src/main/java/com/yunhai/familyactivity/entity/ContactsGroupUser.java b/src/main/java/com/yunhai/familyactivity/entity/ContactsGroupUser.java new file mode 100644 index 0000000..522d417 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/entity/ContactsGroupUser.java @@ -0,0 +1,35 @@ +package com.yunhai.familyactivity.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** + * 联系组-用户关联。对应原 .NET Contacts_Tea 表。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "contacts_group_user", + uniqueConstraints = @UniqueConstraint(name = "uk_group_user", + columnNames = {"group_id", "user_id"})) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class ContactsGroupUser extends BaseEntity { + + /** 联系组ID */ + @Column(name = "group_id", nullable = false) + private Integer groupId; + + /** 用户ID(基础平台用户ID) */ + @Column(name = "user_id", nullable = false) + private Integer userId; + + /** 用户姓名 */ + @Column(name = "user_name", length = 64) + private String userName; +} diff --git a/src/main/java/com/yunhai/familyactivity/entity/OperationLogRecord.java b/src/main/java/com/yunhai/familyactivity/entity/OperationLogRecord.java new file mode 100644 index 0000000..f88efec --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/entity/OperationLogRecord.java @@ -0,0 +1,57 @@ +package com.yunhai.familyactivity.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import java.time.LocalDateTime; + +/** + * 业务操作日志(表 operation_log)。 + * 参考 UserclassService {@code com.yunhai.bschclassmanage.entity.OperationLogRecord}: + * 由 {@link com.yunhai.familyactivity.aspect.OperationLogAspect} 从 {@link com.yunhai.familyactivity.utils.UserContext} + * 取操作人/IP/学校写入,与系统审计日志物理隔离。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "operation_log") +public class OperationLogRecord { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 操作人平台用户ID */ + private Integer operatorId; + + /** 操作时间 */ + private LocalDateTime operateTime; + + /** 操作人姓名 */ + private String operator; + + /** 客户端IP */ + private String ip; + + /** 模块名称(如 "活动管理") */ + private String module; + + /** 操作内容(含关键参数摘要,超长截断) */ + private String content; + + /** 学校ID(数据隔离;超管=0) */ + private Integer schId; + + /** 请求URI */ + private String requestUri; + + /** 请求方法 GET/POST */ + private String requestMethod; +} diff --git a/src/main/java/com/yunhai/familyactivity/entity/SysDict.java b/src/main/java/com/yunhai/familyactivity/entity/SysDict.java new file mode 100644 index 0000000..8e49bbe --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/entity/SysDict.java @@ -0,0 +1,40 @@ +package com.yunhai.familyactivity.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 系统字典(发文部门等)。对应原 .NET Common_DeptDic 表。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "sys_dict") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SysDict extends BaseEntity { + + /** 字典类型(如 "dept"=发文部门) */ + @Column(name = "dict_type", length = 50) + private String dictType; + + /** 字典 key(原 CodeID) */ + @Column(name = "dict_key", length = 100) + private String dictKey; + + /** 字典 value(显示名称,原 Name) */ + @Column(name = "dict_value", length = 200) + private String dictValue; + + /** 排序号 */ + @Column(name = "sort_order") + private Integer sortOrder = 0; + + /** 是否启用:0 禁用 / 1 启用(原 Enable) */ + @Column(name = "enable_flag") + private Integer enableFlag = 1; +} diff --git a/src/main/java/com/yunhai/familyactivity/entity/UpFile.java b/src/main/java/com/yunhai/familyactivity/entity/UpFile.java new file mode 100644 index 0000000..0156a6d --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/entity/UpFile.java @@ -0,0 +1,47 @@ +package com.yunhai.familyactivity.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 附件文件记录。对应原 .NET Common_UpFiles 表。 + * + * 存储文件元信息(真实文件名、存储路径、大小、类型),物理文件存储在 + * application.yml 的 file.upload-path 目录下。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "up_file") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class UpFile extends BaseEntity { + + /** 应用类型(如 "notice"),用于区分不同业务的附件 */ + @Column(name = "topic_type", length = 50) + private String topicType; + + /** 关联主题ID(如通知 serid) */ + @Column(name = "topic_id") + private Integer topicId; + + /** 文件真实名称(用户上传时的原始文件名) */ + @Column(name = "file_true_name", length = 255) + private String fileTrueName; + + /** 文件存储路径(相对路径,如 notice/20260823/uuid.pdf) */ + @Column(name = "file_name", length = 500) + private String fileName; + + /** 文件大小(字节) */ + @Column(name = "file_size") + private Integer fileSize; + + /** 文件后缀名(如 pdf, docx, jpg) */ + @Column(name = "file_type", length = 20) + private String fileType; +} diff --git a/src/main/java/com/yunhai/familyactivity/enums/ActivityStatusEnum.java b/src/main/java/com/yunhai/familyactivity/enums/ActivityStatusEnum.java new file mode 100644 index 0000000..e2752e5 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/enums/ActivityStatusEnum.java @@ -0,0 +1,69 @@ +package com.yunhai.familyactivity.enums; + +/** + * 活动流程状态(对齐前端 ActivityStatus)。 + * + * draft 草稿 → signup 报名中 → closed 已截止 → finished 已结束 + * + * closed/finished 按当前时间与报名截止/结束时间动态推导,不落库。 + */ +public enum ActivityStatusEnum { + + DRAFT("draft", "草稿"), + SIGNUP("signup", "报名中"), + CLOSED("closed", "已截止"), + FINISHED("finished", "已结束"); + + private final String code; + private final String desc; + + ActivityStatusEnum(String code, String desc) { + this.code = code; + this.desc = desc; + } + + public String getCode() { + return code; + } + + public String getDesc() { + return desc; + } + + public static ActivityStatusEnum of(String code) { + if (code != null) { + for (ActivityStatusEnum e : values()) { + if (e.code.equalsIgnoreCase(code)) { + return e; + } + } + } + return null; + } + + /** + * 推导流程状态。 + * + * @param published 是否已发布(false=草稿) + * @param receiptMode 回执方式(none 仅通知无报名语义) + * @param signupDeadline 报名截止时间(可空) + * @param endTime 活动结束时间(可空) + * @param now 当前时间 + */ + public static ActivityStatusEnum derive(boolean published, String receiptMode, + java.time.LocalDateTime signupDeadline, + java.time.LocalDateTime endTime, + java.time.LocalDateTime now) { + if (!published) { + return DRAFT; + } + if (endTime != null && !now.isBefore(endTime)) { + return FINISHED; + } + boolean hasReceipt = receiptMode != null && !"none".equalsIgnoreCase(receiptMode); + if (hasReceipt && signupDeadline != null && !now.isBefore(signupDeadline)) { + return CLOSED; + } + return SIGNUP; + } +} diff --git a/src/main/java/com/yunhai/familyactivity/enums/ReceiptModeEnum.java b/src/main/java/com/yunhai/familyactivity/enums/ReceiptModeEnum.java new file mode 100644 index 0000000..cf2a08b --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/enums/ReceiptModeEnum.java @@ -0,0 +1,43 @@ +package com.yunhai.familyactivity.enums; + +/** + * 回执方式(对齐前端 ReceiptMode)。 + * + * none 仅通知(阅读即知悉,只统计已读) + * signup 需报名(家长填报参加人数,可带随行) + * confirm 需确认(家长端打开活动即自动确认) + * + */ +public enum ReceiptModeEnum { + + NONE("none", "仅通知"), + SIGNUP("signup", "需报名"), + CONFIRM("confirm", "需确认"); + + private final String code; + private final String desc; + + ReceiptModeEnum(String code, String desc) { + this.code = code; + this.desc = desc; + } + + public String getCode() { + return code; + } + + public String getDesc() { + return desc; + } + + public static ReceiptModeEnum of(String code) { + if (code != null) { + for (ReceiptModeEnum e : values()) { + if (e.code.equalsIgnoreCase(code)) { + return e; + } + } + } + return NONE; + } +} diff --git a/src/main/java/com/yunhai/familyactivity/enums/SignupStatusEnum.java b/src/main/java/com/yunhai/familyactivity/enums/SignupStatusEnum.java new file mode 100644 index 0000000..8ba271a --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/enums/SignupStatusEnum.java @@ -0,0 +1,51 @@ +package com.yunhai.familyactivity.enums; + +/** + * 单条回执(报名)状态(对齐前端 SignupStatus)。 + */ +public enum SignupStatusEnum { + + NONE("none", "未回执"), + SIGNED("signed", "已报名"), + CONFIRMED("confirmed", "已确认"), + CANCELED("canceled", "已取消"); + + private final String code; + private final String desc; + + SignupStatusEnum(String code, String desc) { + this.code = code; + this.desc = desc; + } + + public String getCode() { + return code; + } + + public String getDesc() { + return desc; + } + + public static SignupStatusEnum of(String code) { + if (code != null) { + for (SignupStatusEnum e : values()) { + if (e.code.equalsIgnoreCase(code)) { + return e; + } + } + } + return NONE; + } + + /** 严格匹配(区分于 of() 的 NONE 兜底),用于请求参数校验 */ + public static boolean isValid(String code) { + if (code != null) { + for (SignupStatusEnum e : values()) { + if (e.code.equalsIgnoreCase(code)) { + return true; + } + } + } + return false; + } +} diff --git a/src/main/java/com/yunhai/familyactivity/model/BasePageQuery.java b/src/main/java/com/yunhai/familyactivity/model/BasePageQuery.java new file mode 100644 index 0000000..e009323 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/BasePageQuery.java @@ -0,0 +1,14 @@ +package com.yunhai.familyactivity.model; + +import lombok.Data; + +/** + * 统一分页查询入参。pageIndex 从 1 起。 + */ +@Data +public class BasePageQuery { + private int pageIndex = 1; + private int pageSize = 10; + /** 通用模糊搜索关键词 */ + private String keyword; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/BasePageResult.java b/src/main/java/com/yunhai/familyactivity/model/BasePageResult.java new file mode 100644 index 0000000..a368226 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/BasePageResult.java @@ -0,0 +1,33 @@ +package com.yunhai.familyactivity.model; + +import lombok.Data; + +import java.util.List; + +/** + * 统一分页响应外壳。 + */ +@Data +public class BasePageResult { + /** 总记录数 */ + private int total; + /** 当前页数据 */ + private List content; + /** 当前页码(从 1 起) */ + private int pageIndex; + /** 每页条数 */ + private int pageSize; + /** 总页数 */ + private int totalPages; + /** 是否还有下一页 */ + private boolean hasNext; + + public BasePageResult(List content, int total, int pageIndex, int pageSize, int totalPages, boolean hasNext) { + this.content = content; + this.total = total; + this.pageIndex = pageIndex; + this.pageSize = pageSize; + this.totalPages = totalPages; + this.hasNext = hasNext; + } +} diff --git a/src/main/java/com/yunhai/familyactivity/model/SchTeaList.java b/src/main/java/com/yunhai/familyactivity/model/SchTeaList.java new file mode 100644 index 0000000..7ccc3c9 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/SchTeaList.java @@ -0,0 +1,106 @@ +package com.yunhai.familyactivity.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * 班级管理接口-教师(bschclass {@code /oapi/DbClass/GetSchteaList} 响应项)。 + * 对应 C# BestSchool.Entity.Model.SchTeaList。 + */ +public class SchTeaList { + + /** 学校 GUID(SchSerID) */ + @JsonProperty("SchSerID") + private String schSerID; + + @JsonProperty("TeaTel") + private String teaTel; + + /** 教师姓名 */ + @JsonProperty("TeaName") + private String teaName; + + /** 教师用户ID */ + @JsonProperty("UserTeaSerID") + private Integer userTeaSerID; + + @JsonProperty("TeaIDCard") + private String teaIDCard; + + /** 教师工号 */ + @JsonProperty("TeaNo") + private String teaNo; + + /** 教师所属组ID列表(兼容旧字段 GroupIds) */ + @JsonProperty("GroupIds") + private List groupIds; + + /** 教师所属组ID(接口现行字段 GroupID,单个) */ + @JsonProperty("GroupID") + private Integer groupId; + + public Integer getGroupId() { + return groupId; + } + + public void setGroupId(Integer groupId) { + this.groupId = groupId; + } + + public String getSchSerID() { + return schSerID; + } + + public void setSchSerID(String schSerID) { + this.schSerID = schSerID; + } + + public String getTeaTel() { + return teaTel; + } + + public void setTeaTel(String teaTel) { + this.teaTel = teaTel; + } + + public String getTeaName() { + return teaName; + } + + public void setTeaName(String teaName) { + this.teaName = teaName; + } + + public Integer getUserTeaSerID() { + return userTeaSerID; + } + + public void setUserTeaSerID(Integer userTeaSerID) { + this.userTeaSerID = userTeaSerID; + } + + public String getTeaIDCard() { + return teaIDCard; + } + + public void setTeaIDCard(String teaIDCard) { + this.teaIDCard = teaIDCard; + } + + public String getTeaNo() { + return teaNo; + } + + public void setTeaNo(String teaNo) { + this.teaNo = teaNo; + } + + public List getGroupIds() { + return groupIds; + } + + public void setGroupIds(List groupIds) { + this.groupIds = groupIds; + } +} diff --git a/src/main/java/com/yunhai/familyactivity/model/UpFileResultVo.java b/src/main/java/com/yunhai/familyactivity/model/UpFileResultVo.java new file mode 100644 index 0000000..f55a2e0 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/UpFileResultVo.java @@ -0,0 +1,30 @@ +package com.yunhai.familyactivity.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 文件上传结果视图对象(替代原 Map<String,Object> 返回)。 + * 使用 @JsonProperty 保持旧字段名兼容前端渲染。 + */ +@Data +public class UpFileResultVo { + + @JsonProperty("fileid") + private Integer fileid; + + @JsonProperty("name") + private String name; + + @JsonProperty("fileurl") + private String fileurl; + + @JsonProperty("host") + private String host; + + @JsonProperty("type") + private String type; + + @JsonProperty("size") + private Integer size; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/UpFileVo.java b/src/main/java/com/yunhai/familyactivity/model/UpFileVo.java new file mode 100644 index 0000000..9b730b7 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/UpFileVo.java @@ -0,0 +1,31 @@ +package com.yunhai.familyactivity.model; + +import lombok.Data; + +/** + * 附件视图对象(对应原 .NET FileObj)。 + */ +@Data +public class UpFileVo { + + /** 文件记录ID */ + private Integer id; + + /** 文件真实名称 */ + private String fileTrueName; + + /** 文件存储路径(相对路径) */ + private String fileName; + + /** 文件大小(字节) */ + private Integer fileSize; + + /** 文件后缀名 */ + private String fileType; + + /** 关联主题ID */ + private Integer topicId; + + /** 应用类型 */ + private String topicType; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/UserModuleVo.java b/src/main/java/com/yunhai/familyactivity/model/UserModuleVo.java new file mode 100644 index 0000000..1aa50a8 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/UserModuleVo.java @@ -0,0 +1,122 @@ +package com.yunhai.familyactivity.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * 用户可访问模块(左菜单)视图对象。 + * 将基础平台返回的扁平 {@code List} 转换为前端 layout(参考 UserBase)期望的树结构: + * {@code modId}(路由高亮匹配)/{@code page}(跳转路径)/{@code children}(子菜单)。 + */ +public class UserModuleVo { + + /** 模块主键 id(基础平台 module id) */ + private Integer id; + + /** 菜单高亮匹配用:等价于后端模块 id */ + private Integer modId; + + /** 模块名称 */ + private String name; + + /** 父级模块 id(0 表示根节点) */ + private Integer parentId; + + /** 前端路由路径(由 BoaModule.path 映射而来) */ + private String page; + + /** 层级(0=根分组) */ + private Integer level; + + /** 菜单图标 */ + private String icon; + + /** 排序号 */ + private Integer orderNumber; + + /** 是否可用 */ + private Boolean usable; + + /** 子菜单 */ + private List children = new ArrayList<>(); + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Integer getModId() { + return modId; + } + + public void setModId(Integer modId) { + this.modId = modId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Integer getParentId() { + return parentId; + } + + public void setParentId(Integer parentId) { + this.parentId = parentId; + } + + public String getPage() { + return page; + } + + public void setPage(String page) { + this.page = page; + } + + public Integer getLevel() { + return level; + } + + public void setLevel(Integer level) { + this.level = level; + } + + public String getIcon() { + return icon; + } + + public void setIcon(String icon) { + this.icon = icon; + } + + public Integer getOrderNumber() { + return orderNumber; + } + + public void setOrderNumber(Integer orderNumber) { + this.orderNumber = orderNumber; + } + + public Boolean getUsable() { + return usable; + } + + public void setUsable(Boolean usable) { + this.usable = usable; + } + + public List getChildren() { + return children; + } + + public void setChildren(List children) { + this.children = children; + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/ActivityDetailFileItem.java b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityDetailFileItem.java new file mode 100644 index 0000000..b396cb2 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityDetailFileItem.java @@ -0,0 +1,24 @@ +package com.yunhai.familyactivity.model.activity; + +import lombok.Data; + +/** + * 活动详情附件项(对齐前端 ActivityDetailFileItem)。 + */ +@Data +public class ActivityDetailFileItem { + + private Integer id; + + /** 显示名 */ + private String name; + + /** 文件大小(字节) */ + private Long size; + + /** 文件记录ID */ + private Integer fileid; + + /** 存储文件名 */ + private String filename; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/ActivityDetailVo.java b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityDetailVo.java new file mode 100644 index 0000000..12cb38c --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityDetailVo.java @@ -0,0 +1,111 @@ +package com.yunhai.familyactivity.model.activity; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +/** + * 活动详情视图对象(对齐前端 ActivityDetailVo)。 + * 保留旧字段名(Contents / DeptID / DeptName / TeaName / EditDate / DsfsTime / ChaoSend), + * 便于复用既有详情排版。 + */ +@Data +public class ActivityDetailVo { + + /** 活动 id(旧字段名 serid) */ + @JsonProperty("serid") + private Integer serid; + + private String title; + + /** 活动内容(旧字段名 Contents) */ + @JsonProperty("Contents") + private String contents; + + /** 主办部门ID(旧字段名 DeptID) */ + @JsonProperty("DeptID") + private String deptId; + + /** 主办部门名称(旧字段名 DeptName) */ + @JsonProperty("DeptName") + private String deptName; + + /** 发布人姓名(旧字段名 TeaName) */ + @JsonProperty("TeaName") + private String teaName; + + /** 发布时间(旧字段名 EditDate,已格式化) */ + @JsonProperty("EditDate") + private String editDate; + + /** 应读人数 */ + @JsonProperty("numcount") + private long numcount; + + /** 已读人数 */ + @JsonProperty("readnumcount") + private long readnumcount; + + /** 接收列表(旧字段名):"ALL" 或 班级 id 逗号串 */ + @JsonProperty("ReceiveList") + private String receiveList; + + /** 定时发送时间(旧字段名 DsfsTime) */ + @JsonProperty("DsfsTime") + private String dsfsTime; + + /** 是否定时发送 */ + @JsonProperty("isDsfs") + private boolean dsfs; + + /** 活动分类 */ + private String category; + + /** 活动地点 */ + private String venue; + + /** 活动开始时间 */ + private String startTime; + + /** 活动结束时间 */ + private String endTime; + + /** 报名截止时间 */ + private String signupDeadline; + + /** 回执方式 */ + private String receiptMode; + + private String receiptModeDesc; + + /** 名额上限(0 不限) */ + private Integer quota; + + /** 是否允许家长随行 */ + private Integer allowAccompany; + + /** 活动费用(元) */ + private java.math.BigDecimal cost; + + /** 活动流程状态 */ + private String actStatus; + + private String actStatusDesc; + + /** 回执/报名进度(草稿或仅通知为 null) */ + private ActivityProgress progress; + + /** 附件列表(旧字段名 ChaoSend) */ + @JsonProperty("ChaoSend") + private List chaoSend; + + /** 接收对象列表:"all" 或 班级 id 数组 */ + private Object tlist; + + /** 接收对象类型:class=班级 */ + private String receiveType; + + /** 接收对象可读文本 */ + private String receiveText; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/ActivityPageResult.java b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityPageResult.java new file mode 100644 index 0000000..f7e4fcd --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityPageResult.java @@ -0,0 +1,24 @@ +package com.yunhai.familyactivity.model.activity; + +import com.yunhai.familyactivity.model.BasePageResult; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +/** + * 活动分页结果(对齐前端 ActivityPageResult:分页外壳 + 当前用户未读总数)。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class ActivityPageResult extends BasePageResult { + + /** 当前用户未读活动总数 */ + private long unreadCount; + + public ActivityPageResult(List content, int total, int pageIndex, int pageSize, + int totalPages, boolean hasNext, long unreadCount) { + super(content, total, pageIndex, pageSize, totalPages, hasNext); + this.unreadCount = unreadCount; + } +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/ActivityProgress.java b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityProgress.java new file mode 100644 index 0000000..0ae3bbe --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityProgress.java @@ -0,0 +1,50 @@ +package com.yunhai.familyactivity.model.activity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 活动回执/报名进度(对齐前端 ActivityProgress)。 + * receiptMode=none 或草稿时 progress 为 null(不产生回执记录,只统计已读)。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ActivityProgress { + + /** 应收人数 */ + private int should; + + /** 已报名人数 */ + private int signed; + + /** 已确认人数 */ + private int confirmed; + + /** 已取消/请假人数 */ + private int canceled; + + /** 未回执人数 */ + private int unsigned; + + /** 回执率(%) */ + private double signupRate; + + /** 名额上限,0 表示不限 */ + private int quota; + + /** 已用名额(按随行人数合计) */ + private int usedQuota; + + /** 是否名额已满 */ + private boolean full; + + /** 待处理回执(名额超额/待定),统计用,列表场景恒为 0 */ + private int pendingReview; + + public ActivityProgress(int should, int signed, int confirmed, int canceled, int unsigned, + double signupRate, int quota, int usedQuota, boolean full) { + this(should, signed, confirmed, canceled, unsigned, signupRate, quota, usedQuota, full, 0); + } +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/ActivityQuery.java b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityQuery.java new file mode 100644 index 0000000..a69523b --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityQuery.java @@ -0,0 +1,40 @@ +package com.yunhai.familyactivity.model.activity; + +import com.yunhai.familyactivity.model.BasePageQuery; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 活动列表查询参数(对齐前端 ActivityQuery,GET query 绑定)。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class ActivityQuery extends BasePageQuery { + + /** 列表范围(管理端):"user" 只看自己发布的,不传=全部 */ + private String scope; + + /** 主办部门ID */ + private String deptId; + + /** 活动分类筛选 */ + private String category; + + /** 流程状态筛选:draft/signup/closed/finished,缺省或 all=全部 */ + private String actStatus; + + /** 发布人ID */ + private Integer publisherId; + + /** 开始时间(yyyy-MM-dd 短日期,筛选活动开始时间下界) */ + private String startTime; + + /** 结束时间(yyyy-MM-dd 短日期,筛选活动开始时间上界) */ + private String endTime; + + /** 阅读状态过滤(仅家长端):0 全部 / 1 未读 / 2 已读 */ + private Integer readType; + + /** 排序:1 时间倒序 / 2 时间正序 */ + private String orderByTime; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/ActivityRequest.java b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityRequest.java new file mode 100644 index 0000000..0d87f8a --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityRequest.java @@ -0,0 +1,86 @@ +package com.yunhai.familyactivity.model.activity; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 活动发布/编辑入参(对齐前端 ActivityRequest,@RequestBody JSON)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class ActivityRequest { + + /** 标题 */ + private String title; + + /** 活动内容(富文本) */ + private String content; + + /** 活动分类 */ + private String category; + + /** 活动地点 */ + private String venue; + + /** 活动开始时间 yyyy-MM-dd HH:mm:ss */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime startTime; + + /** 活动结束时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime endTime; + + /** 报名截止时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime signupDeadline; + + /** 回执方式:none / signup / confirm */ + private String receiptMode; + + /** 名额上限(0 不限) */ + private Integer quota; + + /** 是否允许家长随行:0 否 / 1 是 */ + private Integer allowAccompany; + + /** 活动费用(元) */ + private BigDecimal cost; + + /** 主办部门ID */ + private String deptId; + + /** 主办部门名称 */ + private String deptName; + + /** 存草稿:true 则不发布 */ + private Boolean draft; + + /** 置顶:0 否 / 1 是 */ + private Integer topmost; + + /** 接收对象:"ALL" 或 班级 id 逗号串(receiveType=class) */ + private String receiveList; + + /** 接收对象类型:class=班级 */ + private String receiveType; + + /** 附件文件ID列表(逗号分隔串) */ + private String fileIds; + + /** 定时发送时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime timingSendTime; + + /** 短信发送标记:0 否 / 1 是 */ + private Integer smsFlag; + + /** 转发/复用的源活动ID */ + private Integer sourceId; + + /** 编辑时的活动ID(前端编辑回显后提交带 serid 时使用,可空) */ + private Integer serid; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/ActivityStatisticsVo.java b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityStatisticsVo.java new file mode 100644 index 0000000..e8353bb --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityStatisticsVo.java @@ -0,0 +1,105 @@ +package com.yunhai.familyactivity.model.activity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 活动统计视图(对齐前端 ActivityStatisticsVo)。 + */ +@Data +public class ActivityStatisticsVo { + + /** 活动总数 */ + private long total; + + /** 已发布(含报名中/已截止/已结束) */ + private long publishedCount; + + /** 我发布的活动数 */ + private long myActivityCount; + + /** 累计回执/报名人次 */ + private long totalSignups; + + /** 平均回执率(%) */ + private double avgSignupRate; + + /** 待处理回执(名额超额或需人工确认) */ + private long pendingReview; + + /** 按主办部门 */ + private List byPublisher; + + /** 按月份 */ + private List byMonth; + + /** 按活动分类分布 */ + private List byCategory; + + /** 我发布的活动·回执与已读 */ + private List byActivity; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class PublisherCount { + private String publisherName; + private long count; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class MonthCount { + private int year; + private int month; + private long count; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class CategoryCount { + /** 显示名(分类文案) */ + private String label; + /** 分类 key */ + private String key; + private long count; + } + + /** 我发布的活动明细(基础信息 + ActivityProgress 展开字段) */ + @Data + public static class MyActivityItem { + private Integer id; + private String title; + private String category; + private String venue; + private String actStatus; + private String actStatusDesc; + /** 应收人数 */ + private int should; + /** 已报名人数 */ + private int signed; + /** 已确认人数 */ + private int confirmed; + /** 已取消/请假人数 */ + private int canceled; + /** 未回执人数 */ + private int unsigned; + /** 回执率(%) */ + private double signupRate; + /** 名额上限,0 表示不限 */ + private int quota; + /** 已用名额(按随行人数合计) */ + private int usedQuota; + /** 是否名额已满 */ + private boolean full; + /** 已读人数 */ + private long readCount; + /** 应读人数 */ + private long allowCount; + } +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/ActivityVo.java b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityVo.java new file mode 100644 index 0000000..381f1c0 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityVo.java @@ -0,0 +1,118 @@ +package com.yunhai.familyactivity.model.activity; + +import lombok.Data; + +import java.util.List; + +/** + * 活动视图对象(列表用,对齐前端 ActivityVo)。 + * 时间字段为格式化字符串 "yyyy-MM-dd HH:mm:ss" 或 null。 + */ +@Data +public class ActivityVo { + + private Integer id; + + private String title; + + private String content; + + /** 活动分类 */ + private String category; + + /** 活动地点 */ + private String venue; + + /** 活动开始时间 */ + private String startTime; + + /** 活动结束时间 */ + private String endTime; + + /** 报名截止时间 */ + private String signupDeadline; + + /** 回执方式 */ + private String receiptMode; + + private String receiptModeDesc; + + /** 名额上限(0 不限) */ + private Integer quota; + + /** 是否允许家长随行 */ + private Integer allowAccompany; + + /** 活动费用(元) */ + private java.math.BigDecimal cost; + + /** 发布人ID(平台用户ID;家长端=学生ID) */ + private Integer publisherId; + + private String publisherName; + + /** 主办部门 */ + private String deptId; + + private String deptName; + + /** 发送状态:0 草稿 / 1 已发布 */ + private Integer status; + + private String statusDesc; + + /** 活动流程状态 */ + private String actStatus; + + private String actStatusDesc; + + /** 置顶:0 否 / 1 是 */ + private Integer topmost; + + /** 已读人数(按班级学生数累加) */ + private long readCount; + + /** 应读人数(按班级学生数累加) */ + private long allowCount; + + /** 当前用户是否已读 */ + private boolean read; + + /** 是否撤回:0 否 / 1 是 */ + private Integer isPulled; + + /** 定时发送时间 */ + private String timingSendTime; + + /** 短信发送标记 */ + private Integer smsFlag; + + /** 接收对象类型:class=班级 */ + private String receiveType; + + /** 接收列表:"all" 或 班级 id 逗号串 */ + private String receiveList; + + /** 接收对象可读文本(列表为摘要,超长时形如「高一 1班、2班、3班等30个班级」) */ + private String receiveText; + + /** 接收对象完整可读文本(列表摘要对应的全文,供悬浮提示等场景;家长端列表不填充) */ + private String receiveTextFull; + + /** 覆盖班级数 */ + private Integer classCount; + + /** 回执/报名进度(草稿或仅通知为 null) */ + private ActivityProgress progress; + + private String createBy; + + private String createTime; + + private String updateBy; + + private String updateTime; + + /** 附件ID串(逗号分隔,列表场景不填充) */ + private List fileIds; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/InitializeRedVo.java b/src/main/java/com/yunhai/familyactivity/model/activity/InitializeRedVo.java new file mode 100644 index 0000000..1fdfede --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/InitializeRedVo.java @@ -0,0 +1,17 @@ +package com.yunhai.familyactivity.model.activity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 一键全部已读结果(对齐前端 InitializeRedVo)。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class InitializeRedVo { + + /** 本次标读的活动数 */ + private int count; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/MyActivitiesVo.java b/src/main/java/com/yunhai/familyactivity/model/activity/MyActivitiesVo.java new file mode 100644 index 0000000..5492276 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/MyActivitiesVo.java @@ -0,0 +1,39 @@ +package com.yunhai.familyactivity.model.activity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 家长端活动列表(对齐前端 MyActivitiesVo:分桶计数 + 列表)。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MyActivitiesVo { + + /** 分桶计数 */ + private Buckets buckets; + + /** 活动列表 */ + private List list; + + /** + * 分桶计数(对齐前端 buckets)。 + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Buckets { + /** 待回执 */ + private int todo; + /** 已报名 */ + private int signed; + /** 已结束 */ + private int finished; + /** 全部 */ + private int all; + } +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/MyActivityVo.java b/src/main/java/com/yunhai/familyactivity/model/activity/MyActivityVo.java new file mode 100644 index 0000000..4ca210c --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/MyActivityVo.java @@ -0,0 +1,58 @@ +package com.yunhai.familyactivity.model.activity; + +import lombok.Data; + +/** + * 家长端某活动详情(对齐前端 MyActivityVo:活动信息 + 我的报名)。 + */ +@Data +public class MyActivityVo { + + /** 活动信息 */ + private ActivityInfo activity; + + /** 我的报名(未回执为 null) */ + private MySignupInfo my; + + /** + * 活动信息(对齐前端 MyActivityVo.activity 内联对象)。 + */ + @Data + public static class ActivityInfo { + + private Integer id; + + private String title; + + private String category; + + private String venue; + + private String content; + + private String startTime; + + private String endTime; + + private String signupDeadline; + + private String receiptMode; + + private String receiptModeDesc; + + private Integer quota; + + private Integer allowAccompany; + + private java.math.BigDecimal cost; + + private String publisherName; + + private String deptName; + + /** 活动流程状态 */ + private String actStatus; + + private String actStatusDesc; + } +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/MySignupInfo.java b/src/main/java/com/yunhai/familyactivity/model/activity/MySignupInfo.java new file mode 100644 index 0000000..76515c4 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/MySignupInfo.java @@ -0,0 +1,33 @@ +package com.yunhai.familyactivity.model.activity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 家长/学生视角的报名态(对齐前端 MySignupInfo)。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MySignupInfo { + + private Integer signupId; + + /** 回执状态 */ + private String status; + + private String statusDesc; + + /** 参加人数(含学生本人) */ + private Integer joinCount; + + private String guardianName; + + private String guardianPhone; + + private String remark; + + /** 回执时间 */ + private String signedAt; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/NextPrevVo.java b/src/main/java/com/yunhai/familyactivity/model/activity/NextPrevVo.java new file mode 100644 index 0000000..4ef6057 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/NextPrevVo.java @@ -0,0 +1,18 @@ +package com.yunhai.familyactivity.model.activity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 上一篇/下一篇活动ID(对齐前端 NextPrevVo)。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class NextPrevVo { + + private Integer prevId; + + private Integer nextId; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/ParentActivityVo.java b/src/main/java/com/yunhai/familyactivity/model/activity/ParentActivityVo.java new file mode 100644 index 0000000..80ce2cd --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/ParentActivityVo.java @@ -0,0 +1,56 @@ +package com.yunhai.familyactivity.model.activity; + +import lombok.Data; + +/** + * 家长端活动项(对齐前端 ParentActivityVo)。 + */ +@Data +public class ParentActivityVo { + + private Integer id; + + private String title; + + private String category; + + private String venue; + + private String startTime; + + private String endTime; + + private String signupDeadline; + + private String receiptMode; + + private String receiptModeDesc; + + private Integer quota; + + private Integer allowAccompany; + + private java.math.BigDecimal cost; + + private String content; + + private String publisherName; + + private String deptName; + + private String createTime; + + /** 活动流程状态 */ + private String actStatus; + + private String actStatusDesc; + + /** 无需报名(仅通知) */ + private boolean viewOnly; + + /** 是否还能报名 */ + private boolean canSignup; + + /** 我的报名态(未回执为 null) */ + private MySignupInfo my; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/ReadGroup.java b/src/main/java/com/yunhai/familyactivity/model/activity/ReadGroup.java new file mode 100644 index 0000000..480e990 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/ReadGroup.java @@ -0,0 +1,44 @@ +package com.yunhai.familyactivity.model.activity; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 阅读情况分组项(对齐前端 ReadGroup,字段名固定旧口径)。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ReadGroup { + + /** 分组名(年级/班级名) */ + @JsonProperty("GroupName") + private String groupName; + + /** 组内用户读/未读明细 */ + @JsonProperty("teaslist") + private List teaslist; + + /** + * 阅读情况用户项(对齐前端 ReadGroupUser;id 为班级ID)。 + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class ReadGroupUser { + + /** 班级ID(按班级粒度展示读/未读) */ + private String id; + + /** 班级名(旧字段名 teaname) */ + @JsonProperty("teaname") + private String teaname; + + /** read / unread */ + private String states; + } +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/ReviewRequest.java b/src/main/java/com/yunhai/familyactivity/model/activity/ReviewRequest.java new file mode 100644 index 0000000..48ce4b7 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/ReviewRequest.java @@ -0,0 +1,21 @@ +package com.yunhai.familyactivity.model.activity; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +/** + * 回执审核/处理入参(对齐前端 ReviewRequest,@RequestBody JSON)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class ReviewRequest { + + /** 回执记录ID */ + private Integer signupId; + + /** confirm 确认参加 / cancel 取消或请假 / waitlist 转待定 */ + private String action; + + /** 备注 */ + private String remark; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/SignupBrief.java b/src/main/java/com/yunhai/familyactivity/model/activity/SignupBrief.java new file mode 100644 index 0000000..161fd1b --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/SignupBrief.java @@ -0,0 +1,45 @@ +package com.yunhai.familyactivity.model.activity; + +import lombok.Data; + +/** + * 回执名单项(对齐前端 SignupBrief,精简)。 + */ +@Data +public class SignupBrief { + + /** 回执记录ID */ + private Integer signupId; + + /** 学生ID */ + private Integer studentId; + + /** 学生姓名 */ + private String studentName; + + /** 班级名称 */ + private String className; + + /** 家长姓名 */ + private String guardianName; + + /** 联系电话 */ + private String guardianPhone; + + /** 参加人数(含学生本人) */ + private Integer joinCount; + + /** 回执状态:none/signed/confirmed/canceled */ + private String status; + + private String statusDesc; + + /** 回执时间 */ + private String signedAt; + + /** 是否已回执/已报名 */ + private boolean hasReceipt; + + /** 备注(如过敏史、随行人数说明) */ + private String remark; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/SignupDetailVo.java b/src/main/java/com/yunhai/familyactivity/model/activity/SignupDetailVo.java new file mode 100644 index 0000000..637458e --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/SignupDetailVo.java @@ -0,0 +1,50 @@ +package com.yunhai.familyactivity.model.activity; + +import lombok.Data; + +/** + * 单条回执详情(对齐前端 SignupDetailVo,回执详情弹窗用)。 + */ +@Data +public class SignupDetailVo { + + private Integer signupId; + + private Integer activityId; + + private String activityTitle; + + private String category; + + private Integer studentId; + + private String studentName; + + private String classId; + + private String className; + + private String guardianName; + + private String guardianPhone; + + /** 参加人数(含学生本人) */ + private Integer joinCount; + + /** 回执状态 */ + private String status; + + private String statusDesc; + + /** 回执时间 */ + private String signedAt; + + /** 是否已读 */ + private boolean read; + + /** 阅读时间 */ + private String readAt; + + /** 备注 */ + private String remark; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/SignupGroup.java b/src/main/java/com/yunhai/familyactivity/model/activity/SignupGroup.java new file mode 100644 index 0000000..fcf9551 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/SignupGroup.java @@ -0,0 +1,22 @@ +package com.yunhai.familyactivity.model.activity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 回执名单按班级分组(对齐前端 SignupGroup)。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SignupGroup { + + /** 班级名称 */ + private String className; + + /** 班级内回执名单 */ + private List students; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/SignupRequest.java b/src/main/java/com/yunhai/familyactivity/model/activity/SignupRequest.java new file mode 100644 index 0000000..7fe5574 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/SignupRequest.java @@ -0,0 +1,27 @@ +package com.yunhai.familyactivity.model.activity; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +/** + * 家长端报名入参(对齐前端 SignupRequest,@RequestBody JSON)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SignupRequest { + + /** 活动ID */ + private Integer serid; + + /** 参加人数(含学生本人) */ + private Integer joinCount = 1; + + /** 家长姓名 */ + private String guardianName; + + /** 联系电话 */ + private String guardianPhone; + + /** 备注(过敏史、随行人数说明等) */ + private String remark; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/SignupsResultVo.java b/src/main/java/com/yunhai/familyactivity/model/activity/SignupsResultVo.java new file mode 100644 index 0000000..62eb460 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/activity/SignupsResultVo.java @@ -0,0 +1,42 @@ +package com.yunhai.familyactivity.model.activity; + +import lombok.Data; + +import java.util.List; + +/** + * 某活动的回执/报名结果(对齐前端 SignupsResultVo)。 + */ +@Data +public class SignupsResultVo { + + private Integer activityId; + + private String title; + + private String category; + + private String venue; + + /** 活动开始时间 */ + private String startTime; + + /** 报名截止时间 */ + private String signupDeadline; + + /** 活动流程状态 */ + private String actStatus; + + private String actStatusDesc; + + /** 回执方式 */ + private String receiptMode; + + private String receiptModeDesc; + + /** 汇总进度 */ + private ActivityProgress summary; + + /** 按班级分组名单 */ + private List groups; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/bsch/SchClassDto.java b/src/main/java/com/yunhai/familyactivity/model/bsch/SchClassDto.java new file mode 100644 index 0000000..6497e06 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/bsch/SchClassDto.java @@ -0,0 +1,29 @@ +package com.yunhai.familyactivity.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 班级 DTO(/oapi/VSchClass/GetVClassList,SchClass 视图)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchClassDto { + + /** 班级业务ID(sch_class.class_id;学生 ClassID 指向它) */ + @JsonProperty("ClassID") + private Integer classId; + + /** 班级名称 */ + @JsonProperty("ClassName") + private String className; + + /** 年级ID(= gradeId) */ + @JsonProperty("Levell") + private Integer gradeId; + + /** 排序号 */ + @JsonProperty("sx") + private Integer sort; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/bsch/SchGradeDto.java b/src/main/java/com/yunhai/familyactivity/model/bsch/SchGradeDto.java new file mode 100644 index 0000000..412e920 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/bsch/SchGradeDto.java @@ -0,0 +1,25 @@ +package com.yunhai.familyactivity.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 年级 DTO(/oapi/Schoolen/GetSchLenLevellist,BaseSchsystemGrade)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchGradeDto { + + /** 年级ID(=gradeId;班级 Levell 指向它) */ + @JsonProperty("Id") + private Integer id; + + /** 年级名称(gradeNickname,如「初一」) */ + @JsonProperty("Name") + private String name; + + /** 所属学段(学制)ID */ + @JsonProperty("OwnerId") + private Integer ownerId; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/bsch/SchInfoDto.java b/src/main/java/com/yunhai/familyactivity/model/bsch/SchInfoDto.java new file mode 100644 index 0000000..ed00eea --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/bsch/SchInfoDto.java @@ -0,0 +1,25 @@ +package com.yunhai.familyactivity.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 学校信息 DTO(/oapi/SchInfo/GetUserSchFirst)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchInfoDto { + + /** 学校 GUID(bschclass 侧 SchSerId) */ + @JsonProperty("SchSerId") + private String schSerId; + + /** 用户中心学校组ID(与本项目 schId 同域) */ + @JsonProperty("UserSchSerId") + private Integer userSchSerId; + + /** 学校名称 */ + @JsonProperty("SchName") + private String schName; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/bsch/SchStudentDto.java b/src/main/java/com/yunhai/familyactivity/model/bsch/SchStudentDto.java new file mode 100644 index 0000000..3b16fd0 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/bsch/SchStudentDto.java @@ -0,0 +1,34 @@ +package com.yunhai.familyactivity.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 学生 DTO(/oapi/OAStu/*,SchStudent 视图)。 + * 登录账号口径:家长端账号 = SchStudent.account(家长挂学生档案,学生账号即家长账号)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchStudentDto { + + /** 学员登录账号 */ + @JsonProperty("Account") + private String account; + + /** 学生ID */ + @JsonProperty("UserStuSerID") + private Integer stuId; + + /** 学生姓名 */ + @JsonProperty("StuName") + private String stuName; + + /** 所在班级ID(sch_class.class_id) */ + @JsonProperty("ClassID") + private Integer classId; + + /** 所在组ID */ + @JsonProperty("GroupID") + private Integer groupId; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/bsch/SchSystemDto.java b/src/main/java/com/yunhai/familyactivity/model/bsch/SchSystemDto.java new file mode 100644 index 0000000..b3bbb0e --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/bsch/SchSystemDto.java @@ -0,0 +1,25 @@ +package com.yunhai.familyactivity.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 学段(学制)DTO(/oapi/Schoolen/GetSchLenlist,BaseSchsystem)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchSystemDto { + + /** 学制ID */ + @JsonProperty("Id") + private Integer id; + + /** 学段名称(如「小学部」「初中部」) */ + @JsonProperty("Name") + private String name; + + /** 学制类型 */ + @JsonProperty("Type") + private Integer type; +} diff --git a/src/main/java/com/yunhai/familyactivity/model/bsch/SchTreeNodeDto.java b/src/main/java/com/yunhai/familyactivity/model/bsch/SchTreeNodeDto.java new file mode 100644 index 0000000..0722e9e --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/model/bsch/SchTreeNodeDto.java @@ -0,0 +1,41 @@ +package com.yunhai.familyactivity.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +/** + * bschclass 组织树节点 DTO(/oapi/SchGroup/GetTeaTree、GetStuTree,TeaTreeVO 视图)。 + * type=1 组节点(学校/学段/年级/班级、教师组),type=2 人员叶子(学生/教师)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchTreeNodeDto { + + /** 节点ID(学校/学段/年级/班级为对应业务ID,叶子为学生 stuId 或教师 teaId) */ + private Integer id; + + /** 节点名称 */ + private String name; + + /** 父节点ID(根节点为 0) */ + private Integer pid; + + /** 排序号(组取 sort,叶子固定 0) */ + private Integer no; + + /** 1=组节点,2=人员叶子 */ + private Integer type; + + /** 是否父节点(组 true,叶子 false) */ + @JsonProperty("isParent") + private Boolean isParent; + + /** 头像(当前固定空串) */ + private String photo; + + /** 子节点(叶子为 null,组节点非 null) */ + private List children; +} diff --git a/src/main/java/com/yunhai/familyactivity/repository/ActivityReadRepository.java b/src/main/java/com/yunhai/familyactivity/repository/ActivityReadRepository.java new file mode 100644 index 0000000..c4d1ca9 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/repository/ActivityReadRepository.java @@ -0,0 +1,22 @@ +package com.yunhai.familyactivity.repository; + +import com.yunhai.familyactivity.entity.ActivityRead; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +/** + * 活动已读记录仓库(班级粒度)。 + */ +public interface ActivityReadRepository extends JpaRepository { + + Optional findByActivityIdAndClassId(Integer activityId, String classId); + + List findByActivityId(Integer activityId); + + List findByActivityIdIn(Collection activityIds); + + long deleteByActivityId(Integer activityId); +} diff --git a/src/main/java/com/yunhai/familyactivity/repository/ActivityRepository.java b/src/main/java/com/yunhai/familyactivity/repository/ActivityRepository.java new file mode 100644 index 0000000..fc970c9 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/repository/ActivityRepository.java @@ -0,0 +1,42 @@ +package com.yunhai.familyactivity.repository; + +import com.yunhai.familyactivity.entity.Activity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import javax.persistence.LockModeType; +import java.util.List; +import java.util.Optional; + +/** + * 活动仓库。列表筛选走 Specification(动态条件),家长端收件箱在 Service 内做接收班级匹配。 + */ +public interface ActivityRepository extends JpaRepository, + JpaSpecificationExecutor { + + /** 某学校已发布且未撤回的活动(家长端收件箱候选集,数据量小,内存匹配接收班级) */ + List findBySchIdAndStatusAndIsPulled(Integer schId, Integer status, Integer isPulled); + + /** 某学校全部活动(管理视角详情导航,含草稿/撤回) */ + List findBySchId(Integer schId); + + /** 我发布的活动(发布人ID) */ + List findBySchIdAndPublisherId(Integer schId, Integer publisherId); + + /** 按源活动查复用生成的活动 */ + List findBySourceId(Integer sourceId); + + /** 字典删除保护:主办部门是否被活动引用 */ + boolean existsByDeptId(String deptId); + + /** 字典删除保护:活动分类是否被活动引用(category 冗余存分类名称) */ + boolean existsByCategory(String category); + + /** 报名场景悲观锁加载(须在事务内调用),串行化同一活动的名额校验防超卖 */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select a from Activity a where a.id = :id") + Optional findByIdForUpdate(@Param("id") Integer id); +} diff --git a/src/main/java/com/yunhai/familyactivity/repository/ActivitySignupRepository.java b/src/main/java/com/yunhai/familyactivity/repository/ActivitySignupRepository.java new file mode 100644 index 0000000..a3931d1 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/repository/ActivitySignupRepository.java @@ -0,0 +1,26 @@ +package com.yunhai.familyactivity.repository; + +import com.yunhai.familyactivity.entity.ActivitySignup; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +/** + * 活动回执/报名记录仓库。 + */ +public interface ActivitySignupRepository extends JpaRepository { + + List findByActivityId(Integer activityId); + + List findByActivityIdAndStatusIn(Integer activityId, Collection statuses); + + List findByActivityIdIn(Collection activityIds); + + Optional findByActivityIdAndStudentId(Integer activityId, Integer studentId); + + List findByStudentIdAndActivityIdIn(Integer studentId, Collection activityIds); + + long deleteByActivityId(Integer activityId); +} diff --git a/src/main/java/com/yunhai/familyactivity/repository/ContactsGroupRepository.java b/src/main/java/com/yunhai/familyactivity/repository/ContactsGroupRepository.java new file mode 100644 index 0000000..38ae952 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/repository/ContactsGroupRepository.java @@ -0,0 +1,27 @@ +package com.yunhai.familyactivity.repository; + +import com.yunhai.familyactivity.entity.ContactsGroup; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ContactsGroupRepository extends JpaRepository { + Page findAllByOrderByCreateTimeDesc(Pageable pageable); + + /** + * 按创建人ID查询联系组列表。 + */ + List findByCreatorId(Integer creatorId); + + /** + * 按创建人ID查询联系组列表(分页)。 + */ + Page findByCreatorId(Integer creatorId, Pageable pageable); + + /** + * 查询全部联系组(按创建时间降序)。 + */ + List findAllByOrderByCreateTimeDesc(); +} diff --git a/src/main/java/com/yunhai/familyactivity/repository/ContactsGroupUserRepository.java b/src/main/java/com/yunhai/familyactivity/repository/ContactsGroupUserRepository.java new file mode 100644 index 0000000..a123b97 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/repository/ContactsGroupUserRepository.java @@ -0,0 +1,42 @@ +package com.yunhai.familyactivity.repository; + +import com.yunhai.familyactivity.entity.ContactsGroupUser; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ContactsGroupUserRepository extends JpaRepository { + Page findByGroupId(Integer groupId, Pageable pageable); + List findByGroupId(Integer groupId); + + /** + * 按组ID批量删除成员(JPQL bulk delete,SQL 立即执行)。 + * 注意:不能用 derived delete——其 DELETE 延迟到 flush 才执行, + * 同事务内先 insert 会触发 uk_group_user 唯一约束冲突。 + */ + @Modifying(clearAutomatically = true) + @Query("delete from ContactsGroupUser g where g.groupId = :groupId") + void deleteByGroupIdBulk(@Param("groupId") Integer groupId); + + /** + * 按多个组ID批量删除成员。 + */ + @Modifying(clearAutomatically = true) + @Query("delete from ContactsGroupUser g where g.groupId in :groupIds") + void deleteByGroupIdInBulk(@Param("groupIds") List groupIds); + + /** + * 按多个组ID查询所有成员(用于统计每组人数、组树构建)。 + */ + List findByGroupIdIn(List groupIds); + + /** + * 按组ID和用户ID查询(判断是否已存在)。 + */ + boolean existsByGroupIdAndUserId(Integer groupId, Integer userId); +} diff --git a/src/main/java/com/yunhai/familyactivity/repository/OperationLogRepository.java b/src/main/java/com/yunhai/familyactivity/repository/OperationLogRepository.java new file mode 100644 index 0000000..8a4ad69 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/repository/OperationLogRepository.java @@ -0,0 +1,10 @@ +package com.yunhai.familyactivity.repository; + +import com.yunhai.familyactivity.entity.OperationLogRecord; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * 业务操作日志仓库。 + */ +public interface OperationLogRepository extends JpaRepository { +} diff --git a/src/main/java/com/yunhai/familyactivity/repository/SysDictRepository.java b/src/main/java/com/yunhai/familyactivity/repository/SysDictRepository.java new file mode 100644 index 0000000..853c961 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/repository/SysDictRepository.java @@ -0,0 +1,15 @@ +package com.yunhai.familyactivity.repository; + +import com.yunhai.familyactivity.entity.SysDict; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface SysDictRepository extends JpaRepository { + List findByDictTypeOrderBySortOrderAsc(String dictType); + List findByDictTypeAndEnableFlagOrderBySortOrderAsc(String dictType, Integer enableFlag); + + /** 按类型+key 取单条字典(发文部门名称解析用) */ + Optional findByDictTypeAndDictKey(String dictType, String dictKey); +} diff --git a/src/main/java/com/yunhai/familyactivity/repository/UpFileRepository.java b/src/main/java/com/yunhai/familyactivity/repository/UpFileRepository.java new file mode 100644 index 0000000..c76e576 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/repository/UpFileRepository.java @@ -0,0 +1,26 @@ +package com.yunhai.familyactivity.repository; + +import com.yunhai.familyactivity.entity.UpFile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +/** + * 附件文件仓储。 + */ +public interface UpFileRepository extends JpaRepository { + + /** + * 按主题类型和主题ID查询附件列表。 + */ + List findByTopicTypeAndTopicId(String topicType, Integer topicId); + + /** + * 按主题类型和多个主题ID批量查询附件(消除 N+1)。 + */ + List findByTopicTypeAndTopicIdIn(String topicType, List topicIds); + + /** + * 按主题类型和主题ID删除附件(通知删除时级联清理)。 + */ + void deleteByTopicTypeAndTopicId(String topicType, Integer topicId); +} diff --git a/src/main/java/com/yunhai/familyactivity/service/IActivityService.java b/src/main/java/com/yunhai/familyactivity/service/IActivityService.java new file mode 100644 index 0000000..47f5ce7 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/service/IActivityService.java @@ -0,0 +1,117 @@ +package com.yunhai.familyactivity.service; + +import com.yunhai.familyactivity.model.BasePageResult; +import com.yunhai.familyactivity.model.activity.ActivityDetailVo; +import com.yunhai.familyactivity.model.activity.ActivityPageResult; +import com.yunhai.familyactivity.model.activity.ActivityQuery; +import com.yunhai.familyactivity.model.activity.ActivityRequest; +import com.yunhai.familyactivity.model.activity.ActivityStatisticsVo; +import com.yunhai.familyactivity.model.activity.ActivityVo; +import com.yunhai.familyactivity.model.activity.InitializeRedVo; +import com.yunhai.familyactivity.model.activity.MyActivitiesVo; +import com.yunhai.familyactivity.model.activity.MyActivityVo; +import com.yunhai.familyactivity.model.activity.NextPrevVo; +import com.yunhai.familyactivity.model.activity.ReadGroup; +import com.yunhai.familyactivity.model.activity.ReviewRequest; +import com.yunhai.familyactivity.model.activity.SignupDetailVo; +import com.yunhai.familyactivity.model.activity.SignupRequest; +import com.yunhai.familyactivity.model.activity.SignupsResultVo; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; +import java.util.Map; + +/** + * 活动信息发布业务接口(对齐前端 activityApi 全部端点)。 + */ +public interface IActivityService { + + // ========== 家长端收件箱 ========== + + /** 已发布活动列表(家长/学生视角,含未读总数) */ + ActivityPageResult list(ActivityQuery query); + + /** 清单:一键全部已读 */ + InitializeRedVo initializeRed(); + + // ========== 我发布的 / 全部活动 ========== + + /** 我发布的 / 全部活动列表(scope="user" 只看自己) */ + BasePageResult sendList(ActivityQuery query); + + // ========== 详情 ========== + + /** 查看详情(type=1 标读;type=0 编辑/管理视角不标读) */ + ActivityDetailVo detail(Integer serid, Integer type); + + /** 编辑回显(不标读) */ + ActivityDetailVo getDetail(Integer serid); + + /** 上一篇/下一篇(type=prev/next) */ + NextPrevVo getNextPrev(Integer serid, String type); + + /** 阅读情况(按班级分组读/未读名单) */ + List readDetail(Integer serid); + + /** 追加接收班级(补发,自动补回执记录) */ + void receiveClasses(Integer serid, String classes); + + // ========== 发布/编辑/删除 ========== + + /** 新建活动(draft=true 存草稿),返回活动 id */ + Integer create(ActivityRequest request); + + /** 编辑活动(含 sourceId 复用场景) */ + void edit(ActivityRequest request); + + /** 批量删除(级联清回执/已读/附件) */ + void delete(List ids); + + /** 撤回(家长端不可见) */ + void userPull(Integer serid); + + /** 置顶/取消置顶 */ + void setTopmost(Integer serid, Integer topmost); + + /** 催办未读(本期日志占位) */ + void expedite(Integer serid); + + // ========== 报名回执(家校核心闭环) ========== + + /** 某活动的回执/报名名单(按班级分组)+ 汇总;signupStatus: all/signed/confirmed/canceled/none */ + SignupsResultVo signups(Integer serid, String signupStatus); + + /** 单条回执详情 */ + SignupDetailVo signupDetail(Integer signupId); + + /** 处理回执:action=confirm/cancel/waitlist */ + void review(ReviewRequest request); + + /** 催报名(本期日志占位),返回 {reminded} */ + Map remind(Integer serid); + + /** 导出回执名单,返回 {csv: CSV文本(含BOM)}(走 JSON 包装,前端取 csv 字段生成文件) */ + Map exportSignups(Integer serid); + + // ========== 家长端(v2) ========== + + /** 活动广场(当前家长/学生视角,含我的报名态 + 分桶计数) */ + MyActivitiesVo myActivities(ActivityQuery query); + + /** 我对某活动的详情(活动信息 + 我的报名,同时标读;confirm 模式自动确认) */ + MyActivityVo mySignup(Integer serid); + + /** 报名 / 修改报名,返回 {signupId, status} */ + Map signup(SignupRequest request); + + /** 取消报名 */ + void cancelSignup(Integer serid); + + // ========== 统计 / 附件 ========== + + /** 活动统计(startTime/endTime 为 yyyy-MM-dd 短日期,可空) */ + ActivityStatisticsVo statistics(String startTime, String endTime); + + /** 全部附件打包下载 */ + void allFileDown(Integer dataid, HttpServletResponse response); +} diff --git a/src/main/java/com/yunhai/familyactivity/service/IClassService.java b/src/main/java/com/yunhai/familyactivity/service/IClassService.java new file mode 100644 index 0000000..859e266 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/service/IClassService.java @@ -0,0 +1,76 @@ +package com.yunhai.familyactivity.service; + +import com.yunhai.familyactivity.model.SchTeaList; +import com.yunhai.familyactivity.model.bsch.SchClassDto; +import com.yunhai.familyactivity.model.bsch.SchGradeDto; +import com.yunhai.familyactivity.model.bsch.SchInfoDto; +import com.yunhai.familyactivity.model.bsch.SchStudentDto; +import com.yunhai.familyactivity.model.bsch.SchSystemDto; +import com.yunhai.familyactivity.model.bsch.SchTreeNodeDto; + +import java.util.List; + +/** + * 班级管理(bschclass)开放接口客户端门面。 + * 基址取配置 {@code yh.open.bschclass}(/oapi/** 无需应用签名,独立 RestTemplate 直连)。 + * 班级树、圈人(发布对象/回执名单)、学生身份解析统一经本门面取数。 + * 参考 CampusNotice 同名门面。 + */ +public interface IClassService { + + /** + * 获取指定学校全部教师(含所属组 GroupIds)。 + * 对应 bschclass 开放接口:GET {@code {bschclass}/oapi/DbClass/GetSchteaList?Group={schId}} + * + * @param schId 学校ID(即用户当前身份的 schId) + */ + List getSchTeaList(int schId); + + /** + * 用户中心学校ID → bschclass 学校信息(SchSerId / 学校名)。 + * 对应:GET {@code {bschclass}/oapi/SchInfo/GetUserSchFirst?UserSchID={schId}} + */ + SchInfoDto getSchFirst(int schId); + + /** + * 学校学段(学制)列表:小学部 / 初中部…。 + * 对应:GET {@code {bschclass}/oapi/Schoolen/GetSchLenlist?SchSerID=} + */ + List getSchLenList(String schSerId); + + /** + * 学校年级列表(Id=gradeId、Name=gradeNickname、OwnerId=学段id)。 + * 对应:GET {@code {bschclass}/oapi/Schoolen/GetSchLenLevellist?SchSerID=} + */ + List getSchGradeList(String schSerId); + + /** + * 学校全部在读班级(ClassID/ClassName/Levell=gradeId)。 + * 对应:GET {@code {bschclass}/oapi/VSchClass/GetVClassList?SchSerID=} + */ + List getSchClassList(String schSerId); + + /** + * 按校全部学生(发布对象预生成回执、家长班级身份解析)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetSchStulist?schserid=} + */ + List getSchStuList(String schSerId); + + /** + * 按班级批量查学生(补发班级、回执名单圈人)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetClaArrStulist?ClassIDs=1,2,3} + */ + List getClaArrStuList(List classIds); + + /** + * 学校学生树:学校→学段→年级→班级→学生(单元素列表,根为学校节点)。 + * 对应:GET {@code {bschclass}/oapi/SchGroup/GetStuTree?schId=&toUser=} + */ + List getStuTree(Integer schId, boolean toUser); + + /** + * 学校教师树:学校→部门/组→教师。 + * 对应:GET {@code {bschclass}/oapi/SchGroup/GetTeaTree?schId=&toUser=} + */ + List getTeaTree(Integer schId, boolean toUser); +} diff --git a/src/main/java/com/yunhai/familyactivity/service/IContactsService.java b/src/main/java/com/yunhai/familyactivity/service/IContactsService.java new file mode 100644 index 0000000..4f8a0c8 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/service/IContactsService.java @@ -0,0 +1,79 @@ +package com.yunhai.familyactivity.service; + +import java.util.List; +import java.util.Map; + +/** + * 联系组业务接口。 + * 对应前端 Contacts 组接口:AddGroup/GetGroup/EditGroup/DelGroup/SetContactsTea/GetContactsTea/GetGroupTree/GetDeptRoleGroupTree/Import/Export。 + */ +public interface IContactsService { + + /** + * 添加联系组。 + * + * @param name 组名 + * @param type 类型(1 自定义组, 2 明信片) + */ + void addGroup(String name, Integer type); + + /** + * 获取联系组列表(含每组人数,分页)。 + * + * @param page 页码(从1起) + * @param count 每页条数 + * @param type 类型(1 自定义组, 2 明信片) + * @return Map: { list, total } + */ + Map getGroup(Integer page, Integer count, Integer type); + + /** + * 编辑联系组。 + * + * @param id 组ID + * @param name 组名 + * @param type 类型 + */ + void editGroup(Integer id, String name, Integer type); + + /** + * 删除联系组(支持批量,同时删除组内成员)。 + * + * @param ids 逗号分隔的组ID + */ + void delGroup(String ids); + + /** + * 设置联系组教师(全量替换:先删后增)。 + * + * @param gid 组ID + * @param ids 逗号分隔的用户ID + */ + void setContactsTea(Integer gid, String ids); + + /** + * 获取联系组教师列表(分页)。 + * + * @param gid 组ID + * @param page 页码 + * @param count 每页条数 + * @return Map: { list, total } + */ + Map getContactsTea(String gid, Integer page, Integer count); + + /** + * 获取联系组树(组节点+用户节点,递归构建)。 + * + * @param type 类型(1 自定义组, 2 明信片) + * @return 树形结构 List<Map> + */ + List> getGroupTree(Integer type); + + /** + * 获取部门角色组树。 + * C# 从外部API获取部门树+用户角色映射。Java版预留。 + * + * @return 树形结构 List<Map> + */ + List> getDeptRoleGroupTree(); +} diff --git a/src/main/java/com/yunhai/familyactivity/service/IDictService.java b/src/main/java/com/yunhai/familyactivity/service/IDictService.java new file mode 100644 index 0000000..245945b --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/service/IDictService.java @@ -0,0 +1,79 @@ +package com.yunhai.familyactivity.service; + +import java.util.List; +import java.util.Map; + +/** + * 系统字典业务接口。 + * 对应前端 Base 组接口:GetSysDic/GetDictMangeList/AddDeptDic/UpdateDeptDic/DelDept/OrdbyDicIds/GetDeptSync/GetSchModuleIsSms。 + * 字典类型:dept=主办部门(发文部门)、category=活动分类。 + */ +public interface IDictService { + + /** + * 获取系统字典(下拉框用)。 + * Type=1 主办部门(DEPT),Type=2 活动分类(CATEGORY)。 + * + * @param type 字典类型编号(1=主办部门,2=活动分类) + * @return List<Map> 每项 {k, v} + */ + List> getSysDic(Integer type); + + /** + * 获取系统字典管理列表(含 SerID/CodeID/Name/Des/Enable)。 + * + * @param type 字典类型("dept"=主办部门,"category"=活动分类) + * @return List<Map> + */ + List> getDictMangeList(String type); + + /** + * 添加字典项。 + * + * @param dictType 字典类型(dept/category) + * @param name 名称 + */ + void addDict(String dictType, String name); + + /** + * 更新字典项(名称、启用状态)。 + * + * @param dictType 字典类型(dept/category) + * @param serID 记录ID + * @param name 名称 + * @param enable 启用状态 + */ + void updateDict(String dictType, Integer serID, String name, Integer enable); + + /** + * 删除字典项(校验是否被活动引用)。 + * + * @param dictType 字典类型(dept/category) + * @param serID 记录ID + */ + void delDict(String dictType, Integer serID); + + /** + * 字典项排序。 + * + * @param dictType 字典类型(dept/category) + * @param ids 逗号分隔的 SerID 列表(按前端顺序) + */ + void sortDict(String dictType, String ids); + + /** + * 是否发送短信(查询当前学校模块短信开关)。 + * + * @return Boolean + */ + Boolean getSchModuleIsSms(); + + /** + * 按主办部门字典 key(CodeID)解析部门名称;未命中返回 null。 + * 用于活动列表/详情展示 DeptName(对应 C# GetDeptName)。 + * + * @param deptId 活动 deptId(存的是 sys_dict.dict_key) + * @return 部门名称;无匹配时返回 null + */ + String resolveDeptName(String deptId); +} diff --git a/src/main/java/com/yunhai/familyactivity/service/IFileService.java b/src/main/java/com/yunhai/familyactivity/service/IFileService.java new file mode 100644 index 0000000..51c2fcd --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/service/IFileService.java @@ -0,0 +1,94 @@ +package com.yunhai.familyactivity.service; + +import com.yunhai.familyactivity.entity.UpFile; +import com.yunhai.familyactivity.model.UpFileVo; +import com.yunhai.familyactivity.model.UpFileResultVo; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; + +/** + * 文件上传/下载/删除业务接口。 + */ +public interface IFileService { + /** + * 上传单个文件。 + * + * @param file 上传的文件 + * @param appType 应用类型(如 "notice") + * @param topicId 关联主题ID(可为空,通知保存后再绑定) + * @return 文件视图对象 + */ + UpFileVo upload(MultipartFile file, String appType, Integer topicId); + + /** + * 将已上传但未绑定主题的附件绑定到指定通知。 + * + * @param fileIds 文件记录ID列表 + * @param topicId 通知ID + */ + void bindToTopic(List fileIds, Integer topicId); + + /** + * 查询某主题下的附件列表。 + * + * @param appType 应用类型 + * @param topicId 主题ID + * @return 附件列表 + */ + List listByTopic(String appType, Integer topicId); + + /** + * 下载文件。 + * + * @param fileId 文件记录ID + * @param response HTTP 响应 + */ + void download(Integer fileId, HttpServletResponse response); + + /** + * 在线预览文件(Content-Disposition: inline,按扩展名输出真实 Content-Type, + * 供前端 open-file-viewer 取 Blob / video 播放)。 + * + * @param fileId 文件记录ID + * @param response HTTP 响应 + */ + void preview(Integer fileId, HttpServletResponse response); + + /** + * 删除文件(同时删除物理文件和数据库记录)。 + * + * @param fileId 文件记录ID + */ + void delete(Integer fileId); + + /** + * 打包下载多个附件为 ZIP。 + * + * @param files 附件记录列表(调用方按 fileIds 查好) + * @param zipName 压缩包显示名(自动追加 .zip) + * @param response HTTP 响应 + */ + void downloadZip(List files, String zipName, HttpServletResponse response); + + /** + * 按主题类型和主题ID打包下载附件为 ZIP(Controller 便捷方法,内部自行查附件列表)。 + * + * @param topicType 主题类型(如 "notice") + * @param topicId 主题ID + * @param zipName 压缩包显示名 + * @param response HTTP 响应 + */ + void downloadZipByTopic(String topicType, Integer topicId, String zipName, HttpServletResponse response); + + /** + * 批量上传图片(循环上传,返回每个文件的视图对象列表)。 + */ + List uploadImages(MultipartFile[] files, String appType); + + /** + * 上传单个文件并返回前端兼容的结果 VO(含 fileid/name/fileurl/host/type/size)。 + */ + UpFileResultVo uploadToResult(MultipartFile file, String appType); +} diff --git a/src/main/java/com/yunhai/familyactivity/service/IUserCenterService.java b/src/main/java/com/yunhai/familyactivity/service/IUserCenterService.java new file mode 100644 index 0000000..8ee6bec --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/service/IUserCenterService.java @@ -0,0 +1,53 @@ +package com.yunhai.familyactivity.service; + +import com.yunhai.platform.utils.model.BoaModule; + +import java.util.List; +import java.util.Map; + +/** + * 用户中心门面服务(对齐 UserBase {@code UserCenterService} 模式)。 + * 封装 {@code YschBaseOpenApi} / {@code OpenApiUserClient} 静态门面调用, + * 把原本散落在 Controller 的组树组装、教师挂组、成员解析等业务逻辑下沉到 Service 层, + * Controller 只保留"取参 → 调 Service → 返回"的薄壳。 + */ +public interface IUserCenterService { + + /** + * 获取当前用户学校组及用户(树形结构)。 + * 组装规则(从原 BaseApiController.getSchoolAll 下沉): + * + * userbase 读取当前学校整棵组树(getGroupById(schId, withSub=true)),剔除学生组(type=8)及其子组; + * bschclass 获取该校全部教师(/oapi/DbClass/GetSchteaList?Group=schId),按 GroupIds 挂到对应组下, + * 无有效组(如所属组全被剔除)的教师兜底挂学校根节点; + * 扁平节点按 pid 递归组装为树(空组剔除,同 C# FormatUserTree)。 + * + * + * @param schId 学校ID(用户当前角色所属学校) + * @return 用户树形结构(组节点 type=1 / 教师节点 type=2) + */ + List> getSchoolAllTree(Integer schId); + + /** + * 获取指定组内用户(教师 + 管理员)。 + * 对应前端 base/GetGroupUser。原实现为空桩返回空列表,现按 C# 语义 + * (ITeainfoBLL.GetGroupTeainfo + IAdminfoBLL.GetGroupAdminfo)接入用户中心。 + * 若用户中心不可用或未配置对应开放接口,则抛出带语义的异常(显式报错而非静默失能)。 + * + * @param gid 组ID + * @param role 角色(默认 2=教师;1=管理员等) + * @return 组内用户列表 + */ + List> getGroupUser(Integer gid, Integer role); + + /** + * 获取当前用户可访问的模块树(左侧菜单)。 + * 从基础平台拉取当前用户模块列表(含学校上下文),组装为前端 layout 期望的树结构 + * ({@code modId/page/children}),对应前端 GetUserMod 接口。 + * + * @param userId 用户ID + * @param schId 学校ID(可为 null) + * @return 模块树(顶层 List,含 children 嵌套) + */ + List getUserModuleTree(Integer userId, Integer schId); +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/familyactivity/service/impl/ActivityService.java b/src/main/java/com/yunhai/familyactivity/service/impl/ActivityService.java new file mode 100644 index 0000000..b16a36d --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/service/impl/ActivityService.java @@ -0,0 +1,1818 @@ +package com.yunhai.familyactivity.service.impl; + +import com.yunhai.familyactivity.entity.Activity; +import com.yunhai.familyactivity.entity.ActivityRead; +import com.yunhai.familyactivity.entity.ActivitySignup; +import com.yunhai.familyactivity.entity.UpFile; +import com.yunhai.familyactivity.enums.ActivityStatusEnum; +import com.yunhai.familyactivity.enums.ReceiptModeEnum; +import com.yunhai.familyactivity.enums.SignupStatusEnum; +import com.yunhai.familyactivity.model.BasePageResult; +import com.yunhai.familyactivity.model.activity.ActivityDetailFileItem; +import com.yunhai.familyactivity.model.activity.ActivityDetailVo; +import com.yunhai.familyactivity.model.activity.ActivityPageResult; +import com.yunhai.familyactivity.model.activity.ActivityProgress; +import com.yunhai.familyactivity.model.activity.ActivityQuery; +import com.yunhai.familyactivity.model.activity.ActivityRequest; +import com.yunhai.familyactivity.model.activity.ActivityStatisticsVo; +import com.yunhai.familyactivity.model.activity.ActivityVo; +import com.yunhai.familyactivity.model.activity.InitializeRedVo; +import com.yunhai.familyactivity.model.activity.MyActivitiesVo; +import com.yunhai.familyactivity.model.activity.MyActivityVo; +import com.yunhai.familyactivity.model.activity.MySignupInfo; +import com.yunhai.familyactivity.model.activity.NextPrevVo; +import com.yunhai.familyactivity.model.activity.ParentActivityVo; +import com.yunhai.familyactivity.model.activity.ReadGroup; +import com.yunhai.familyactivity.model.activity.ReviewRequest; +import com.yunhai.familyactivity.model.activity.SignupBrief; +import com.yunhai.familyactivity.model.activity.SignupDetailVo; +import com.yunhai.familyactivity.model.activity.SignupGroup; +import com.yunhai.familyactivity.model.activity.SignupRequest; +import com.yunhai.familyactivity.model.activity.SignupsResultVo; +import com.yunhai.familyactivity.model.bsch.SchClassDto; +import com.yunhai.familyactivity.model.bsch.SchGradeDto; +import com.yunhai.familyactivity.model.bsch.SchInfoDto; +import com.yunhai.familyactivity.model.bsch.SchStudentDto; +import com.yunhai.familyactivity.repository.ActivityReadRepository; +import com.yunhai.familyactivity.repository.ActivityRepository; +import com.yunhai.familyactivity.repository.ActivitySignupRepository; +import com.yunhai.familyactivity.repository.UpFileRepository; +import com.yunhai.familyactivity.service.IActivityService; +import com.yunhai.familyactivity.service.IClassService; +import com.yunhai.familyactivity.service.IDictService; +import com.yunhai.familyactivity.service.IFileService; +import com.yunhai.familyactivity.utils.UserContext; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import javax.persistence.criteria.Predicate; +import javax.servlet.http.HttpServletResponse; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Collectors; + +/** + * 活动信息发布业务实现(家校核心闭环)。 + * + * 关键口径(与前端契约一致): + * 1. 发布对象到「班级」一级:receiveList="ALL" 或班级 id 逗号串;应收/已读人数按班级学生数累加。 + * 2. 回执方式:none 仅通知(只统计已读)/ signup 需报名 / confirm 需确认(打开即自动确认)。 + * 3. 流程状态 actStatus 按当前时间推导(draft→signup→closed→finished),不落库。 + * 4. 发布时按接收班级×学生预生成回执记录(status=none),保证应收人数、未回执名单可查。 + */ +@Service +public class ActivityService implements IActivityService { + + private static final Logger log = LoggerFactory.getLogger(ActivityService.class); + + private static final DateTimeFormatter DATETIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** 发送状态:草稿 */ + private static final int STATUS_DRAFT = 0; + /** 发送状态:已发布 */ + private static final int STATUS_PUBLISHED = 1; + + /** 附件主题类型 */ + private static final String TOPIC_TYPE = "activity"; + + @Autowired + private ActivityRepository activityRepository; + @Autowired + private ActivityReadRepository activityReadRepository; + @Autowired + private ActivitySignupRepository activitySignupRepository; + @Autowired + private UpFileRepository upFileRepository; + @Autowired + private IClassService classService; + @Autowired + private IDictService dictService; + @Autowired + private IFileService fileService; + + // ============================================================ + // 家长端收件箱 + // ============================================================ + + @Override + public ActivityPageResult list(ActivityQuery query) { + Integer schId = currentSchId(); + ParentContext pc = loadParentContext(schId); + LocalDateTime now = LocalDateTime.now(); + List candidates = inboxCandidates(schId, now); + Set readIds = readActivityIds(pc, candidates); + List visible = filterInbox(candidates, pc, query, now, readIds); + + // 未读总数(过滤前口径,但仅统计面向本班的活动,否则角标虚高;班级解析失败时无从统计) + long unreadCount = pc.myClassId == null ? 0 : candidates.stream() + .filter(a -> targetsClass(a, pc.myClassId)) + .filter(a -> !readIds.contains(a.getId())).count(); + + // 排序:置顶优先,时间次之 + boolean asc = "2".equals(query.getOrderByTime()); + visible.sort(Comparator + .comparing((Activity a) -> intVal(a.getTopmost())).reversed() + .thenComparing(Activity::getId, asc ? Comparator.naturalOrder() : Comparator.reverseOrder())); + + int total = visible.size(); + int pageIndex = Math.max(query.getPageIndex(), 1); + int pageSize = Math.max(query.getPageSize(), 1); + List content = visible.stream() + .skip((long) (pageIndex - 1) * pageSize) + .limit(pageSize) + .map(a -> toParentVo(a, pc, readIds)) + .collect(Collectors.toList()); + + int totalPages = (total + pageSize - 1) / pageSize; + return new ActivityPageResult<>(content, total, pageIndex, pageSize, totalPages, + pageIndex < totalPages, unreadCount); + } + + @Override + @Transactional + public InitializeRedVo initializeRed() { + Integer schId = currentSchId(); + ParentContext pc = loadParentContext(schId); + if (pc.myClassId == null) { + return new InitializeRedVo(0); + } + LocalDateTime now = LocalDateTime.now(); + List candidates = inboxCandidates(schId, now); + Set readIds = readActivityIds(pc, candidates); + List reads = new ArrayList<>(); + for (Activity a : candidates) { + if (readIds.contains(a.getId()) || !targetsClass(a, pc.myClassId)) { + continue; + } + ActivityRead read = new ActivityRead(); + read.setActivityId(a.getId()); + read.setClassId(pc.myClassId); + read.setStudentId(studentIdOf(pc)); + read.setReadTime(LocalDateTime.now()); + reads.add(read); + } + if (!reads.isEmpty()) { + activityReadRepository.saveAll(reads); + } + return new InitializeRedVo(reads.size()); + } + + // ============================================================ + // 我发布的 / 全部活动 + // ============================================================ + + @Override + public BasePageResult sendList(ActivityQuery query) { + Integer schId = currentSchId(); + // scope="user" 只看自己发布的 + Integer publisherId = "user".equalsIgnoreCase(query.getScope()) + ? UserContext.getUserId() : query.getPublisherId(); + + Specification spec = (root, cq, cb) -> { + List ps = new ArrayList<>(); + if (schId != null && schId > 0) { + ps.add(cb.equal(root.get("schId"), schId)); + } + if (publisherId != null) { + ps.add(cb.equal(root.get("publisherId"), publisherId)); + } + if (StringUtils.hasText(query.getKeyword())) { + String kw = "%" + likeEscape(query.getKeyword().trim()) + "%"; + ps.add(cb.or( + cb.like(root.get("title"), kw, '\\'), + cb.like(root.get("venue"), kw, '\\'), + cb.like(root.get("publisherName"), kw, '\\'), + cb.like(root.get("deptName"), kw, '\\'))); + } + if (StringUtils.hasText(query.getCategory())) { + ps.add(cb.equal(root.get("category"), query.getCategory().trim())); + } + if (StringUtils.hasText(query.getDeptId())) { + ps.add(cb.equal(root.get("deptId"), query.getDeptId().trim())); + } + // actStatus 推导条件翻译为时间/状态谓词(与 ActivityStatusEnum.derive 完全同口径: + // 仅通知(none)无截止语义,填了截止时间也不进入 closed) + ActivityStatusEnum statusFilter = ActivityStatusEnum.of(query.getActStatus()); + if (statusFilter == ActivityStatusEnum.DRAFT) { + ps.add(cb.equal(root.get("status"), STATUS_DRAFT)); + } else if (statusFilter != null) { + LocalDateTime now = LocalDateTime.now(); + ps.add(cb.equal(root.get("status"), STATUS_PUBLISHED)); + if (statusFilter == ActivityStatusEnum.FINISHED) { + ps.add(cb.isNotNull(root.get("endTime"))); + ps.add(cb.lessThanOrEqualTo(root.get("endTime"), now)); + } else { + // signup / closed 都要求活动未结束 + ps.add(cb.or(cb.isNull(root.get("endTime")), + cb.greaterThan(root.get("endTime"), now))); + if (statusFilter == ActivityStatusEnum.SIGNUP) { + // 仅通知(none)无截止语义:不看 signupDeadline + ps.add(cb.or(cb.equal(root.get("receiptMode"), ReceiptModeEnum.NONE.getCode()), + cb.isNull(root.get("signupDeadline")), + cb.greaterThan(root.get("signupDeadline"), now))); + } else { // CLOSED:需回执且截止已到 + ps.add(cb.notEqual(root.get("receiptMode"), ReceiptModeEnum.NONE.getCode())); + ps.add(cb.isNotNull(root.get("signupDeadline"))); + ps.add(cb.lessThanOrEqualTo(root.get("signupDeadline"), now)); + } + } + } + // 时间范围(按活动开始时间) + LocalDate start = parseDate(query.getStartTime()); + LocalDate end = parseDate(query.getEndTime()); + if (start != null) { + ps.add(cb.greaterThanOrEqualTo(root.get("startTime"), start.atStartOfDay())); + } + if (end != null) { + ps.add(cb.lessThanOrEqualTo(root.get("startTime"), end.atTime(23, 59, 59))); + } + return cb.and(ps.toArray(new Predicate[0])); + }; + + Sort sort = Sort.by(Sort.Order.desc("topmost"), + "2".equals(query.getOrderByTime()) ? Sort.Order.asc("id") : Sort.Order.desc("id")); + Pageable pageable = PageRequest.of(Math.max(query.getPageIndex(), 1) - 1, + Math.max(query.getPageSize(), 1), sort); + Page page = activityRepository.findAll(spec, pageable); + + List content = toVoList(page.getContent()); + int totalPages = page.getTotalPages(); + return new BasePageResult<>(content, (int) page.getTotalElements(), pageable.getPageNumber() + 1, + pageable.getPageSize(), totalPages, pageable.getPageNumber() + 1 < totalPages); + } + + // ============================================================ + // 详情 + // ============================================================ + + @Override + @Transactional + public ActivityDetailVo detail(Integer serid, Integer type) { + Activity activity = requireActivity(serid); + // type=1 查看视角:家长账号解析到班级则标读,confirm 模式自动确认; + // type=0 管理视角无需家长身份解析(省 2~4 次 bschclass 远程调用) + if (type != null && type == 1) { + // 家长视角可见性:草稿/已撤回/定时未到点一律拒绝,防按自增 id 遍历越权 + ensureParentVisible(activity); + Integer schId = currentSchId(); + ParentContext pc = loadParentContext(schId); + if (pc.myClassId != null && targetsClass(activity, pc.myClassId)) { + markRead(activity.getId(), pc.myClassId, studentIdOf(pc)); + autoConfirmIfNeeded(activity, studentIdOf(pc)); + } + } + return toDetailVo(activity); + } + + @Override + public ActivityDetailVo getDetail(Integer serid) { + return toDetailVo(requireActivity(serid)); + } + + @Override + public NextPrevVo getNextPrev(Integer serid, String type) { + requireActivity(serid); + Integer schId = currentSchId(); + LocalDateTime now = LocalDateTime.now(); + // 导航序列与列表页同口径:type=1 家长视角=收件箱候选(已发布/未撤回/定时到点), + // type=0 管理视角=本校全部(含草稿/撤回,与 SendList 入口一致);置顶优先、时间倒序。 + List all = "1".equals(type) + ? inboxCandidates(schId, now) + : activityRepository.findBySchId(schId); + all.sort(Comparator + .comparing((Activity a) -> intVal(a.getTopmost())).reversed() + .thenComparing(Activity::getId, Comparator.reverseOrder())); + List ids = all.stream().map(Activity::getId).collect(Collectors.toList()); + int i = ids.indexOf(serid); + return new NextPrevVo(i > 0 ? ids.get(i - 1) : null, + i > -1 && i < ids.size() - 1 ? ids.get(i + 1) : null); + } + + @Override + public List readDetail(Integer serid) { + Activity activity = requireActivity(serid); + Integer schId = currentSchId(); + Map classes = schoolClasses(schId); + List targetIds = targetClassIds(activity, classes); + Set readClassIds = activityReadRepository.findByActivityId(serid).stream() + .map(ActivityRead::getClassId).collect(Collectors.toSet()); + Map> byClass = studentsByClass(schId, targetIds); + + // 契约:按年级分组(GroupName=年级名),组内每个班级一条「班级名(N人)」,读/未读按班级粒度 + Map gradeNames = gradeNames(schId); + Map> byGrade = new LinkedHashMap<>(); + for (Integer classId : targetIds) { + SchClassDto cls = classes.get(classId); + String className = cls != null && StringUtils.hasText(cls.getClassName()) + ? cls.getClassName() : String.valueOf(classId); + String gradeName = cls != null && cls.getGradeId() != null + ? gradeNames.getOrDefault(cls.getGradeId(), "其他") : "其他"; + int studentCount = byClass.getOrDefault(classId, Collections.emptyList()).size(); + boolean classRead = readClassIds.contains(String.valueOf(classId)); + byGrade.computeIfAbsent(gradeName, k -> new ArrayList<>()) + .add(new ReadGroup.ReadGroupUser(String.valueOf(classId), + className + "(" + studentCount + "人)", + classRead ? "read" : "unread")); + } + List groups = new ArrayList<>(); + byGrade.forEach((name, users) -> groups.add(new ReadGroup(name, users))); + return groups; + } + + @Override + @Transactional + public void receiveClasses(Integer serid, String classes) { + Activity activity = requireActivity(serid); + if (!StringUtils.hasText(classes)) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + List addIds = parseIds(classes); + if (addIds.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + // 补发班级必须属于本校,防止跨校拉入学生生成回执 + Map schoolClasses = schoolClasses(currentSchId()); + addIds = addIds.stream().filter(schoolClasses::containsKey).collect(Collectors.toList()); + if (addIds.isEmpty()) { + throw new BizException(ErrorCode.FORBIDDEN, "补发班级不属于本校"); + } + String current = activity.getReceiveList(); + if ("ALL".equalsIgnoreCase(current)) { + return; // 已全校,无需补发 + } + LinkedHashSet merged = new LinkedHashSet<>(parseIds(current)); + merged.addAll(addIds); + activity.setReceiveList(merged.stream().map(String::valueOf).collect(Collectors.joining(","))); + activityRepository.save(activity); + // 已发布且需回执:为新班级补回执记录 + if (activity.isPublished() && !ReceiptModeEnum.NONE.getCode().equalsIgnoreCase(activity.getReceiptMode())) { + preGenerateSignups(activity, addIds, schoolClasses); + } + } + + // ============================================================ + // 发布/编辑/删除 + // ============================================================ + + @Override + @Transactional + public Integer create(ActivityRequest request) { + Integer schId = currentSchId(); + // 超管/未绑定学校的账号发布后 schId=0,家长端收件箱永远查不到,直接拒绝 + if (schId == null || schId <= 0) { + throw new BizException(ErrorCode.FORBIDDEN, "当前账号未绑定学校,无法发布活动"); + } + Activity activity = new Activity(); + applyRequest(activity, request); + activity.setSchId(schId); + activity.setPublisherId(UserContext.getUserId()); + activity.setPublisherName(UserContext.getUserName()); + activity.setSourceId(request.getSourceId()); + boolean publish = request.getDraft() == null || !request.getDraft(); + activity.setStatus(publish ? STATUS_PUBLISHED : STATUS_DRAFT); + activityRepository.save(activity); + + bindFiles(request.getFileIds(), activity.getId()); + if (publish) { + // 定时发布同样预生成回执:收件箱按定时时间过滤可见性 + preGenerateIfNeeded(activity); + } + log.info("[activity] 发布活动 id={}, title={}, publisherId={}", + activity.getId(), activity.getTitle(), activity.getPublisherId()); + return activity.getId(); + } + + @Override + @Transactional + public void edit(ActivityRequest request) { + // 前端编辑提交不带 serid,而是用 sourceId 字段携带活动ID(见 NewActivity.vue doAdd) + Integer id = request.getSerid() != null ? request.getSerid() : request.getSourceId(); + if (id == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "缺少活动ID"); + } + Activity activity = requireActivity(id); + boolean wasPublished = activity.isPublished(); + applyRequest(activity, request); + if (request.getDraft() != null && request.getDraft()) { + activity.setStatus(STATUS_DRAFT); + } else if (!wasPublished) { + activity.setStatus(STATUS_PUBLISHED); + } + activity.setIsPulled(0); // 编辑保存即恢复可见(对齐 mock:编辑重置撤回标记) + activityRepository.save(activity); + if (request.getFileIds() != null) { + rebindFiles(request.getFileIds(), activity.getId()); + } + if (activity.isPublished()) { + preGenerateIfNeeded(activity); + removeOutOfRangeRecords(activity); + } + } + + @Override + @Transactional + public void delete(List ids) { + if (ids == null || ids.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + for (Integer id : ids) { + // 学校归属校验,防跨校按 id 枚举删除;不存在/无权的 id 跳过而非整批失败 + Activity activity = activityRepository.findById(id).orElse(null); + if (activity == null) { + continue; + } + requireActivity(id); + activityReadRepository.deleteByActivityId(id); + activitySignupRepository.deleteByActivityId(id); + // 物理删除附件 + List files = upFileRepository.findByTopicTypeAndTopicId(TOPIC_TYPE, id); + for (UpFile f : files) { + try { + fileService.delete(f.getId()); + } catch (Exception e) { + log.warn("[activity] 删除附件失败 fileId={}", f.getId(), e); + } + } + // 列表过期后批量删除可能含已不存在的 id,跳过而非整批失败 + activityRepository.delete(activity); + } + } + + @Override + @Transactional + public void userPull(Integer serid) { + Activity activity = requireActivity(serid); + activity.setIsPulled(1); + activityRepository.save(activity); + } + + @Override + @Transactional + public void setTopmost(Integer serid, Integer topmost) { + Activity activity = requireActivity(serid); + activity.setTopmost(topmost != null && topmost == 1 ? 1 : 0); + activityRepository.save(activity); + } + + @Override + public void expedite(Integer serid) { + requireActivity(serid); + // 催办未读(短信/站内提醒)——短信通道本期未接入,仅记录操作日志 + log.info("[activity] 催办未读 serid={}, operator={}", serid, UserContext.getAccount()); + } + + // ============================================================ + // 报名回执 + // ============================================================ + + @Override + public SignupsResultVo signups(Integer serid, String signupStatus) { + Activity activity = requireActivity(serid); + Integer schId = currentSchId(); + List records = activitySignupRepository.findByActivityId(serid); + if (StringUtils.hasText(signupStatus) && !"all".equalsIgnoreCase(signupStatus.trim()) + && !SignupStatusEnum.isValid(signupStatus)) { + throw new BizException(ErrorCode.PARAM_ERROR, "非法的回执状态筛选值: " + signupStatus); + } + SignupStatusEnum filter = "all".equalsIgnoreCase(signupStatus) ? null : SignupStatusEnum.of(signupStatus); + List filtered = filter == null ? records : records.stream() + .filter(r -> filter.getCode().equalsIgnoreCase(r.getStatus())) + .collect(Collectors.toList()); + + Map classes = schoolClasses(schId); + Map> byClass = filtered.stream() + .collect(Collectors.groupingBy(r -> r.getClassName() != null ? r.getClassName() + : classNameOf(classes, parseSafe(r.getClassId())), LinkedHashMap::new, Collectors.toList())); + + List groups = byClass.entrySet().stream() + .map(e -> new SignupGroup(e.getKey(), e.getValue().stream() + .map(this::toBrief).collect(Collectors.toList()))) + .collect(Collectors.toList()); + + Map> byClassStudents = studentsByClass(schId, targetClassIds(activity, classes)); + int should = byClassStudents.values().stream().mapToInt(List::size).sum(); + if (should == 0 && !records.isEmpty()) { + should = records.size(); // 班级数据不可达时降级为回执表实有人数 + } + + SignupsResultVo vo = new SignupsResultVo(); + vo.setActivityId(activity.getId()); + vo.setTitle(activity.getTitle()); + vo.setCategory(activity.getCategory()); + vo.setVenue(activity.getVenue()); + vo.setStartTime(fmt(activity.getStartTime())); + vo.setSignupDeadline(fmt(activity.getSignupDeadline())); + ActivityStatusEnum status = deriveStatus(activity, LocalDateTime.now()); + vo.setActStatus(status.getCode()); + vo.setActStatusDesc(status.getDesc()); + ReceiptModeEnum mode = ReceiptModeEnum.of(activity.getReceiptMode()); + vo.setReceiptMode(mode.getCode()); + vo.setReceiptModeDesc(mode.getDesc()); + vo.setSummary(computeProgress(activity, records, should)); + vo.setGroups(groups); + return vo; + } + + @Override + public SignupDetailVo signupDetail(Integer signupId) { + ActivitySignup record = activitySignupRepository.findById(signupId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "回执记录不存在")); + Activity activity = requireActivity(record.getActivityId()); + ActivityRead read = findRead(record.getActivityId(), record.getClassId()); + + SignupDetailVo vo = new SignupDetailVo(); + vo.setSignupId(record.getId()); + vo.setActivityId(activity.getId()); + vo.setActivityTitle(activity.getTitle()); + vo.setCategory(activity.getCategory()); + vo.setStudentId(record.getStudentId()); + vo.setStudentName(record.getStudentName()); + vo.setClassId(record.getClassId()); + vo.setClassName(record.getClassName()); + vo.setGuardianName(record.getGuardianName()); + vo.setGuardianPhone(record.getGuardianPhone()); + vo.setJoinCount(record.getJoinCount()); + vo.setStatus(record.getStatus()); + vo.setStatusDesc(SignupStatusEnum.of(record.getStatus()).getDesc()); + vo.setSignedAt(fmt(record.getSignedAt())); + vo.setRead(read != null); + vo.setReadAt(read == null ? null : fmt(read.getReadTime())); + vo.setRemark(record.getRemark()); + return vo; + } + + @Override + @Transactional + public void review(ReviewRequest request) { + if (request.getSignupId() == null || !StringUtils.hasText(request.getAction())) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + ActivitySignup record = activitySignupRepository.findById(request.getSignupId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "回执记录不存在")); + // 归属校验:回执必须属于当前学校可见的活动,防跨校篡改他人回执 + requireActivity(record.getActivityId()); + String action = request.getAction().toLowerCase(); + if ("confirm".equals(action)) { + record.setStatus(SignupStatusEnum.CONFIRMED.getCode()); + } else if ("cancel".equals(action)) { + record.setStatus(SignupStatusEnum.CANCELED.getCode()); + } else if ("waitlist".equals(action)) { + // 待定:保留报名态,备注标记(先剥掉历史 [待定] 前缀,避免重复处理时堆叠) + record.setStatus(SignupStatusEnum.SIGNED.getCode()); + String remark = request.getRemark() == null ? "" : request.getRemark().trim(); + if (remark.startsWith("[待定]")) { + remark = remark.substring("[待定]".length()).trim(); + } + record.setRemark(("[待定] " + remark).trim()); + } else { + throw new BizException(ErrorCode.PARAM_ERROR, "不支持的处理动作: " + request.getAction()); + } + if (StringUtils.hasText(request.getRemark()) && !"waitlist".equals(action)) { + record.setRemark(request.getRemark()); + } + activitySignupRepository.save(record); + } + + @Override + public Map remind(Integer serid) { + requireActivity(serid); + // 催报名(向未回执家长发送提醒)——短信通道本期未接入,仅记录操作日志 + log.info("[activity] 催报名 serid={}, operator={}", serid, UserContext.getAccount()); + Map result = new HashMap<>(); + result.put("reminded", 0); + return result; + } + + @Override + public Map exportSignups(Integer serid) { + Activity activity = requireActivity(serid); + List records = activitySignupRepository.findByActivityId(serid); + StringBuilder csv = new StringBuilder(); + csv.append('\uFEFF'); // BOM,保证 Excel 中文不乱码 + csv.append("班级,学生,家长,联系电话,参加人数,状态,回执时间,备注\r\n"); + for (ActivitySignup r : records) { + csv.append(csvCell(r.getClassName())).append(',') + .append(csvCell(r.getStudentName())).append(',') + .append(csvCell(r.getGuardianName())).append(',') + .append(csvCell(r.getGuardianPhone())).append(',') + .append(r.getJoinCount() == null ? 1 : r.getJoinCount()).append(',') + .append(csvCell(SignupStatusEnum.of(r.getStatus()).getDesc())).append(',') + .append(csvCell(fmt(r.getSignedAt()))).append(',') + .append(csvCell(r.getRemark())).append("\r\n"); + } + csv.append("\r\n共 ").append(records.size()).append(" 条回执记录\r\n"); + log.info("[activity] 导出回执名单 serid={}, rows={}", activity.getId(), records.size()); + // 返回 JSON 包装({csv})而非裸 text/csv:平台 ApiResponseAdvice 不包装 String 返回, + // 前端拦截器统一 response.json() 解析,裸 CSV 会导致导出链路必然失败 + Map result = new HashMap<>(); + result.put("csv", csv.toString()); + return result; + } + + // ============================================================ + // 家长端(v2) + // ============================================================ + + @Override + public MyActivitiesVo myActivities(ActivityQuery query) { + Integer schId = currentSchId(); + ParentContext pc = loadParentContext(schId); + LocalDateTime now = LocalDateTime.now(); + List candidates = inboxCandidates(schId, now); + List visible = filterInbox(candidates, pc, query, now, readActivityIds(pc, candidates)); + visible.sort(Comparator + .comparing((Activity a) -> intVal(a.getTopmost())).reversed() + .thenComparing(Activity::getId, Comparator.reverseOrder())); + + Map myRecords = mySignupMap(pc, candidates); + // 批量算名额占用,避免逐活动查询 + Map usedMap = new HashMap<>(); + if (!visible.isEmpty()) { + List visIds = visible.stream().map(Activity::getId).collect(Collectors.toList()); + activitySignupRepository.findByActivityIdIn(visIds) + .forEach(r -> usedMap.merge(r.getActivityId(), isOccupying(r) ? nz(r.getJoinCount()) : 0, Integer::sum)); + } + List list = new ArrayList<>(); + int todo = 0; + int signed = 0; + int finished = 0; + for (Activity a : visible) { + ActivitySignup my = myRecords.get(a.getId()); + ActivityStatusEnum st = deriveStatus(a, now); + ParentActivityVo vo = toParentActivityVo(a, my, st, now, + usedMap.getOrDefault(a.getId(), 0)); + // 分桶口径与前端 ParentActivities 的 tab 过滤逐条对齐(相互独立,可重叠计数): + // todo=需回执且未报(含已取消后待重报,限报名中);signed=已报名/已确认(不限流程); + // finished=已截止或已结束 + boolean viewOnly = ReceiptModeEnum.NONE.getCode().equalsIgnoreCase(a.getReceiptMode()); + boolean acted = my != null && isOccupying(my); + if (st == ActivityStatusEnum.FINISHED || st == ActivityStatusEnum.CLOSED) { + finished++; + } + if (acted) { + signed++; + } + if (!viewOnly && !acted && st == ActivityStatusEnum.SIGNUP) { + todo++; + } + list.add(vo); + } + return new MyActivitiesVo(new MyActivitiesVo.Buckets(todo, signed, finished, list.size()), list); + } + + @Override + @Transactional + public MyActivityVo mySignup(Integer serid) { + Activity activity = requireActivity(serid); + // 家长视角可见性:草稿/已撤回/定时未到点拒绝查看(同时防止标读/自动确认污染数据) + ensureParentVisible(activity); + Integer schId = currentSchId(); + ParentContext pc = loadParentContext(schId); + if (pc.myClassId != null && targetsClass(activity, pc.myClassId)) { + markRead(activity.getId(), pc.myClassId, studentIdOf(pc)); + autoConfirmIfNeeded(activity, studentIdOf(pc)); + } + ActivitySignup my = activitySignupRepository + .findByActivityIdAndStudentId(activity.getId(), studentIdOf(pc)).orElse(null); + ActivityStatusEnum st = deriveStatus(activity, LocalDateTime.now()); + + MyActivityVo vo = new MyActivityVo(); + MyActivityVo.ActivityInfo info = new MyActivityVo.ActivityInfo(); + info.setId(activity.getId()); + info.setTitle(activity.getTitle()); + info.setCategory(activity.getCategory()); + info.setVenue(activity.getVenue()); + info.setContent(activity.getContent()); + info.setStartTime(fmt(activity.getStartTime())); + info.setEndTime(fmt(activity.getEndTime())); + info.setSignupDeadline(fmt(activity.getSignupDeadline())); + ReceiptModeEnum mode = ReceiptModeEnum.of(activity.getReceiptMode()); + info.setReceiptMode(mode.getCode()); + info.setReceiptModeDesc(mode.getDesc()); + info.setQuota(activity.getQuota()); + info.setAllowAccompany(activity.getAllowAccompany()); + info.setCost(activity.getCost()); + info.setPublisherName(activity.getPublisherName()); + info.setDeptName(activity.getDeptName()); + info.setActStatus(st.getCode()); + info.setActStatusDesc(st.getDesc()); + vo.setActivity(info); + vo.setMy(toMySignupInfo(my)); + return vo; + } + + @Override + @Transactional + public Map signup(SignupRequest request) { + if (request.getSerid() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + // 悲观锁加载活动行:串行化同一活动的名额校验,防并发超卖/重复回执 + Activity activity = requireActivityForUpdate(request.getSerid()); + if (!activity.isPublished() || intVal(activity.getIsPulled()) == 1) { + throw new BizException(ErrorCode.FORBIDDEN, "活动不可报名"); + } + ReceiptModeEnum mode = ReceiptModeEnum.of(activity.getReceiptMode()); + if (mode == ReceiptModeEnum.NONE) { + throw new BizException(ErrorCode.FORBIDDEN, "本活动无需报名"); + } + LocalDateTime now = LocalDateTime.now(); + // 定时发布未到点不可报名(创建即 status=1,仅靠状态机拦不住) + if (activity.getTimingSendTime() != null && now.isBefore(activity.getTimingSendTime())) { + throw new BizException(ErrorCode.FORBIDDEN, "活动尚未开始报名"); + } + // 流程状态校验:仅「报名中」可报名(截止/已结束/仅通知的截止语义统一由此拦截) + if (deriveStatus(activity, now) != ActivityStatusEnum.SIGNUP) { + throw new BizException(ErrorCode.FORBIDDEN, "该活动已截止报名"); + } + if (activity.getSignupDeadline() != null && now.isAfter(activity.getSignupDeadline())) { + throw new BizException(ErrorCode.FORBIDDEN, "报名已截止"); + } + Integer schId = currentSchId(); + ParentContext pc = loadParentContext(schId); + // 报名范围校验:活动必须面向当前家长所在班级 + if (pc.myClassId == null || !targetsClass(activity, pc.myClassId)) { + throw new BizException(ErrorCode.FORBIDDEN, "本活动未面向您所在班级"); + } + + ActivitySignup record = activitySignupRepository + .findByActivityIdAndStudentId(activity.getId(), studentIdOf(pc)) + .orElseGet(ActivitySignup::new); + boolean isNew = record.getId() == null; + if (isNew) { + record.setActivityId(activity.getId()); + record.setSchId(schId); + record.setStudentId(studentIdOf(pc)); + if (pc.myStudent != null) { + record.setStudentId(pc.myStudent.getStuId()); + record.setStudentName(pc.myStudent.getStuName()); + record.setClassId(pc.myClassId); + record.setClassName(classNameOf(schoolClasses(schId), parseSafe(pc.myClassId))); + } + } + int joinCount = request.getJoinCount() != null && request.getJoinCount() > 0 + ? request.getJoinCount() : 1; + // 不允许随行的活动服务端也强制 1 人(前端仅做了输入框限制) + if (intVal(activity.getAllowAccompany()) == 0 && joinCount > 1) { + throw new BizException(ErrorCode.FORBIDDEN, "本次活动不允许家长随行,参加人数仅限 1 人"); + } + // 名额校验:仅旧记录本身占用名额(signed/confirmed)时才先扣除其 joinCount; + // 预生成(none)与已取消(canceled)的残留 joinCount 不占名额,否则会少算导致超卖 + if (activity.getQuota() != null && activity.getQuota() > 0) { + int used = usedQuota(activitySignupRepository.findByActivityId(activity.getId())) + - (!isNew && isOccupying(record) ? nz(record.getJoinCount()) : 0) + + joinCount; + if (used > activity.getQuota()) { + throw new BizException(ErrorCode.FORBIDDEN, "名额已满"); + } + } + record.setJoinCount(joinCount); + if (request.getGuardianName() != null) { + record.setGuardianName(request.getGuardianName()); + } + if (request.getGuardianPhone() != null) { + record.setGuardianPhone(request.getGuardianPhone()); + } + if (request.getRemark() != null) { + record.setRemark(request.getRemark()); + } + record.setStatus(SignupStatusEnum.SIGNED.getCode()); + record.setSignedAt(LocalDateTime.now()); + activitySignupRepository.save(record); + + Map result = new HashMap<>(); + result.put("signupId", record.getId()); + result.put("status", record.getStatus()); + return result; + } + + @Override + @Transactional + public void cancelSignup(Integer serid) { + if (serid == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + Activity activity = requireActivity(serid); + ParentContext pc = loadParentContext(currentSchId()); + ActivitySignup record = activitySignupRepository + .findByActivityIdAndStudentId(activity.getId(), studentIdOf(pc)) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "尚未报名")); + record.setStatus(SignupStatusEnum.CANCELED.getCode()); + record.setJoinCount(0); // 取消后释放随行占位(对齐 mock 口径) + record.setSignedAt(LocalDateTime.now()); + activitySignupRepository.save(record); + } + + // ============================================================ + // 统计 / 附件 + // ============================================================ + + @Override + public ActivityStatisticsVo statistics(String startTime, String endTime) { + Integer schId = currentSchId(); + LocalDate start = parseDate(startTime); + LocalDate end = parseDate(endTime); + List activities = activityRepository.findAll((root, cq, cb) -> { + List ps = new ArrayList<>(); + if (schId != null && schId > 0) { + ps.add(cb.equal(root.get("schId"), schId)); + } + if (start != null) { + ps.add(cb.greaterThanOrEqualTo(root.get("createTime"), start.atStartOfDay())); + } + if (end != null) { + ps.add(cb.lessThanOrEqualTo(root.get("createTime"), end.atTime(23, 59, 59))); + } + return cb.and(ps.toArray(new Predicate[0])); + }); + + List ids = activities.stream().map(Activity::getId).collect(Collectors.toList()); + Map> signupMap = ids.isEmpty() ? Collections.emptyMap() + : activitySignupRepository.findByActivityIdIn(ids).stream() + .collect(Collectors.groupingBy(ActivitySignup::getActivityId)); + Map> readMap = ids.isEmpty() ? Collections.emptyMap() + : activityReadRepository.findByActivityIdIn(ids).stream() + .collect(Collectors.groupingBy(ActivityRead::getActivityId)); + + Map classes = schoolClasses(schId); + Map> stuByClass = studentsByClass(schId, new ArrayList<>(classes.keySet())); + Map classSize = new HashMap<>(); + stuByClass.forEach((k, v) -> classSize.put(k, v.size())); + + Integer myUserId = UserContext.getUserId(); + LocalDateTime now = LocalDateTime.now(); + long publishedCount = 0; + long totalSignups = 0; + double rateSum = 0; + int rateCount = 0; + long pendingReview = 0; + Map byPublisher = new LinkedHashMap<>(); + Map byMonth = new TreeMap<>(); + Map byCategory = new LinkedHashMap<>(); + List myItems = new ArrayList<>(); + + for (Activity a : activities) { + if (a.isPublished()) { + publishedCount++; + } + if (myUserId != null && myUserId.equals(a.getPublisherId()) + && a.isPublished() + && !ReceiptModeEnum.NONE.getCode().equalsIgnoreCase(a.getReceiptMode())) { + ActivityStatisticsVo.MyActivityItem item = new ActivityStatisticsVo.MyActivityItem(); + ActivityStatusEnum st = deriveStatus(a, now); + item.setId(a.getId()); + item.setTitle(a.getTitle()); + item.setCategory(a.getCategory()); + item.setVenue(a.getVenue()); + item.setActStatus(st.getCode()); + item.setActStatusDesc(st.getDesc()); + List records = signupMap.getOrDefault(a.getId(), Collections.emptyList()); + List targetIds = targetClassIds(a, classes); + int allowCount = targetIds.stream().mapToInt(t -> classSize.getOrDefault(t, 0)).sum(); + Set 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(); + fillProgressItem(item, computeProgress(a, records, allowCount), readCount, allowCount); + myItems.add(item); + } + List records = signupMap.getOrDefault(a.getId(), Collections.emptyList()); + long receiptCount = records.stream() + .filter(r -> SignupStatusEnum.SIGNED.getCode().equalsIgnoreCase(r.getStatus()) + || SignupStatusEnum.CONFIRMED.getCode().equalsIgnoreCase(r.getStatus())) + .count(); + totalSignups += receiptCount; + if (a.isPublished() && !ReceiptModeEnum.NONE.getCode().equalsIgnoreCase(a.getReceiptMode())) { + List targetIds = targetClassIds(a, classes); + int allowCount = targetIds.stream().mapToInt(t -> classSize.getOrDefault(t, 0)).sum(); + if (allowCount > 0) { + rateSum += receiptCount * 100.0 / allowCount; + rateCount++; + } + ActivityProgress progress = computeProgress(a, records, allowCount); + pendingReview += progress.getPendingReview(); + } + byPublisher.merge(a.getPublisherName() == null ? "未知" : a.getPublisherName(), 1L, Long::sum); + if (a.getCreateTime() != null) { + byMonth.merge(a.getCreateTime().getYear() + "-" + String.format("%02d", a.getCreateTime().getMonthValue()), + 1L, Long::sum); + } + if (StringUtils.hasText(a.getCategory())) { + byCategory.merge(a.getCategory(), 1L, Long::sum); + } + } + + ActivityStatisticsVo vo = new ActivityStatisticsVo(); + vo.setTotal(activities.size()); + vo.setPublishedCount(publishedCount); + vo.setMyActivityCount(myItems.size()); + vo.setTotalSignups(totalSignups); + vo.setAvgSignupRate(rateCount == 0 ? 0 : Math.round(rateSum / rateCount * 10) / 10.0); + vo.setPendingReview(pendingReview); + vo.setByPublisher(byPublisher.entrySet().stream() + .map(e -> new ActivityStatisticsVo.PublisherCount(e.getKey(), e.getValue())) + .collect(Collectors.toList())); + vo.setByMonth(byMonth.entrySet().stream() + .map(e -> { + String[] parts = e.getKey().split("-"); + return new ActivityStatisticsVo.MonthCount(Integer.parseInt(parts[0]), + Integer.parseInt(parts[1]), e.getValue()); + }) + .collect(Collectors.toList())); + vo.setByCategory(byCategory.entrySet().stream() + .map(e -> new ActivityStatisticsVo.CategoryCount(e.getKey(), e.getKey(), e.getValue())) + .collect(Collectors.toList())); + vo.setByActivity(myItems); + return vo; + } + + @Override + public void allFileDown(Integer dataid, HttpServletResponse response) { + Activity activity = requireActivity(dataid); // 归属校验,防跨校打包下载附件 + List files = upFileRepository.findByTopicTypeAndTopicId(TOPIC_TYPE, dataid); + String zipName = (StringUtils.hasText(activity.getTitle()) + ? activity.getTitle() : "activity-" + dataid) + "-附件"; + fileService.downloadZip(files, zipName, response); + } + + // ============================================================ + // 私有辅助 + // ============================================================ + + /** 家长视角上下文:当前账号解析到的学生/班级(bschclass 数据不可达时降级为空) */ + private class ParentContext { + String account; + String myClassId; + SchStudentDto myStudent; + Map classes = Collections.emptyMap(); + Map grades = Collections.emptyMap(); + Map> studentsByClass = Collections.emptyMap(); + } + + private ParentContext loadParentContext(Integer schId) { + ParentContext pc = new ParentContext(); + pc.account = UserContext.getAccount(); + try { + pc.classes = schoolClasses(schId); + pc.grades = gradeNames(schId); + List students = schoolStudents(schId); + pc.studentsByClass = students.stream() + .filter(s -> s.getClassId() != null) + .collect(Collectors.groupingBy(SchStudentDto::getClassId)); + if (StringUtils.hasText(pc.account)) { + pc.myStudent = pickMyStudent(students, pc.account, pc.classes); + pc.myClassId = pc.myStudent != null && pc.myStudent.getClassId() != null + ? String.valueOf(pc.myStudent.getClassId()) : null; + } + } catch (Exception e) { + log.warn("[activity] 班级/学生数据不可达,家长视角降级: {}", e.getMessage()); + } + return pc; + } + + /** 当前家长视角对应的学生ID(班级/学生数据不可达时为 null,不落库账号) */ + private Integer studentIdOf(ParentContext pc) { + return pc.myStudent != null ? pc.myStudent.getStuId() : null; + } + + /** + * 定位当前账号对应的学生档案。bschclass 全校学生列表对同一账号可能返回多条记录 + * (学生档案同时挂着已停用的历史班级),若直接取首条可能解析到班级表中已不存在的 + * 「幽灵班级」,导致 {@code myClassId} 落空、家长端收件箱被整体判空。 + * 因此优先取班级仍存在于班级表中的那条记录,其次取有班级值的,最后才退回首条。 + */ + private SchStudentDto pickMyStudent(List students, String account, + Map classes) { + List mine = students.stream() + .filter(s -> account.equals(s.getAccount())) + .collect(Collectors.toList()); + if (mine.isEmpty()) { + return null; + } + return mine.stream() + .filter(s -> s.getClassId() != null && classes.containsKey(s.getClassId())) + .findFirst() + .orElseGet(() -> mine.stream() + .filter(s -> s.getClassId() != null) + .findFirst() + .orElse(mine.get(0))); + } + + private Integer currentSchId() { + Integer schId = UserContext.getSchId(); + return schId == null ? 0 : schId; + } + + /** 学校班级表(classId → 班级),失败返回空表 */ + private Map schoolClasses(Integer schId) { + try { + SchInfoDto sch = classService.getSchFirst(schId); + if (sch == null || !StringUtils.hasText(sch.getSchSerId())) { + return Collections.emptyMap(); + } + return classService.getSchClassList(sch.getSchSerId()).stream() + .filter(c -> c.getClassId() != null) + .collect(Collectors.toMap(SchClassDto::getClassId, c -> c, (a, b) -> a)); + } catch (Exception e) { + log.warn("[activity] 获取班级列表失败: {}", e.getMessage()); + return Collections.emptyMap(); + } + } + + /** 学校学生列表,失败返回空表 */ + private List schoolStudents(Integer schId) { + try { + SchInfoDto sch = classService.getSchFirst(schId); + if (sch == null || !StringUtils.hasText(sch.getSchSerId())) { + return Collections.emptyList(); + } + return classService.getSchStuList(sch.getSchSerId()); + } catch (Exception e) { + log.warn("[activity] 获取学生列表失败: {}", e.getMessage()); + return Collections.emptyList(); + } + } + + /** 按班级分组学生(优先批量接口,失败降级为全校列表过滤);同一学生只计一次 */ + private Map> studentsByClass(Integer schId, List classIds) { + if (classIds == null || classIds.isEmpty()) { + return Collections.emptyMap(); + } + try { + return groupByClassUnique(classService.getClaArrStuList(classIds)); + } catch (Exception e) { + Map> all = groupByClassUnique(schoolStudents(schId)); + Map> result = new HashMap<>(); + for (Integer id : classIds) { + result.put(id, all.getOrDefault(id, Collections.emptyList())); + } + return result; + } + } + + /** 按班级分组,并按 (班级, 学生ID) 去重:bschclass 同一学生可能返回多条档案记录 */ + private Map> groupByClassUnique(List students) { + Map> result = new HashMap<>(); + Set seen = new LinkedHashSet<>(); + for (SchStudentDto s : students) { + if (s.getClassId() == null) { + continue; + } + if (s.getStuId() != null && !seen.add(s.getClassId() + ":" + s.getStuId())) { + continue; + } + result.computeIfAbsent(s.getClassId(), k -> new ArrayList<>()).add(s); + } + return result; + } + + /** 活动接收的班级ID列表("ALL"=全校班级) */ + private List targetClassIds(Activity activity, Map classes) { + String receiveList = activity.getReceiveList(); + if (receiveList == null) { + return Collections.emptyList(); + } + if ("ALL".equalsIgnoreCase(receiveList.trim())) { + return new ArrayList<>(classes.keySet()); + } + return parseIds(receiveList); + } + + private boolean targetsClass(Activity activity, String classId) { + String receiveList = activity.getReceiveList(); + if (receiveList == null || classId == null) { + return false; + } + return "ALL".equalsIgnoreCase(receiveList.trim()) || parseIds(receiveList).contains(parseSafe(classId)); + } + + /** 家长端候选活动:已发布、未撤回、定时发送已到点 */ + private List inboxCandidates(Integer schId, LocalDateTime now) { + List candidates = + activityRepository.findBySchIdAndStatusAndIsPulled(schId, STATUS_PUBLISHED, 0); + return candidates.stream() + .filter(a -> a.getTimingSendTime() == null || !now.isBefore(a.getTimingSendTime())) + .collect(Collectors.toList()); + } + + /** 家长端收件箱过滤:接收班级、关键字、分类、流程状态、阅读状态(readIds 由调用方传入,避免重复查库) */ + private List filterInbox(List candidates, ParentContext pc, + ActivityQuery query, LocalDateTime now, Set readIds) { + // 班级数据不可达时不放行全校活动(避免同校跨班泄露),直接返回空列表 + if (pc.myClassId == null) { + log.warn("[activity] 当前账号未解析到班级,收件箱返回空列表 account={}", pc.account); + return new ArrayList<>(); + } + return candidates.stream() + .filter(a -> targetsClass(a, pc.myClassId)) + .filter(a -> !StringUtils.hasText(query.getKeyword()) + || (a.getTitle() != null && a.getTitle().contains(query.getKeyword().trim()))) + .filter(a -> !StringUtils.hasText(query.getCategory()) + || query.getCategory().equals(a.getCategory())) + .filter(a -> { + ActivityStatusEnum f = ActivityStatusEnum.of(query.getActStatus()); + return f == null || f == deriveStatus(a, now); + }) + .filter(a -> { + if (query.getReadType() == null || query.getReadType() <= 0) { + return true; + } + boolean read = readIds.contains(a.getId()); + return query.getReadType() == 1 ? !read : read; + }) + .collect(Collectors.toList()); + } + + private Set readActivityIds(ParentContext pc, List candidates) { + if (pc.myClassId == null || candidates.isEmpty()) { + return Collections.emptySet(); + } + List ids = candidates.stream().map(Activity::getId).collect(Collectors.toList()); + // 已读是班级粒度(fa_activity_read 唯一键为 activity+class,studentId 只是首读学生), + // 不能按学生过滤,否则同班其他学生永远「未读」 + return activityReadRepository + .findByActivityIdIn(ids).stream() + .filter(r -> pc.myClassId.equals(r.getClassId())) + .map(ActivityRead::getActivityId) + .collect(Collectors.toSet()); + } + + /** 标读(班级粒度,幂等) */ + private void markRead(Integer activityId, String classId, Integer studentId) { + if (activityReadRepository.findByActivityIdAndClassId(activityId, classId).isPresent()) { + return; + } + ActivityRead read = new ActivityRead(); + read.setActivityId(activityId); + read.setClassId(classId); + read.setStudentId(studentId); + read.setReadTime(LocalDateTime.now()); + // 前置已查重;并发窗口撞唯一约束时让事务诚实失败(同一事务内 catch 无法回滚 rollback-only) + 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())) { + return; + } + activitySignupRepository.findByActivityIdAndStudentId(activity.getId(), studentId) + .ifPresent(record -> { + if (SignupStatusEnum.NONE.getCode().equalsIgnoreCase(record.getStatus())) { + record.setStatus(SignupStatusEnum.CONFIRMED.getCode()); + record.setSignedAt(LocalDateTime.now()); + activitySignupRepository.save(record); + } + }); + } + + /** 发布(非定时)后按接收班级预生成回执记录 */ + private void preGenerateIfNeeded(Activity activity) { + if (ReceiptModeEnum.NONE.getCode().equalsIgnoreCase(activity.getReceiptMode())) { + return; + } + Map classes = schoolClasses(currentSchId()); + preGenerateSignups(activity, targetClassIds(activity, classes), classes); + } + + /** 按班级预生成回执记录(幂等:跳过已存在学生);兼容补发场景自行取班级表 */ + private void preGenerateSignups(Activity activity, List classIds) { + preGenerateSignups(activity, classIds, schoolClasses(currentSchId())); + } + + private void preGenerateSignups(Activity activity, List classIds, + Map classes) { + if (classIds == null || classIds.isEmpty()) { + return; + } + Map> byClass = studentsByClass(currentSchId(), classIds); + Set existing = activitySignupRepository.findByActivityId(activity.getId()).stream() + .map(ActivitySignup::getStudentId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + List toSave = new ArrayList<>(); + for (Integer classId : classIds) { + String className = classNameOf(classes, classId); + for (SchStudentDto s : byClass.getOrDefault(classId, Collections.emptyList())) { + if (s.getStuId() == null || existing.contains(s.getStuId())) { + continue; + } + ActivitySignup record = new ActivitySignup(); + record.setActivityId(activity.getId()); + record.setSchId(activity.getSchId()); + record.setStudentId(s.getStuId()); + record.setStudentName(s.getStuName()); + record.setClassId(String.valueOf(classId)); + record.setClassName(className); + record.setJoinCount(1); + record.setStatus(SignupStatusEnum.NONE.getCode()); + toSave.add(record); + existing.add(s.getStuId()); + } + } + if (!toSave.isEmpty()) { + activitySignupRepository.saveAll(toSave); + log.info("[activity] 预生成回执记录 activityId={}, classes={}, rows={}", + activity.getId(), classIds.size(), toSave.size()); + } + } + /** 回执进度(should 优先取班级学生总数,降级为记录数) */ + private ActivityProgress computeProgress(Activity activity, List records, int should) { + int signed = 0; + int confirmed = 0; + int canceled = 0; + int unsigned = 0; + int usedQuota = 0; + for (ActivitySignup r : records) { + String st = r.getStatus(); + if (SignupStatusEnum.SIGNED.getCode().equalsIgnoreCase(st)) { + signed++; + usedQuota += nz(r.getJoinCount()); + } else if (SignupStatusEnum.CONFIRMED.getCode().equalsIgnoreCase(st)) { + confirmed++; + usedQuota += nz(r.getJoinCount()); + } else if (SignupStatusEnum.CANCELED.getCode().equalsIgnoreCase(st)) { + canceled++; + } else { + unsigned++; + } + } + int quota = nz(activity.getQuota()); + boolean full = quota > 0 && usedQuota >= quota; + double rate = should > 0 ? Math.round((signed + confirmed) * 1000.0 / should) / 10.0 : 0; + int pending = quota > 0 ? Math.max(0, usedQuota - quota) : 0; + return new ActivityProgress(should, signed, confirmed, canceled, unsigned, rate, quota, usedQuota, full, pending); + } + + private int usedQuota(List records) { + return records.stream() + .filter(r -> SignupStatusEnum.SIGNED.getCode().equalsIgnoreCase(r.getStatus()) + || SignupStatusEnum.CONFIRMED.getCode().equalsIgnoreCase(r.getStatus())) + .mapToInt(r -> nz(r.getJoinCount())) + .sum(); + } + + private ActivityStatusEnum deriveStatus(Activity activity, LocalDateTime now) { + return ActivityStatusEnum.derive(activity.isPublished(), activity.getReceiptMode(), + activity.getSignupDeadline(), activity.getEndTime(), now); + } + + private void applyRequest(Activity activity, ActivityRequest request) { + if (StringUtils.hasText(request.getTitle())) { + activity.setTitle(request.getTitle()); + } + activity.setContent(request.getContent()); + activity.setCategory(request.getCategory()); + activity.setVenue(request.getVenue()); + activity.setStartTime(request.getStartTime()); + activity.setEndTime(request.getEndTime()); + activity.setSignupDeadline(request.getSignupDeadline()); + activity.setReceiptMode(ReceiptModeEnum.of(request.getReceiptMode()).getCode()); + activity.setQuota(request.getQuota() != null ? request.getQuota() : 0); + activity.setAllowAccompany(request.getAllowAccompany() != null ? request.getAllowAccompany() : 0); + activity.setCost(request.getCost() != null ? request.getCost() : java.math.BigDecimal.ZERO); + activity.setDeptId(request.getDeptId()); + activity.setDeptName(StringUtils.hasText(request.getDeptName()) + ? request.getDeptName() : dictService.resolveDeptName(request.getDeptId())); + // 置顶由列表页独立接口(SetTopmost)维护,编辑提交不带该字段时保留原值,避免编辑静默取消置顶 + if (request.getTopmost() != null) { + activity.setTopmost(request.getTopmost()); + } + activity.setReceiveType(StringUtils.hasText(request.getReceiveType()) ? request.getReceiveType() : "class"); + activity.setReceiveList(request.getReceiveList()); + activity.setTimingSendTime(request.getTimingSendTime()); + activity.setSmsFlag(request.getSmsFlag() != null ? request.getSmsFlag() : 0); + // 注意:sourceId(复用/转发源活动)不在编辑时回写,避免编辑提交把活动 id 自引用; + // 复用另发走 Create,由 create() 落 sourceId + } + + private void bindFiles(String fileIds, Integer topicId) { + if (!StringUtils.hasText(fileIds)) { + return; + } + List ids = parseIds(fileIds); + if (!ids.isEmpty()) { + fileService.bindToTopic(ids, topicId); + } + } + + /** + * 编辑保存时整体替换附件绑定:新列表绑定 + 已绑但被移除的解绑, + * 否则编辑中删掉的附件仍会出现在详情里。 + */ + private void rebindFiles(String fileIds, Integer topicId) { + Set keep = new LinkedHashSet<>(parseIds(fileIds)); + if (!keep.isEmpty()) { + fileService.bindToTopic(new ArrayList<>(keep), topicId); + } + upFileRepository.findByTopicTypeAndTopicId(TOPIC_TYPE, topicId).stream() + .filter(f -> !keep.contains(f.getId())) + .forEach(f -> { + f.setTopicId(null); + upFileRepository.save(f); + }); + } + + /** + * 编辑收窄接收班级后,清理已移出班级的残留数据: + * 回执记录仅删未回执/已取消的(已报名数据不可逆,保留并在日志中提示), + * 班级粒度已读记录全部移除,避免已读统计污染。 + */ + private void removeOutOfRangeRecords(Activity activity) { + Map classes = schoolClasses(currentSchId()); + Set keep = new LinkedHashSet<>(targetClassIds(activity, classes)); + if ("ALL".equalsIgnoreCase(activity.getReceiveList() == null ? "" : activity.getReceiveList().trim())) { + return; // 全校接收无出界班级 + } + List obsolete = activitySignupRepository.findByActivityId(activity.getId()).stream() + .filter(r -> !keep.contains(parseSafe(r.getClassId()))) + .filter(r -> !isOccupying(r)) + .collect(Collectors.toList()); + if (!obsolete.isEmpty()) { + activitySignupRepository.deleteAll(obsolete); + log.info("[activity] 编辑收窄接收班级,清理出界回执 activityId={}, rows={}", + activity.getId(), obsolete.size()); + } + List obsoleteReads = activityReadRepository.findByActivityId(activity.getId()).stream() + .filter(r -> !keep.contains(parseSafe(r.getClassId()))) + .collect(Collectors.toList()); + if (!obsoleteReads.isEmpty()) { + activityReadRepository.deleteAll(obsoleteReads); + } + } + + /** 年级ID → 年级名(ReadDetail 按年级分组用),取数失败返回空表 */ + private Map gradeNames(Integer schId) { + try { + SchInfoDto sch = classService.getSchFirst(schId); + if (sch == null || !StringUtils.hasText(sch.getSchSerId())) { + return Collections.emptyMap(); + } + return classService.getSchGradeList(sch.getSchSerId()).stream() + .filter(g -> g.getId() != null) + .collect(Collectors.toMap(SchGradeDto::getId, g -> + StringUtils.hasText(g.getName()) ? g.getName() : String.valueOf(g.getId()), + (a, b) -> a)); + } catch (Exception e) { + log.warn("[activity] 获取年级列表失败: {}", e.getMessage()); + return Collections.emptyMap(); + } + } + + private Activity requireActivity(Integer serid) { + if (serid == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + Activity activity = activityRepository.findById(serid) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "活动不存在")); + // 学校数据隔离(超管 schId=0 放行;schId 为 null 的异常数据同样不放行) + Integer schId = UserContext.getSchId(); + if (schId != null && schId > 0 + && (activity.getSchId() == null || !schId.equals(activity.getSchId()))) { + throw new BizException(ErrorCode.FORBIDDEN, "无权访问该活动"); + } + return activity; + } + + /** 报名场景专用:悲观锁加载活动行(事务内调用),隔离校验与 {@link #requireActivity} 同口径 */ + private Activity requireActivityForUpdate(Integer serid) { + if (serid == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + Activity activity = activityRepository.findByIdForUpdate(serid) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "活动不存在")); + Integer schId = UserContext.getSchId(); + if (schId != null && schId > 0 + && (activity.getSchId() == null || !schId.equals(activity.getSchId()))) { + throw new BizException(ErrorCode.FORBIDDEN, "无权访问该活动"); + } + return activity; + } + + /** 家长视角实体可见性:已发布、未撤回、定时已到点(不含班级匹配,班级数据故障不阻塞查看) */ + private void ensureParentVisible(Activity activity) { + LocalDateTime now = LocalDateTime.now(); + if (!activity.isPublished() || intVal(activity.getIsPulled()) == 1 + || (activity.getTimingSendTime() != null && now.isBefore(activity.getTimingSendTime()))) { + throw new BizException(ErrorCode.FORBIDDEN, "活动不存在或未发布"); + } + } + + // ============================================================ + // VO 组装 + // ============================================================ + + private ActivityVo toParentVo(Activity a, ParentContext pc, Set readIds) { + ActivityVo vo = toVo(a); + vo.setRead(readIds.contains(a.getId())); + vo.setReceiveText(receiveText(a, pc.classes, pc.grades)); + return vo; + } + + /** 列表 VO(含进度与已读统计,供 SendList 使用) */ + private List toVoList(List activities) { + if (activities.isEmpty()) { + return new ArrayList<>(); + } + Integer schId = currentSchId(); + List ids = activities.stream().map(Activity::getId).collect(Collectors.toList()); + Map> signupMap = activitySignupRepository.findByActivityIdIn(ids).stream() + .collect(Collectors.groupingBy(ActivitySignup::getActivityId)); + Map> readMap = activityReadRepository.findByActivityIdIn(ids).stream() + .collect(Collectors.groupingBy(ActivityRead::getActivityId)); + Map classes = schoolClasses(schId); + Map
路径前缀:{@code Config.API_PATH + "/file/"}(原 base/ 前缀已修正为独立文件域)。 + * 方法名统一 PascalCase;写操作一律 POST。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。
+ * 存储文件元信息(真实文件名、存储路径、大小、类型),物理文件存储在 + * application.yml 的 file.upload-path 目录下。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "up_file") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class UpFile extends BaseEntity { + + /** 应用类型(如 "notice"),用于区分不同业务的附件 */ + @Column(name = "topic_type", length = 50) + private String topicType; + + /** 关联主题ID(如通知 serid) */ + @Column(name = "topic_id") + private Integer topicId; + + /** 文件真实名称(用户上传时的原始文件名) */ + @Column(name = "file_true_name", length = 255) + private String fileTrueName; + + /** 文件存储路径(相对路径,如 notice/20260823/uuid.pdf) */ + @Column(name = "file_name", length = 500) + private String fileName; + + /** 文件大小(字节) */ + @Column(name = "file_size") + private Integer fileSize; + + /** 文件后缀名(如 pdf, docx, jpg) */ + @Column(name = "file_type", length = 20) + private String fileType; +} diff --git a/src/main/java/com/yunhai/familyactivity/enums/ActivityStatusEnum.java b/src/main/java/com/yunhai/familyactivity/enums/ActivityStatusEnum.java new file mode 100644 index 0000000..e2752e5 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/enums/ActivityStatusEnum.java @@ -0,0 +1,69 @@ +package com.yunhai.familyactivity.enums; + +/** + * 活动流程状态(对齐前端 ActivityStatus)。 + *
将基础平台返回的扁平 {@code List} 转换为前端 layout(参考 UserBase)期望的树结构: + * {@code modId}(路由高亮匹配)/{@code page}(跳转路径)/{@code children}(子菜单)。
基址取配置 {@code yh.open.bschclass}(/oapi/** 无需应用签名,独立 RestTemplate 直连)。 + * 班级树、圈人(发布对象/回执名单)、学生身份解析统一经本门面取数。 + * 参考 CampusNotice 同名门面。
封装 {@code YschBaseOpenApi} / {@code OpenApiUserClient} 静态门面调用, + * 把原本散落在 Controller 的组树组装、教师挂组、成员解析等业务逻辑下沉到 Service 层, + * Controller 只保留"取参 → 调 Service → 返回"的薄壳。
组装规则(从原 BaseApiController.getSchoolAll 下沉):
对应前端 base/GetGroupUser。原实现为空桩返回空列表,现按 C# 语义 + * (ITeainfoBLL.GetGroupTeainfo + IAdminfoBLL.GetGroupAdminfo)接入用户中心。 + * 若用户中心不可用或未配置对应开放接口,则抛出带语义的异常(显式报错而非静默失能)。
从基础平台拉取当前用户模块列表(含学校上下文),组装为前端 layout 期望的树结构 + * ({@code modId/page/children}),对应前端 GetUserMod 接口。