From 36a3be8294cf6216e8cc739e67f97c2ac646bc47 Mon Sep 17 00:00:00 2001 From: wanghu Date: Sun, 20 Sep 2026 09:11:53 +0800 Subject: [PATCH] commit --- .gitignore | 5 + docker/application-prod.yml | 52 + docker/run-app.sh | 9 + docs/后端重构计划-活动信息发布.md | 431 ++++ pom.xml | 198 ++ .../FamilyActivityApplication.java | 18 + .../annotation/OperationLog.java | 24 + .../aspect/ControllerUserContextAspect.java | 158 ++ .../aspect/OperationLogAspect.java | 100 + .../common/listener/NoticeAuditListener.java | 47 + .../common/validation/ValidationGroups.java | 10 + .../yunhai/familyactivity/config/Config.java | 11 + .../familyactivity/config/JacksonConfig.java | 35 + .../familyactivity/config/SwaggerConfig.java | 22 + .../controller/api/ActivityController.java | 227 ++ .../controller/api/BaseApiController.java | 212 ++ .../controller/api/ContactsController.java | 99 + .../controller/api/DictController.java | 78 + .../controller/api/FileController.java | 88 + .../familyactivity/entity/Activity.java | 122 ++ .../familyactivity/entity/ActivityRead.java | 39 + .../familyactivity/entity/ActivitySignup.java | 71 + .../familyactivity/entity/BaseEntity.java | 49 + .../familyactivity/entity/ContactsGroup.java | 28 + .../entity/ContactsGroupUser.java | 35 + .../entity/OperationLogRecord.java | 57 + .../yunhai/familyactivity/entity/SysDict.java | 40 + .../yunhai/familyactivity/entity/UpFile.java | 47 + .../enums/ActivityStatusEnum.java | 69 + .../familyactivity/enums/ReceiptModeEnum.java | 43 + .../enums/SignupStatusEnum.java | 51 + .../familyactivity/model/BasePageQuery.java | 14 + .../familyactivity/model/BasePageResult.java | 33 + .../familyactivity/model/SchTeaList.java | 106 + .../familyactivity/model/UpFileResultVo.java | 30 + .../yunhai/familyactivity/model/UpFileVo.java | 31 + .../familyactivity/model/UserModuleVo.java | 122 ++ .../activity/ActivityDetailFileItem.java | 24 + .../model/activity/ActivityDetailVo.java | 111 + .../model/activity/ActivityPageResult.java | 24 + .../model/activity/ActivityProgress.java | 50 + .../model/activity/ActivityQuery.java | 40 + .../model/activity/ActivityRequest.java | 86 + .../model/activity/ActivityStatisticsVo.java | 105 + .../model/activity/ActivityVo.java | 118 ++ .../model/activity/InitializeRedVo.java | 17 + .../model/activity/MyActivitiesVo.java | 39 + .../model/activity/MyActivityVo.java | 58 + .../model/activity/MySignupInfo.java | 33 + .../model/activity/NextPrevVo.java | 18 + .../model/activity/ParentActivityVo.java | 56 + .../model/activity/ReadGroup.java | 44 + .../model/activity/ReviewRequest.java | 21 + .../model/activity/SignupBrief.java | 45 + .../model/activity/SignupDetailVo.java | 50 + .../model/activity/SignupGroup.java | 22 + .../model/activity/SignupRequest.java | 27 + .../model/activity/SignupsResultVo.java | 42 + .../model/bsch/SchClassDto.java | 29 + .../model/bsch/SchGradeDto.java | 25 + .../familyactivity/model/bsch/SchInfoDto.java | 25 + .../model/bsch/SchStudentDto.java | 34 + .../model/bsch/SchSystemDto.java | 25 + .../model/bsch/SchTreeNodeDto.java | 41 + .../repository/ActivityReadRepository.java | 22 + .../repository/ActivityRepository.java | 42 + .../repository/ActivitySignupRepository.java | 26 + .../repository/ContactsGroupRepository.java | 27 + .../ContactsGroupUserRepository.java | 42 + .../repository/OperationLogRepository.java | 10 + .../repository/SysDictRepository.java | 15 + .../repository/UpFileRepository.java | 26 + .../service/IActivityService.java | 117 ++ .../familyactivity/service/IClassService.java | 76 + .../service/IContactsService.java | 79 + .../familyactivity/service/IDictService.java | 79 + .../familyactivity/service/IFileService.java | 94 + .../service/IUserCenterService.java | 53 + .../service/impl/ActivityService.java | 1818 +++++++++++++++++ .../service/impl/ClassService.java | 159 ++ .../service/impl/ContactsService.java | 398 ++++ .../service/impl/DictService.java | 285 +++ .../service/impl/FileService.java | 326 +++ .../service/impl/UserCenterService.java | 263 +++ .../familyactivity/utils/PageUtils.java | 37 + .../familyactivity/utils/UserContext.java | 131 ++ src/main/resources/application.yml | 72 + src/main/resources/bootstrap.yml | 33 + src/main/resources/logback-spring.xml | 35 + 89 files changed, 8285 insertions(+) create mode 100644 .gitignore create mode 100644 docker/application-prod.yml create mode 100644 docker/run-app.sh create mode 100644 docs/后端重构计划-活动信息发布.md create mode 100644 pom.xml create mode 100644 src/main/java/com/yunhai/familyactivity/FamilyActivityApplication.java create mode 100644 src/main/java/com/yunhai/familyactivity/annotation/OperationLog.java create mode 100644 src/main/java/com/yunhai/familyactivity/aspect/ControllerUserContextAspect.java create mode 100644 src/main/java/com/yunhai/familyactivity/aspect/OperationLogAspect.java create mode 100644 src/main/java/com/yunhai/familyactivity/common/listener/NoticeAuditListener.java create mode 100644 src/main/java/com/yunhai/familyactivity/common/validation/ValidationGroups.java create mode 100644 src/main/java/com/yunhai/familyactivity/config/Config.java create mode 100644 src/main/java/com/yunhai/familyactivity/config/JacksonConfig.java create mode 100644 src/main/java/com/yunhai/familyactivity/config/SwaggerConfig.java create mode 100644 src/main/java/com/yunhai/familyactivity/controller/api/ActivityController.java create mode 100644 src/main/java/com/yunhai/familyactivity/controller/api/BaseApiController.java create mode 100644 src/main/java/com/yunhai/familyactivity/controller/api/ContactsController.java create mode 100644 src/main/java/com/yunhai/familyactivity/controller/api/DictController.java create mode 100644 src/main/java/com/yunhai/familyactivity/controller/api/FileController.java create mode 100644 src/main/java/com/yunhai/familyactivity/entity/Activity.java create mode 100644 src/main/java/com/yunhai/familyactivity/entity/ActivityRead.java create mode 100644 src/main/java/com/yunhai/familyactivity/entity/ActivitySignup.java create mode 100644 src/main/java/com/yunhai/familyactivity/entity/BaseEntity.java create mode 100644 src/main/java/com/yunhai/familyactivity/entity/ContactsGroup.java create mode 100644 src/main/java/com/yunhai/familyactivity/entity/ContactsGroupUser.java create mode 100644 src/main/java/com/yunhai/familyactivity/entity/OperationLogRecord.java create mode 100644 src/main/java/com/yunhai/familyactivity/entity/SysDict.java create mode 100644 src/main/java/com/yunhai/familyactivity/entity/UpFile.java create mode 100644 src/main/java/com/yunhai/familyactivity/enums/ActivityStatusEnum.java create mode 100644 src/main/java/com/yunhai/familyactivity/enums/ReceiptModeEnum.java create mode 100644 src/main/java/com/yunhai/familyactivity/enums/SignupStatusEnum.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/BasePageQuery.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/BasePageResult.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/SchTeaList.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/UpFileResultVo.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/UpFileVo.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/UserModuleVo.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/ActivityDetailFileItem.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/ActivityDetailVo.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/ActivityPageResult.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/ActivityProgress.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/ActivityQuery.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/ActivityRequest.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/ActivityStatisticsVo.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/ActivityVo.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/InitializeRedVo.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/MyActivitiesVo.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/MyActivityVo.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/MySignupInfo.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/NextPrevVo.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/ParentActivityVo.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/ReadGroup.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/ReviewRequest.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/SignupBrief.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/SignupDetailVo.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/SignupGroup.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/SignupRequest.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/activity/SignupsResultVo.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/bsch/SchClassDto.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/bsch/SchGradeDto.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/bsch/SchInfoDto.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/bsch/SchStudentDto.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/bsch/SchSystemDto.java create mode 100644 src/main/java/com/yunhai/familyactivity/model/bsch/SchTreeNodeDto.java create mode 100644 src/main/java/com/yunhai/familyactivity/repository/ActivityReadRepository.java create mode 100644 src/main/java/com/yunhai/familyactivity/repository/ActivityRepository.java create mode 100644 src/main/java/com/yunhai/familyactivity/repository/ActivitySignupRepository.java create mode 100644 src/main/java/com/yunhai/familyactivity/repository/ContactsGroupRepository.java create mode 100644 src/main/java/com/yunhai/familyactivity/repository/ContactsGroupUserRepository.java create mode 100644 src/main/java/com/yunhai/familyactivity/repository/OperationLogRepository.java create mode 100644 src/main/java/com/yunhai/familyactivity/repository/SysDictRepository.java create mode 100644 src/main/java/com/yunhai/familyactivity/repository/UpFileRepository.java create mode 100644 src/main/java/com/yunhai/familyactivity/service/IActivityService.java create mode 100644 src/main/java/com/yunhai/familyactivity/service/IClassService.java create mode 100644 src/main/java/com/yunhai/familyactivity/service/IContactsService.java create mode 100644 src/main/java/com/yunhai/familyactivity/service/IDictService.java create mode 100644 src/main/java/com/yunhai/familyactivity/service/IFileService.java create mode 100644 src/main/java/com/yunhai/familyactivity/service/IUserCenterService.java create mode 100644 src/main/java/com/yunhai/familyactivity/service/impl/ActivityService.java create mode 100644 src/main/java/com/yunhai/familyactivity/service/impl/ClassService.java create mode 100644 src/main/java/com/yunhai/familyactivity/service/impl/ContactsService.java create mode 100644 src/main/java/com/yunhai/familyactivity/service/impl/DictService.java create mode 100644 src/main/java/com/yunhai/familyactivity/service/impl/FileService.java create mode 100644 src/main/java/com/yunhai/familyactivity/service/impl/UserCenterService.java create mode 100644 src/main/java/com/yunhai/familyactivity/utils/PageUtils.java create mode 100644 src/main/java/com/yunhai/familyactivity/utils/UserContext.java create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/bootstrap.yml create mode 100644 src/main/resources/logback-spring.xml 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):

+ *
    + *
  1. 控制器方法参数中平台参数解析器绑定的 {@link BoaUser}(兼容现有写法);
  2. + *
  3. 平台静态持有者 {@link BoaUserContext#get()};
  4. + *
  5. request attribute(外部认证框架注入的 BoaUser);
  6. + *
  7. {@link UserContextHolder} 的 {@link LoginUser}(仅补 account/userName,无 id/schId)。
  8. + *
+ * 超管(type=1)不绑定具体学校,schId 统一置 0,保证数据隔离口径一致。 + */ +@Aspect +@Component +@Order(1) +public class ControllerUserContextAspect { + + private static final Logger log = LoggerFactory.getLogger(ControllerUserContextAspect.class); + + /** 外部认证框架把解析好的 BoaUser 放进 request attribute 的候选 key。 */ + private static final List BOA_USER_ATTR_KEYS = Arrays.asList( + "boaUser", "boauser", "currentUser", "user", "authUser", "loginUser" + ); + + /** 平台用户类型:超级管理员 */ + private static final Integer USER_TYPE_SUPER_ADMIN = 1; + + @Around("execution(* com.yunhai.familyactivity.controller..*.*(..))") + public Object setUserContext(ProceedingJoinPoint joinPoint) throws Throwable { + // 1. 优先从方法参数提取 BoaUser(兼容旧写法) + BoaUser boaUser = null; + for (Object arg : joinPoint.getArgs()) { + if (arg instanceof BoaUser) { + boaUser = (BoaUser) arg; + break; + } + } + + // 2. 参数缺失时依次回退:平台静态持有者 → request attribute + if (boaUser == null) { + try { + boaUser = BoaUserContext.get(); + } catch (Throwable ignore) { + boaUser = null; + } + } + HttpServletRequest request = getCurrentHttpRequest(); + if (request != null && boaUser == null) { + boaUser = resolveBoaUserFromRequest(request); + } + + // 3. 兜底:UserContextHolder 的 LoginUser(CAS 会话),至少补齐 account/userName + LoginUser loginUser = null; + if (boaUser == null) { + try { + loginUser = UserContextHolder.get(); + } catch (Throwable ignore) { + loginUser = null; + } + } + + populateUserContext(boaUser, loginUser, request); + + try { + return joinPoint.proceed(); + } finally { + UserContext.clear(); + log.debug("UserContext cleared for {}", joinPoint.getSignature().toShortString()); + } + } + + private BoaUser resolveBoaUserFromRequest(HttpServletRequest request) { + for (String key : BOA_USER_ATTR_KEYS) { + Object attr = request.getAttribute(key); + if (attr instanceof BoaUser) { + return (BoaUser) attr; + } + } + return null; + } + + private HttpServletRequest getCurrentHttpRequest() { + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + return attributes != null ? attributes.getRequest() : null; + } + + private void populateUserContext(BoaUser boaUser, LoginUser loginUser, HttpServletRequest request) { + if (boaUser != null) { + UserContext.setUserId(boaUser.getId()); + UserContext.setUserName(boaUser.getName() != null ? boaUser.getName() : "unknown"); + UserContext.setAccount(boaUser.getAccount()); + UserContext.setType(boaUser.getType()); + if (boaUser.getRoleIds() != null && !boaUser.getRoleIds().isEmpty() + && boaUser.getRoleIds().get(0) != null) { + UserContext.setRoleId(boaUser.getRoleIds().get(0)); + } + Integer schId = boaUser.getSchool() == null ? null : boaUser.getSchool().getId(); + if (schId == null && boaUser.getCurrentRole() != null) { + schId = boaUser.getCurrentRole().getSchId(); + } + UserContext.setSchId(schId); + log.debug("UserContext set from BoaUser: id={}, name={}", boaUser.getId(), boaUser.getName()); + } else if (loginUser != null) { + UserContext.setUserName(loginUser.getDisplayName() != null + ? loginUser.getDisplayName() : loginUser.getLoginName()); + UserContext.setAccount(loginUser.getLoginName()); + log.debug("UserContext set from LoginUser: loginName={}", loginUser.getLoginName()); + } else { + UserContext.setUserName("anonymous"); + log.warn("No BoaUser/LoginUser found, using anonymous"); + } + + // 超管(TYPE=1)不绑定具体学校,schId 统一置 0 + if (USER_TYPE_SUPER_ADMIN.equals(UserContext.getType())) { + UserContext.setSchId(0); + log.debug("Super admin detected(TYPE=1), force schId = 0"); + } + + if (request != null) { + UserContext.setIp(getClientIp(request)); + } + } + + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { + int idx = ip.indexOf(','); + return idx > 0 ? ip.substring(0, idx).trim() : ip.trim(); + } + ip = request.getHeader("X-Real-IP"); + if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { + return ip.trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/src/main/java/com/yunhai/familyactivity/aspect/OperationLogAspect.java b/src/main/java/com/yunhai/familyactivity/aspect/OperationLogAspect.java new file mode 100644 index 0000000..26f8ed1 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/aspect/OperationLogAspect.java @@ -0,0 +1,100 @@ +package com.yunhai.familyactivity.aspect; + +import com.yunhai.familyactivity.annotation.OperationLog; +import com.yunhai.familyactivity.entity.OperationLogRecord; +import com.yunhai.familyactivity.repository.OperationLogRepository; +import com.yunhai.familyactivity.utils.UserContext; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.Aspect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +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.time.LocalDateTime; +import java.util.Arrays; +import java.util.stream.Collectors; + +/** + * 操作日志切面:拦截标注 {@link OperationLog} 的方法,成功返回后落库 operation_log。 + * 操作人/学校/IP 取自 {@link UserContext}(由 ControllerUserContextAspect 在同一请求线程填充, + * 此处同步落库以保证上下文可见,写操作频率低无性能问题)。 + */ +@Aspect +@Component +public class OperationLogAspect { + + private static final Logger log = LoggerFactory.getLogger(OperationLogAspect.class); + + /** content 落库最大长度 */ + private static final int CONTENT_MAX_LEN = 3000; + + @Autowired + private OperationLogRepository operationLogRepository; + + @AfterReturning("@annotation(operationLog)") + public void recordLog(JoinPoint joinPoint, OperationLog operationLog) { + try { + OperationLogRecord record = new OperationLogRecord(); + record.setOperatorId(UserContext.getUserId()); + record.setOperator(UserContext.getUserName()); + record.setIp(UserContext.getIp()); + record.setModule(operationLog.module()); + record.setContent(truncate(buildContent(operationLog, joinPoint))); + record.setSchId(UserContext.getSchId()); + record.setOperateTime(LocalDateTime.now()); + + HttpServletRequest request = getCurrentRequest(); + if (request != null) { + record.setRequestUri(request.getRequestURI()); + record.setRequestMethod(request.getMethod()); + } + operationLogRepository.save(record); + } catch (Exception e) { + // 日志失败不影响业务 + log.warn("[operation-log] 落库失败: {}", e.getMessage()); + } + } + + private String buildContent(OperationLog operationLog, JoinPoint joinPoint) { + StringBuilder sb = new StringBuilder(); + if (!operationLog.content().isEmpty()) { + sb.append(operationLog.content()); + } + Object[] args = joinPoint.getArgs(); + if (args != null && args.length > 0) { + String params = Arrays.stream(args) + .filter(a -> !(a instanceof javax.servlet.ServletRequest) + && !(a instanceof javax.servlet.ServletResponse)) + .map(a -> { + try { + return String.valueOf(a); + } catch (Exception e) { + return "?"; + } + }) + .collect(Collectors.joining(", ")); + if (!params.isEmpty()) { + sb.append(sb.length() > 0 ? ";参数: " : "参数: ").append(params); + } + } + return sb.toString(); + } + + private String truncate(String s) { + if (s == null) { + return ""; + } + return s.length() > CONTENT_MAX_LEN ? s.substring(0, CONTENT_MAX_LEN) : s; + } + + private HttpServletRequest getCurrentRequest() { + ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + return attrs != null ? attrs.getRequest() : null; + } +} diff --git a/src/main/java/com/yunhai/familyactivity/common/listener/NoticeAuditListener.java b/src/main/java/com/yunhai/familyactivity/common/listener/NoticeAuditListener.java new file mode 100644 index 0000000..a1cfa0b --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/common/listener/NoticeAuditListener.java @@ -0,0 +1,47 @@ +package com.yunhai.familyactivity.common.listener; + +import com.yunhai.familyactivity.entity.BaseEntity; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import org.springframework.stereotype.Component; + +import javax.persistence.PrePersist; +import javax.persistence.PreUpdate; +import java.time.LocalDateTime; + +/** + * JPA 审计监听器:在实体持久化/更新前自动填充创建人/更新人(平台当前登录人)与时间戳。 + * 对应 {@link BaseEntity} 的 createBy / updateBy / createTime / updateTime 字段。 + * 当前登录人取自基础平台 {@link UserContextHolder}。 + */ +@Component +public class NoticeAuditListener { + + @PrePersist + public void prePersist(BaseEntity entity) { + LocalDateTime now = LocalDateTime.now(); + entity.setCreateTime(now); + entity.setUpdateTime(now); + String account = currentAccount(); + if (account != null) { + if (entity.getCreateBy() == null) { + entity.setCreateBy(account); + } + entity.setUpdateBy(account); + } + } + + @PreUpdate + public void preUpdate(BaseEntity entity) { + entity.setUpdateTime(LocalDateTime.now()); + String account = currentAccount(); + if (account != null) { + entity.setUpdateBy(account); + } + } + + private String currentAccount() { + LoginUser user = UserContextHolder.get(); + return user == null ? null : user.getLoginName(); + } +} diff --git a/src/main/java/com/yunhai/familyactivity/common/validation/ValidationGroups.java b/src/main/java/com/yunhai/familyactivity/common/validation/ValidationGroups.java new file mode 100644 index 0000000..d62d423 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/common/validation/ValidationGroups.java @@ -0,0 +1,10 @@ +package com.yunhai.familyactivity.common.validation; + +/** + * JSR-303 分组校验标记接口(对齐 UserBase 规范)。 + * Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。 + */ +public interface ValidationGroups { + interface Create {} + interface Update {} +} diff --git a/src/main/java/com/yunhai/familyactivity/config/Config.java b/src/main/java/com/yunhai/familyactivity/config/Config.java new file mode 100644 index 0000000..3ed009e --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/config/Config.java @@ -0,0 +1,11 @@ +package com.yunhai.familyactivity.config; + +/** + * 全局路径常量。与 UserBase 一致:API 统一挂在 /{platform}/ 之下(platform = api / manage / mobiapi)。 + */ +public enum Config { + Inst; + + public static final String API_PATH = "/{platform}"; // manage / api / mobiapi,前面有/后面无/,拼接子路径时用"/"分隔 + public static final String OPEN_API_PATH = "/oapi"; +} diff --git a/src/main/java/com/yunhai/familyactivity/config/JacksonConfig.java b/src/main/java/com/yunhai/familyactivity/config/JacksonConfig.java new file mode 100644 index 0000000..22710e0 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/config/JacksonConfig.java @@ -0,0 +1,35 @@ +package com.yunhai.familyactivity.config; + +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; + +import java.time.format.DateTimeFormatter; + +/** + * Jackson 时间序列化全局约定(对齐旧 .NET 版 "yyyy-MM-dd HH:mm:ss"): + * 接口出入参中的 LocalDateTime/LocalDate 统一使用该格式,避免 ISO 的 "T" 分隔符 + * (如 2026-09-01T16:38:40)直接暴露给前端表格。 + */ +@Configuration +public class JacksonConfig { + + private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + @Bean + public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { + return builder -> builder + .serializers( + new LocalDateTimeSerializer(DATE_TIME), + new LocalDateSerializer(DATE)) + .deserializers( + new LocalDateTimeDeserializer(DATE_TIME), + new LocalDateDeserializer(DATE)); + } +} diff --git a/src/main/java/com/yunhai/familyactivity/config/SwaggerConfig.java b/src/main/java/com/yunhai/familyactivity/config/SwaggerConfig.java new file mode 100644 index 0000000..7489382 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/config/SwaggerConfig.java @@ -0,0 +1,22 @@ +package com.yunhai.familyactivity.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 接口文档(springdoc-openapi,替代旧 Springfox)。 + */ +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI familyActivityOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("活动信息发布接口文档") + .description("武校家校信息发布 · 活动信息发布子应用:活动发布/报名回执/统计/班级树/字典/附件(springdoc-openapi)") + .version("1.0")); + } +} diff --git a/src/main/java/com/yunhai/familyactivity/controller/api/ActivityController.java b/src/main/java/com/yunhai/familyactivity/controller/api/ActivityController.java new file mode 100644 index 0000000..718c985 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/controller/api/ActivityController.java @@ -0,0 +1,227 @@ +package com.yunhai.familyactivity.controller.api; + +import com.yunhai.familyactivity.annotation.OperationLog; +import com.yunhai.familyactivity.config.Config; +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 com.yunhai.familyactivity.service.IActivityService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; +import java.util.Map; + +/** + * 活动信息发布接口(/api/activity/*,对齐前端 activityApi)。 + *

返回裸对象,由平台 ApiResponseAdvice 自动包装为 {code, msg, data}; + * 写操作标注 {@link OperationLog} 落库 operation_log。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/activity/") +public class ActivityController { + + @Autowired + private IActivityService activityService; + + // ============================ 家长端收件箱 ============================ + + /** 已发布活动列表(家长/学生视角,含未读总数) */ + @GetMapping("List") + public ActivityPageResult list(ActivityQuery query) { + return activityService.list(query); + } + + /** 清单:一键全部已读 */ + @PostMapping("InitializeRed") + @OperationLog(module = "活动管理", content = "一键全部已读") + public InitializeRedVo initializeRed() { + return activityService.initializeRed(); + } + + // ============================ 我发布的 / 全部活动 ============================ + + /** 我发布的 / 全部活动列表(scope="user" 只看自己) */ + @GetMapping("SendList") + public BasePageResult sendList(ActivityQuery query) { + return activityService.sendList(query); + } + + // ============================ 详情 ============================ + + /** 查看详情(type=1 标读;type=0 编辑/管理视角不标读) */ + @GetMapping("Detail") + public ActivityDetailVo detail(@RequestParam("serid") Integer serid, + @RequestParam(value = "type", defaultValue = "0") Integer type) { + return activityService.detail(serid, type); + } + + /** 编辑回显(不标读) */ + @GetMapping("GetDetail") + public ActivityDetailVo getDetail(@RequestParam("serid") Integer serid) { + return activityService.getDetail(serid); + } + + /** 上一篇/下一篇 */ + @GetMapping("GetNextPrev") + public NextPrevVo getNextPrev(@RequestParam("serid") Integer serid, + @RequestParam(value = "type", defaultValue = "next") String type) { + return activityService.getNextPrev(serid, type); + } + + /** 阅读情况(按班级分组读/未读名单) */ + @GetMapping("ReadDetail") + public List readDetail(@RequestParam("serid") Integer serid) { + return activityService.readDetail(serid); + } + + /** 追加接收班级(补发,自动补回执记录) */ + @PostMapping("ReceiveClasses") + @OperationLog(module = "活动管理", content = "追加接收班级") + public void receiveClasses(@RequestParam("serid") Integer serid, + @RequestParam("classes") String classes) { + activityService.receiveClasses(serid, classes); + } + + // ============================ 发布/编辑/删除 ============================ + + /** 新建活动,返回活动 id */ + @PostMapping("Create") + @OperationLog(module = "活动管理", content = "新建活动") + public Integer create(@RequestBody ActivityRequest request) { + return activityService.create(request); + } + + /** 编辑活动(含 sourceId 复用场景) */ + @PostMapping("Edit") + @OperationLog(module = "活动管理", content = "编辑活动") + public void edit(@RequestBody ActivityRequest request) { + activityService.edit(request); + } + + /** 批量删除(级联清回执/已读/附件) */ + @PostMapping("Delete") + @OperationLog(module = "活动管理", content = "删除活动") + public void delete(@RequestBody List ids) { + activityService.delete(ids); + } + + /** 撤回(家长端不可见) */ + @PostMapping("UserPull") + @OperationLog(module = "活动管理", content = "撤回活动") + public void userPull(@RequestParam("serid") Integer serid) { + activityService.userPull(serid); + } + + /** 置顶/取消置顶 */ + @PostMapping("SetTopmost") + @OperationLog(module = "活动管理", content = "设置置顶") + public void setTopmost(@RequestParam("serid") Integer serid, + @RequestParam(value = "topmost", defaultValue = "1") Integer topmost) { + activityService.setTopmost(serid, topmost); + } + + /** 催办未读 */ + @PostMapping("Expedite") + @OperationLog(module = "活动管理", content = "催办未读") + public void expedite(@RequestParam("serid") Integer serid) { + activityService.expedite(serid); + } + + // ============================ 报名回执 ============================ + + /** 某活动的回执/报名名单(按班级分组)+ 汇总 */ + @GetMapping("Signups") + public SignupsResultVo signups(@RequestParam("serid") Integer serid, + @RequestParam(value = "signupStatus", defaultValue = "all") String signupStatus) { + return activityService.signups(serid, signupStatus); + } + + /** 单条回执详情 */ + @GetMapping("SignupDetail") + public SignupDetailVo signupDetail(@RequestParam("signupId") Integer signupId) { + return activityService.signupDetail(signupId); + } + + /** 处理回执:action=confirm/cancel/waitlist */ + @PostMapping("Review") + @OperationLog(module = "活动管理", content = "处理回执") + public void review(@RequestBody ReviewRequest request) { + activityService.review(request); + } + + /** 催报名,返回 {reminded} */ + @PostMapping("Remind") + @OperationLog(module = "活动管理", content = "催报名") + public Map remind(@RequestParam("serid") Integer serid) { + return activityService.remind(serid); + } + + /** 导出回执名单(JSON 包装 {csv},前端取 csv 字段生成文件下载) */ + @GetMapping("ExportSignups") + public Map exportSignups(@RequestParam("serid") Integer serid) { + return activityService.exportSignups(serid); + } + + // ============================ 家长端(v2) ============================ + + /** 活动广场(含分桶计数) */ + @GetMapping("MyActivities") + public MyActivitiesVo myActivities(ActivityQuery query) { + return activityService.myActivities(query); + } + + /** 我对某活动的详情(同时标读;confirm 模式自动确认) */ + @GetMapping("MySignup") + public MyActivityVo mySignup(@RequestParam("serid") Integer serid) { + return activityService.mySignup(serid); + } + + /** 报名 / 修改报名,返回 {signupId, status} */ + @PostMapping("Signup") + @OperationLog(module = "活动管理", content = "家长报名") + public Map signup(@RequestBody SignupRequest request) { + return activityService.signup(request); + } + + /** 取消报名 */ + @PostMapping("CancelSignup") + @OperationLog(module = "活动管理", content = "取消报名") + public void cancelSignup(@RequestParam("serid") Integer serid) { + activityService.cancelSignup(serid); + } + + // ============================ 统计 / 附件 ============================ + + /** 活动统计 */ + @GetMapping("Statis") + public ActivityStatisticsVo statistics(@RequestParam(value = "startTime", required = false) String startTime, + @RequestParam(value = "endTime", required = false) String endTime) { + return activityService.statistics(startTime, endTime); + } + + /** 全部附件打包下载 */ + @GetMapping("AllFileDown") + public void allFileDown(@RequestParam("dataid") Integer dataid, HttpServletResponse response) { + activityService.allFileDown(dataid, response); + } +} diff --git a/src/main/java/com/yunhai/familyactivity/controller/api/BaseApiController.java b/src/main/java/com/yunhai/familyactivity/controller/api/BaseApiController.java new file mode 100644 index 0000000..c121fe8 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/controller/api/BaseApiController.java @@ -0,0 +1,212 @@ +package com.yunhai.familyactivity.controller.api; + +import com.yunhai.familyactivity.config.Config; +import com.yunhai.familyactivity.model.UpFileResultVo; +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.service.IClassService; +import com.yunhai.familyactivity.service.IFileService; +import com.yunhai.familyactivity.service.IUserCenterService; +import com.yunhai.familyactivity.utils.UserContext; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaModule; +import com.yunhai.platform.utils.model.BoaUser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletRequest; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 平台代理域接口(base/)。 + *

路径前缀:{@code Config.API_PATH + "/base/"}(原硬编码已修正)。 + * 承载平台代理接口:GetUserInfo / GetUserMod / GetSchoolAll / GetClassTree / SetRole / GetGroupUser / Logout, + * 业务逻辑已下沉 {@link IUserCenterService},本控制器仅做"取参 → 调 Service → 返回"。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/base/") +public class BaseApiController { + + private static final Logger log = LoggerFactory.getLogger(BaseApiController.class); + + /** 会话属性:当前视角(teacher 发布端 / parent 家长端,前端 App.vue 按路由前缀切换) */ + public static final String SESSION_VIEW_ROLE = "activityViewRole"; + + @Autowired + private IUserCenterService userCenterService; + + @Autowired + private IFileService fileService; + + @Autowired + private IClassService classService; + + // ============================ FileUpload ============================ + + /** + * 附件上传(单文件)。 + * 前端: POST base/FileUpload (form-data: file) + * 返回: { fileid, host, fileurl, name, type, size } + */ + @PostMapping("FileUpload") + public UpFileResultVo fileUpload(@RequestParam("file") MultipartFile file) { + return fileService.uploadToResult(file, "activity"); + } + + // ============================ GetUserInfo ============================ + + /** + * 获取当前登录用户信息(CAS 会话注入)。 + */ + @GetMapping("GetUserInfo") + public BoaUser getUserInfo(BoaUser authUserWithDept) { + return authUserWithDept; + } + + // ============================ GetUserMod ============================ + + /** + * 获取当前用户已授权的模块列表(左侧菜单树)。 + * 模块树组装已下沉 IUserCenterService。 + */ + @GetMapping("GetUserMod") + public List getUserMod(BoaUser authUserWithDept) { + Integer schId = authUserWithDept.getCurrentRole() != null + ? authUserWithDept.getCurrentRole().getSchId() : null; + return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId); + } + + // ============================ GetSchoolAll ============================ + + /** + * 获取当前用户学校组及用户(树形结构)。 + * 组树组装/教师挂组/递归建树逻辑已下沉 IUserCenterService。 + */ + @GetMapping("GetSchoolAll") + public List> getSchoolAll(BoaUser authUserWithDept) { + Integer schId = authUserWithDept.getCurrentRole() != null + ? authUserWithDept.getCurrentRole().getSchId() : null; + return userCenterService.getSchoolAllTree(schId); + } + + // ============================ GetGroupUser ============================ + + /** + * 获取指定组内用户(教师 + 管理员)。 + * 空桩已改为显式报错(避免前端误以为组内无用户),由 IUserCenterService 统一处理。 + */ + @GetMapping("GetGroupUser") + public List> getGroupUser( + @RequestParam("gid") Integer gid, + @RequestParam(value = "role", defaultValue = "2") Integer role) { + return userCenterService.getGroupUser(gid, role); + } + + // ============================ GetClassTree ============================ + + /** + * 年级 → 班级树(活动发布对象:选择到班级一级,叶子带 studentCount)。 + * 前端: GET base/GetClassTree,返回 ClassTreeNode[] + */ + @GetMapping("GetClassTree") + public List> getClassTree(BoaUser authUserWithDept) { + Integer schId = authUserWithDept.getCurrentRole() != null + ? authUserWithDept.getCurrentRole().getSchId() : null; + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "缺少学校信息"); + } + SchInfoDto sch = classService.getSchFirst(schId); + if (sch == null || !StringUtils.hasText(sch.getSchSerId())) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取学校信息失败"); + } + List grades = classService.getSchGradeList(sch.getSchSerId()); + List classes = classService.getSchClassList(sch.getSchSerId()); + // 每班学生数(一次取全校学生统计,失败降级为不计学生数) + Map stuCount = new HashMap<>(); + try { + List students = classService.getSchStuList(sch.getSchSerId()); + stuCount = students.stream() + .filter(s -> s.getClassId() != null) + .collect(Collectors.groupingBy(SchStudentDto::getClassId, Collectors.counting())); + } catch (Exception e) { + log.warn("[GetClassTree] 学生数统计失败,降级为 0: {}", e.getMessage()); + } + Map finalStuCount = stuCount; + Map> byGrade = classes.stream() + .filter(c -> c.getGradeId() != null) + .collect(Collectors.groupingBy(SchClassDto::getGradeId)); + + List> tree = new ArrayList<>(); + for (SchGradeDto grade : grades) { + Map gradeNode = new LinkedHashMap<>(); + gradeNode.put("id", grade.getId()); + gradeNode.put("name", grade.getName()); + gradeNode.put("isParent", true); + List> children = new ArrayList<>(); + long gradeStuCount = 0; + for (SchClassDto cls : byGrade.getOrDefault(grade.getId(), Collections.emptyList())) { + long count = finalStuCount.getOrDefault(cls.getClassId(), 0L); + gradeStuCount += count; + Map classNode = new LinkedHashMap<>(); + classNode.put("id", cls.getClassId()); + classNode.put("name", StringUtils.hasText(cls.getClassName()) ? cls.getClassName() : String.valueOf(cls.getClassId())); + classNode.put("isParent", false); + classNode.put("studentCount", count); + classNode.put("children", new ArrayList<>()); + children.add(classNode); + } + gradeNode.put("studentCount", gradeStuCount); + gradeNode.put("children", children); + tree.add(gradeNode); + } + return tree; + } + + // ============================ SetRole ============================ + + /** + * 端分离入口:切换当前视角(teacher 发布端 / parent 家长端)。 + * 前端: POST base/SetRole (JSON: {as}),需在拉取用户信息前调用。 + */ + @PostMapping("SetRole") + public Map setRole(@RequestBody(required = false) Map body, + HttpServletRequest request) { + String as = body != null ? body.get("as") : null; + if (!"teacher".equals(as) && !"parent".equals(as)) { + throw new BizException(ErrorCode.PARAM_ERROR, "不支持的角色: " + as); + } + request.getSession(true).setAttribute(SESSION_VIEW_ROLE, as); + log.info("[SetRole] 切换视角: {} (account={})", as, UserContext.getAccount()); + Map result = new HashMap<>(); + result.put("role", as); + return result; + } + + // ============================ Logout ============================ + + /** + * 退出登录。 + * 认证由平台网关处理,此处仅返回成功。 + */ + @PostMapping("Logout") + public void logout() { + LoginUser user = UserContextHolder.get(); + log.info("[logout] 用户退出登录: {}", user == null ? null : user.getLoginName()); + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/familyactivity/controller/api/ContactsController.java b/src/main/java/com/yunhai/familyactivity/controller/api/ContactsController.java new file mode 100644 index 0000000..0652e9d --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/controller/api/ContactsController.java @@ -0,0 +1,99 @@ +package com.yunhai.familyactivity.controller.api; + +import com.yunhai.familyactivity.config.Config; +import com.yunhai.familyactivity.service.IContactsService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +/** + * 联系组接口(contacts/)。 + *

路径前缀:{@code Config.API_PATH + "/contacts/"}(原硬编码已修正为 Config 拼接)。 + * 方法名已为 PascalCase 且写操作已是 POST,保持不动。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/contacts/") +public class ContactsController { + + @Autowired + private IContactsService contactsService; + + /** + * 添加联系组。 + */ + @PostMapping("AddGroup") + public void addGroup( + @RequestParam("name") String name, + @RequestParam(value = "type", required = false, defaultValue = "1") String type) { + contactsService.addGroup(name, Integer.parseInt(type)); + } + + /** + * 获取联系组列表。 + */ + @GetMapping("GetGroup") + public Map getGroup( + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "count", defaultValue = "0") Integer count, + @RequestParam(value = "type", defaultValue = "1") Integer type) { + return contactsService.getGroup(page, count, type); + } + + /** + * 编辑联系组。 + */ + @PostMapping("EditGroup") + public void editGroup( + @RequestParam("name") String name, + @RequestParam("id") String id, + @RequestParam(value = "type", required = false, defaultValue = "1") String type) { + contactsService.editGroup(Integer.parseInt(id), name, Integer.parseInt(type)); + } + + /** + * 删除联系组(支持批量)。 + */ + @PostMapping("DelGroup") + public void delGroup(@RequestParam("ids") String ids) { + contactsService.delGroup(ids); + } + + /** + * 设置联系组教师。 + */ + @PostMapping("SetContactsTea") + public void setContactsTea( + @RequestParam("gid") String gid, + @RequestParam(value = "ids", required = false) String ids) { + contactsService.setContactsTea(Integer.parseInt(gid), ids); + } + + /** + * 获取联系组教师列表。 + */ + @GetMapping("GetContactsTea") + public Map getContactsTea( + @RequestParam("gid") String gid, + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "count", defaultValue = "0") Integer count) { + return contactsService.getContactsTea(gid, page, count); + } + + /** + * 获取联系组树。 + */ + @GetMapping("GetGroupTree") + public List> getGroupTree(@RequestParam("type") Integer type) { + return contactsService.getGroupTree(type); + } + + /** + * 获取部门角色组树。 + */ + @GetMapping("GetDeptRoleGroupTree") + public List> getDeptRoleGroupTree() { + return contactsService.getDeptRoleGroupTree(); + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/familyactivity/controller/api/DictController.java b/src/main/java/com/yunhai/familyactivity/controller/api/DictController.java new file mode 100644 index 0000000..a60519d --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/controller/api/DictController.java @@ -0,0 +1,78 @@ +package com.yunhai.familyactivity.controller.api; + +import com.yunhai.familyactivity.config.Config; +import com.yunhai.familyactivity.service.IDictService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 系统字典接口(主办部门 / 活动分类域)。 + *

路径前缀:{@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 下沉):

+ *
    + *
  1. userbase 读取当前学校整棵组树(getGroupById(schId, withSub=true)),剔除学生组(type=8)及其子组;
  2. + *
  3. bschclass 获取该校全部教师(/oapi/DbClass/GetSchteaList?Group=schId),按 GroupIds 挂到对应组下, + * 无有效组(如所属组全被剔除)的教师兜底挂学校根节点;
  4. + *
  5. 扁平节点按 pid 递归组装为树(空组剔除,同 C# FormatUserTree)。
  6. + *
+ * + * @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 grades = gradeNames(schId); + Map> stuByClass = studentsByClass(schId, new ArrayList<>(classes.keySet())); + Map classSize = new HashMap<>(); + stuByClass.forEach((k, v) -> classSize.put(k, v.size())); + LocalDateTime now = LocalDateTime.now(); + + return activities.stream().map(a -> { + ActivityVo vo = toVo(a); + // 列表列空间有限:receiveText 用摘要,完整文本另存 receiveTextFull 供前端悬浮提示等场景 + vo.setReceiveText(receiveTextBrief(a, classes, grades)); + vo.setReceiveTextFull(receiveText(a, classes, grades)); + List targetIds = targetClassIds(a, classes); + Set targetSet = new LinkedHashSet<>(targetIds); + int allowCount = targetIds.stream().mapToInt(t -> classSize.getOrDefault(t, 0)).sum(); + // 已读只按目标班级累计(收窄接收班级后的残留已读行不计入) + int readCount = readMap.getOrDefault(a.getId(), Collections.emptyList()).stream() + .mapToInt(r -> targetSet.contains(parseSafe(r.getClassId())) + ? classSize.getOrDefault(parseSafe(r.getClassId()), 0) : 0).sum(); + vo.setAllowCount(allowCount); + vo.setReadCount(readCount); + // 管理端列表不填已读标志:fa_activity_read 是班级粒度且 studentId 只记首读学生, + // 发布人不在其中,按人判断恒为 false(家长端 list() 另行填充) + vo.setRead(false); + if (a.isPublished() && !ReceiptModeEnum.NONE.getCode().equalsIgnoreCase(a.getReceiptMode())) { + vo.setProgress(computeProgress(a, signupMap.getOrDefault(a.getId(), Collections.emptyList()), allowCount)); + } + return vo; + }).collect(Collectors.toList()); + } + + /** 基础 VO 字段映射 */ + private ActivityVo toVo(Activity a) { + ActivityVo vo = new ActivityVo(); + vo.setId(a.getId()); + vo.setTitle(a.getTitle()); + vo.setContent(a.getContent()); + vo.setCategory(a.getCategory()); + vo.setVenue(a.getVenue()); + vo.setStartTime(fmt(a.getStartTime())); + vo.setEndTime(fmt(a.getEndTime())); + vo.setSignupDeadline(fmt(a.getSignupDeadline())); + ReceiptModeEnum mode = ReceiptModeEnum.of(a.getReceiptMode()); + vo.setReceiptMode(mode.getCode()); + vo.setReceiptModeDesc(mode.getDesc()); + vo.setQuota(a.getQuota()); + vo.setAllowAccompany(a.getAllowAccompany()); + vo.setCost(a.getCost()); + vo.setPublisherId(a.getPublisherId()); + vo.setPublisherName(a.getPublisherName()); + vo.setDeptId(a.getDeptId()); + vo.setDeptName(a.getDeptName()); + vo.setStatus(a.getStatus()); + vo.setStatusDesc(a.getStatus() != null && a.getStatus() == STATUS_PUBLISHED ? "已发布" : "草稿"); + ActivityStatusEnum st = deriveStatus(a, LocalDateTime.now()); + vo.setActStatus(st.getCode()); + vo.setActStatusDesc(st.getDesc()); + vo.setTopmost(a.getTopmost()); + vo.setIsPulled(a.getIsPulled()); + vo.setTimingSendTime(fmt(a.getTimingSendTime())); + vo.setSmsFlag(a.getSmsFlag()); + vo.setReceiveType(a.getReceiveType()); + vo.setReceiveList(a.getReceiveList()); + vo.setCreateBy(a.getCreateBy()); + vo.setCreateTime(fmt(a.getCreateTime())); + vo.setUpdateBy(a.getUpdateBy()); + vo.setUpdateTime(fmt(a.getUpdateTime())); + return vo; + } + + private ActivityDetailVo toDetailVo(Activity a) { + ActivityDetailVo vo = new ActivityDetailVo(); + vo.setSerid(a.getId()); + vo.setTitle(a.getTitle()); + vo.setContents(a.getContent()); + vo.setDeptId(a.getDeptId()); + vo.setDeptName(a.getDeptName()); + vo.setTeaName(a.getPublisherName()); + vo.setEditDate(fmt(a.getCreateTime())); + vo.setReceiveList(a.getReceiveList()); + vo.setDsfsTime(fmt(a.getTimingSendTime())); + vo.setDsfs(a.getTimingSendTime() != null); + vo.setCategory(a.getCategory()); + vo.setVenue(a.getVenue()); + vo.setStartTime(fmt(a.getStartTime())); + vo.setEndTime(fmt(a.getEndTime())); + vo.setSignupDeadline(fmt(a.getSignupDeadline())); + ReceiptModeEnum mode = ReceiptModeEnum.of(a.getReceiptMode()); + vo.setReceiptMode(mode.getCode()); + vo.setReceiptModeDesc(mode.getDesc()); + vo.setQuota(a.getQuota()); + vo.setAllowAccompany(a.getAllowAccompany()); + vo.setCost(a.getCost()); + ActivityStatusEnum st = deriveStatus(a, LocalDateTime.now()); + vo.setActStatus(st.getCode()); + vo.setActStatusDesc(st.getDesc()); + vo.setReceiveType(a.getReceiveType()); + // 接收对象列表:"all" 或班级 id 数组 + if (a.getReceiveList() != null && "ALL".equalsIgnoreCase(a.getReceiveList().trim())) { + vo.setTlist("all"); + } else { + vo.setTlist(parseIds(a.getReceiveList()).stream().map(String::valueOf) + .collect(Collectors.toList())); + } + // 附件 + List files = upFileRepository + .findByTopicTypeAndTopicId(TOPIC_TYPE, a.getId()).stream() + .map(f -> { + ActivityDetailFileItem item = new ActivityDetailFileItem(); + item.setId(f.getId()); + item.setName(f.getFileTrueName() != null ? f.getFileTrueName() : f.getFileName()); + item.setSize(f.getFileSize() == null ? null : f.getFileSize().longValue()); + item.setFileid(f.getId()); + item.setFilename(f.getFileName()); + return item; + }) + .collect(Collectors.toList()); + vo.setChaoSend(files); + + // 已读/进度统计 + Integer schId = currentSchId(); + Map classes = schoolClasses(schId); + Map grades = gradeNames(schId); + vo.setReceiveText(receiveText(a, classes, grades)); + List targetIds = targetClassIds(a, classes); + Map> byClass = studentsByClass(schId, targetIds); + int allowCount = byClass.values().stream().mapToInt(List::size).sum(); + int readCount = activityReadRepository.findByActivityId(a.getId()).stream() + .mapToInt(r -> byClass.getOrDefault(parseSafe(r.getClassId()), Collections.emptyList()).size()) + .sum(); + vo.setNumcount(allowCount); + vo.setReadnumcount(readCount); + if (a.isPublished() && !ReceiptModeEnum.NONE.getCode().equalsIgnoreCase(a.getReceiptMode())) { + List records = activitySignupRepository.findByActivityId(a.getId()); + int should = allowCount > 0 ? allowCount : records.size(); + vo.setProgress(computeProgress(a, records, should)); + } + return vo; + } + + private SignupBrief toBrief(ActivitySignup r) { + SignupBrief brief = new SignupBrief(); + brief.setSignupId(r.getId()); + brief.setStudentId(r.getStudentId()); + brief.setStudentName(r.getStudentName()); + brief.setClassName(r.getClassName()); + brief.setGuardianName(r.getGuardianName()); + brief.setGuardianPhone(r.getGuardianPhone()); + brief.setJoinCount(r.getJoinCount()); + brief.setStatus(r.getStatus()); + brief.setStatusDesc(SignupStatusEnum.of(r.getStatus()).getDesc()); + brief.setSignedAt(fmt(r.getSignedAt())); + boolean receipt = !SignupStatusEnum.NONE.getCode().equalsIgnoreCase(r.getStatus()); + brief.setHasReceipt(receipt); + brief.setRemark(r.getRemark()); + return brief; + } + + /** 是否占用名额(已报名/已确认) */ + private boolean isOccupying(ActivitySignup r) { + return SignupStatusEnum.SIGNED.getCode().equalsIgnoreCase(r.getStatus()) + || SignupStatusEnum.CONFIRMED.getCode().equalsIgnoreCase(r.getStatus()); + } + + private ParentActivityVo toParentActivityVo(Activity a, ActivitySignup my, + ActivityStatusEnum st, LocalDateTime now, int usedQuota) { + ParentActivityVo vo = new ParentActivityVo(); + vo.setId(a.getId()); + vo.setTitle(a.getTitle()); + vo.setCategory(a.getCategory()); + vo.setVenue(a.getVenue()); + vo.setStartTime(fmt(a.getStartTime())); + vo.setEndTime(fmt(a.getEndTime())); + vo.setSignupDeadline(fmt(a.getSignupDeadline())); + ReceiptModeEnum mode = ReceiptModeEnum.of(a.getReceiptMode()); + vo.setReceiptMode(mode.getCode()); + vo.setReceiptModeDesc(mode.getDesc()); + vo.setQuota(a.getQuota()); + vo.setAllowAccompany(a.getAllowAccompany()); + vo.setCost(a.getCost()); + vo.setContent(a.getContent()); + vo.setPublisherName(a.getPublisherName()); + vo.setDeptName(a.getDeptName()); + vo.setCreateTime(fmt(a.getCreateTime())); + vo.setActStatus(st.getCode()); + vo.setActStatusDesc(st.getDesc()); + boolean viewOnly = mode == ReceiptModeEnum.NONE; + vo.setViewOnly(viewOnly); + boolean myActive = my != null && isOccupying(my); + boolean full = !viewOnly && a.getQuota() != null && a.getQuota() > 0 && usedQuota >= a.getQuota(); + // 与服务端 signup() 校验链对齐:名额已满(含本人记录已取消后想重报的场景)一律不可再报 + vo.setCanSignup(!viewOnly && st == ActivityStatusEnum.SIGNUP && !myActive && !full); + vo.setMy(toMySignupInfo(my)); + return vo; + } + + private MySignupInfo toMySignupInfo(ActivitySignup my) { + if (my == null) { + return null; + } + return new MySignupInfo(my.getId(), my.getStatus(), + SignupStatusEnum.of(my.getStatus()).getDesc(), + my.getJoinCount(), my.getGuardianName(), my.getGuardianPhone(), + my.getRemark(), fmt(my.getSignedAt())); + } + + private Map mySignupMap(ParentContext pc, List candidates) { + Integer myStudentId = studentIdOf(pc); + if (myStudentId == null || candidates.isEmpty()) { + return Collections.emptyMap(); + } + List ids = candidates.stream().map(Activity::getId).collect(Collectors.toList()); + return activitySignupRepository.findByStudentIdAndActivityIdIn(myStudentId, ids).stream() + .collect(Collectors.toMap(ActivitySignup::getActivityId, r -> r, (a, b) -> a)); + } + + /** 接收对象可读文本:全校班级 / 按年级分组合并展示(如「高一 1班、2班、3班;高二 1班」),省去逐班重复的年级前缀 */ + private String receiveText(Activity a, Map classes, Map grades) { + String receiveList = a.getReceiveList(); + if (receiveList == null) { + return ""; + } + if ("ALL".equalsIgnoreCase(receiveList.trim())) { + return "全校班级"; + } + ReceiveClassGroups g = groupReceiveClasses(a, classes, grades); + List parts = new ArrayList<>(g.plain); + g.byGrade.forEach((grade, names) -> parts.add(grade + " " + String.join("、", names))); + return String.join(";", parts); + } + + /** 列表用接收班级摘要:完整文本过长时只展示首个年级分组的前3个班级,以「等N个班级」收尾,避免列表列被撑爆(详情页仍用完整版) */ + private String receiveTextBrief(Activity a, Map classes, Map grades) { + String full = receiveText(a, classes, grades); + if (full.length() <= RECEIVE_TEXT_BRIEF_LIMIT) { + return full; + } + ReceiveClassGroups g = groupReceiveClasses(a, classes, grades); + int total = parseIds(a.getReceiveList()).size(); + Map.Entry> first = g.byGrade.isEmpty() ? null : g.byGrade.entrySet().iterator().next(); + String head; + int shown; + if (first != null) { + shown = Math.min(3, first.getValue().size()); + head = first.getKey() + " " + String.join("、", first.getValue().subList(0, shown)); + } else { + shown = Math.min(3, g.plain.size()); + head = String.join("、", g.plain.subList(0, shown)); + } + // 展示的班级已覆盖全部接收班级时无需「等N个班级」 + return shown >= total ? full : head + "等" + total + "个班级"; + } + + /** 列表接收班级摘要的长度阈值:完整文本不超过该长度时原样展示 */ + private static final int RECEIVE_TEXT_BRIEF_LIMIT = 16; + + /** 接收班级按年级归组结果(plain=无法按年级归组而平铺的班级名,均保持接收顺序) */ + private static class ReceiveClassGroups { + final List plain = new ArrayList<>(); + final Map> byGrade = new LinkedHashMap<>(); + } + + private ReceiveClassGroups groupReceiveClasses(Activity a, Map classes, Map grades) { + ReceiveClassGroups g = new ReceiveClassGroups(); + for (Integer id : parseIds(a.getReceiveList())) { + SchClassDto c = id == null ? null : classes.get(id); + String name = c != null && StringUtils.hasText(c.getClassName()) ? c.getClassName() : String.valueOf(id); + String grade = c != null && c.getGradeId() != null ? grades.get(c.getGradeId()) : null; + // 班级名本身已带年级(如「初一(1)班」)或缺年级信息时不并入分组,原样展示 + if (!StringUtils.hasText(grade) || name.contains(grade)) { + g.plain.add(name); + } else { + g.byGrade.computeIfAbsent(grade, k -> new ArrayList<>()).add(name); + } + } + return g; + } + + private String classNameOf(Map classes, Integer classId) { + if (classId == null) { + return ""; + } + SchClassDto c = classes.get(classId); + return c != null && StringUtils.hasText(c.getClassName()) ? c.getClassName() : String.valueOf(classId); + } + + private void fillProgressItem(ActivityStatisticsVo.MyActivityItem item, ActivityProgress p, + long readCount, long allowCount) { + item.setShould(p.getShould()); + item.setSigned(p.getSigned()); + item.setConfirmed(p.getConfirmed()); + item.setCanceled(p.getCanceled()); + item.setUnsigned(p.getUnsigned()); + item.setSignupRate(p.getSignupRate()); + item.setQuota(p.getQuota()); + item.setUsedQuota(p.getUsedQuota()); + item.setFull(p.isFull()); + item.setReadCount(readCount); + item.setAllowCount(allowCount); + } + + private List parseIds(String csv) { + if (!StringUtils.hasText(csv)) { + return new ArrayList<>(); + } + return Arrays.stream(csv.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(this::parseSafe) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + } + + private Integer parseSafe(String s) { + try { + return Integer.valueOf(s.trim()); + } catch (Exception e) { + return null; + } + } + + private LocalDate parseDate(String s) { + if (!StringUtils.hasText(s)) { + return null; + } + try { + return LocalDate.parse(s.trim(), DateTimeFormatter.ofPattern("yyyy-MM-dd")); + } catch (Exception e) { + return null; + } + } + + private String fmt(LocalDateTime time) { + return time == null ? null : DATETIME.format(time); + } + + private int intVal(Integer v) { + return v == null ? 0 : v; + } + + private int nz(Integer v) { + return v == null ? 0 : v; + } + + /** LIKE 通配符转义,避免用户输入 %/_ 被当作通配符全表模糊匹配 */ + private String likeEscape(String s) { + return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + } + + private String csvCell(String v) { + if (v == null) { + return ""; + } + String s = v; + // 防 Excel 公式注入:以 = + - @ 开头的文本前置单引号 + if (s.startsWith("=") || s.startsWith("+") || s.startsWith("-") || s.startsWith("@")) { + s = "'" + s; + } + if (s.contains(",") || s.contains("\"") || s.contains("\n") || s.contains("\r")) { + return "\"" + s.replace("\"", "\"\"") + "\""; + } + return s; + } +} diff --git a/src/main/java/com/yunhai/familyactivity/service/impl/ClassService.java b/src/main/java/com/yunhai/familyactivity/service/impl/ClassService.java new file mode 100644 index 0000000..3b615f2 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/service/impl/ClassService.java @@ -0,0 +1,159 @@ +package com.yunhai.familyactivity.service.impl; + +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 com.yunhai.familyactivity.service.IClassService; +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.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 班级管理(bschclass)开放接口实现。 + * bschclass 为开放接口(无需应用签名),使用独立 RestTemplate 直连。 + *

学段 GetSchLenlist、年级 GetSchLenLevellist、班级 GetVClassList、 + * 学生 GetSchStulist / GetClaArrStulist、组织树 SchGroup/GetStuTree、GetTeaTree。

+ */ +@Service +public class ClassService implements IClassService { + + private static final Logger log = LoggerFactory.getLogger(ClassService.class); + + /** 班级管理服务基址,如 http://base.yunhaixingtong.com/bschclass */ + @Value("${yh.open.bschclass:}") + private String bschClassHost; + + private final RestTemplate restTemplate; + + public ClassService() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(10000); + this.restTemplate = new RestTemplate(factory); + } + + @Override + public List getSchTeaList(int schId) { + return exchangeList(bschClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId, + new ParameterizedTypeReference>() { + }, "获取教师数据", "schId=" + schId); + } + + @Override + public SchInfoDto getSchFirst(int schId) { + requireHost(); + String url = bschClassHost + "/oapi/SchInfo/GetUserSchFirst?UserSchID=" + schId; + try { + ResponseEntity resp = restTemplate.getForEntity(URI.create(url), SchInfoDto.class); + return resp.getBody(); + } catch (RestClientException e) { + log.error("[getSchFirst] 请求班级管理接口失败, url={}", url, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取学校信息失败"); + } + } + + @Override + public List getSchLenList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/Schoolen/GetSchLenlist?SchSerID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取学段列表", "schSerId=" + schSerId); + } + + @Override + public List getSchGradeList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/Schoolen/GetSchLenLevellist?SchSerID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取年级列表", "schSerId=" + schSerId); + } + + @Override + public List getSchClassList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/VSchClass/GetVClassList?SchSerID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取班级列表", "schSerId=" + schSerId); + } + + @Override + public List getSchStuList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/OAStu/GetSchStulist?schserid=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取学校学生", "schSerId=" + schSerId); + } + + @Override + public List getClaArrStuList(List classIds) { + String csv = joinIds(classIds); + return exchangeList(bschClassHost + "/oapi/OAStu/GetClaArrStulist?ClassIDs=" + csv, + new ParameterizedTypeReference>() { + }, "获取班级学生", "classIds=" + csv); + } + + @Override + public List getStuTree(Integer schId, boolean toUser) { + return exchangeList(bschClassHost + "/oapi/SchGroup/GetStuTree?schId=" + schId + "&toUser=" + toUser, + new ParameterizedTypeReference>() { + }, "获取学生树", "schId=" + schId); + } + + @Override + public List getTeaTree(Integer schId, boolean toUser) { + return exchangeList(bschClassHost + "/oapi/SchGroup/GetTeaTree?schId=" + schId + "&toUser=" + toUser, + new ParameterizedTypeReference>() { + }, "获取教师树", "schId=" + schId); + } + + // ============================ 通用调用 ============================ + + private List exchangeList(String url, ParameterizedTypeReference> type, + String action, String arg) { + requireHost(); + try { + // GET + Accept: application/json + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + ResponseEntity> resp = restTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(headers), type); + List list = resp.getBody(); + log.info("[bschclass] {} 完成, {} 返回 {} 条", action, arg, list == null ? 0 : list.size()); + return list != null ? list : Collections.emptyList(); + } catch (RestClientException e) { + log.error("[bschclass] {} 失败, {}, url={}", action, arg, url, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, action + "失败"); + } + } + + private void requireHost() { + if (!StringUtils.hasText(bschClassHost)) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口未配置(yh.open.bschclass)"); + } + } + + private String joinIds(List ids) { + if (ids == null || ids.isEmpty()) { + return ""; + } + return ids.stream().map(String::valueOf).collect(Collectors.joining(",")); + } +} diff --git a/src/main/java/com/yunhai/familyactivity/service/impl/ContactsService.java b/src/main/java/com/yunhai/familyactivity/service/impl/ContactsService.java new file mode 100644 index 0000000..1ad7450 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/service/impl/ContactsService.java @@ -0,0 +1,398 @@ +package com.yunhai.familyactivity.service.impl; + +import com.yunhai.familyactivity.entity.ContactsGroup; +import com.yunhai.familyactivity.entity.ContactsGroupUser; +import com.yunhai.familyactivity.repository.ContactsGroupRepository; +import com.yunhai.familyactivity.repository.ContactsGroupUserRepository; +import com.yunhai.familyactivity.service.IContactsService; +import com.yunhai.familyactivity.utils.UserContext; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.base.YschBaseOpenApi; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 联系组服务实现。 + * 对应 C# ContactsController + ManageContacts.cs。 + */ +@Service +public class ContactsService implements IContactsService { + + private static final Logger log = LoggerFactory.getLogger(ContactsService.class); + + @Autowired + private ContactsGroupRepository groupRepository; + + @Autowired + private ContactsGroupUserRepository groupUserRepository; + + // ============================ AddGroup ============================ + + @Override + @Transactional + public void addGroup(String name, Integer type) { + if (!StringUtils.hasText(name)) { + throw new BizException(ErrorCode.PARAM_ERROR, "请输入自定义组的名字"); + } + if (type == null) { + type = 1; + } + Integer creatorId = currentUserId(); + + // 查重:同创建人下同名同类型组不重复 + List existing = groupRepository.findByCreatorId(creatorId); + boolean duplicate = existing.stream().anyMatch(g -> + name.equals(g.getGroupName())); + if (duplicate) { + throw new BizException(ErrorCode.FORBIDDEN, "已存在该联系组!"); + } + + ContactsGroup group = new ContactsGroup(); + group.setGroupName(name); + group.setCreatorId(creatorId); + groupRepository.save(group); + } + + // ============================ GetGroup ============================ + + @Override + public Map getGroup(Integer page, Integer count, Integer type) { + if (type == null) { + type = 1; + } + Integer creatorId = currentUserId(); + + // 查询当前用户创建的全部联系组 + List allGroups = groupRepository.findByCreatorId(creatorId); + + // 统计每组人数 + List groupIds = allGroups.stream().map(ContactsGroup::getId).collect(Collectors.toList()); + List allMembers = groupIds.isEmpty() ? Collections.emptyList() + : groupUserRepository.findByGroupIdIn(groupIds); + Map countByGroup = allMembers.stream() + .collect(Collectors.groupingBy(ContactsGroupUser::getGroupId, Collectors.counting())); + + // 构建列表数据 + List> listData = allGroups.stream().map(g -> { + Map item = new LinkedHashMap<>(); + item.put("serid", g.getId()); + item.put("GroupName", g.getGroupName()); + item.put("count", countByGroup.getOrDefault(g.getId(), 0L).intValue()); + return item; + }).collect(Collectors.toList()); + + // 分页 + int total = listData.size(); + if (count != null && count > 0 && page != null && page > 0) { + int fromIndex = (page - 1) * count; + int toIndex = Math.min(fromIndex + count, total); + if (fromIndex < total) { + listData = listData.subList(fromIndex, toIndex); + } else { + listData = Collections.emptyList(); + } + } + + Map result = new LinkedHashMap<>(); + result.put("list", listData); + result.put("total", total); + return result; + } + + // ============================ EditGroup ============================ + + @Override + @Transactional + public void editGroup(Integer id, String name, Integer type) { + if (!StringUtils.hasText(name)) { + throw new BizException(ErrorCode.PARAM_ERROR, "联系组名称不能为空"); + } + if (id == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误!"); + } + Integer creatorId = currentUserId(); + + // 查重(排除自身) + List existing = groupRepository.findByCreatorId(creatorId); + boolean duplicate = existing.stream().anyMatch(g -> + !g.getId().equals(id) && name.equals(g.getGroupName())); + if (duplicate) { + throw new BizException(ErrorCode.FORBIDDEN, "已存在该联系组"); + } + + ContactsGroup group = groupRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "联系组不存在")); + group.setGroupName(name); + groupRepository.save(group); + } + + // ============================ DelGroup ============================ + + @Override + @Transactional + public void delGroup(String ids) { + if (!StringUtils.hasText(ids)) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误,删除失败"); + } + List idList = Arrays.stream(ids.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(Integer::parseInt) + .collect(Collectors.toList()); + + // 先删成员再删组 + groupUserRepository.deleteByGroupIdInBulk(idList); + groupRepository.findAllById(idList).forEach(groupRepository::delete); + } + + // ============================ SetContactsTea ============================ + + @Override + @Transactional + public void setContactsTea(Integer gid, String ids) { + if (gid == null || gid == 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + // 全量替换:bulk delete 立即执行 SQL,避免 derived delete 延迟 flush + // 导致同事务 insert 先行触发 uk_group_user(参考 PartyLearn GroupService.memberEdit) + groupUserRepository.deleteByGroupIdBulk(gid); + + if (StringUtils.hasText(ids)) { + List userIds = Arrays.stream(ids.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(Integer::parseInt) + .distinct() + .collect(Collectors.toList()); + for (Integer userId : userIds) { + ContactsGroupUser member = new ContactsGroupUser(); + member.setGroupId(gid); + member.setUserId(userId); + // 姓名快照落库(对齐 PartyLearn memberEdit 保存 userName 的做法), + // 查询时 fillUserInfo 仅对缺失值兜底,避免组树/分页反复远程调用 + try { + BoaUser user = YschBaseOpenApi.getUserInfo(userId); + if (user != null) { + member.setUserName(user.getName()); + } + } catch (Exception e) { + log.warn("[setContactsTea] 查询用户信息失败, userId={}", userId, e); + } + groupUserRepository.save(member); + } + } + } + + // ============================ GetContactsTea ============================ + + @Override + public Map getContactsTea(String gid, Integer page, Integer count) { + Integer groupId = 0; + try { + groupId = Integer.parseInt(gid); + } catch (NumberFormatException e) { + groupId = 0; + } + if (groupId == 0) { + Map empty = new LinkedHashMap<>(); + empty.put("list", Collections.emptyList()); + empty.put("total", 0); + return empty; + } + + List allMembers = groupUserRepository.findByGroupId(groupId); + int total = allMembers.size(); + + // 先分页再补全用户信息(避免为未分页数据调用用户中心) + List pageMembers = allMembers; + if (count != null && count > 0 && page != null && page > 0) { + int fromIndex = (page - 1) * count; + int toIndex = Math.min(fromIndex + count, total); + pageMembers = fromIndex < total ? allMembers.subList(fromIndex, toIndex) : Collections.emptyList(); + } + + List> listData = pageMembers.stream().map(m -> { + Map item = new LinkedHashMap<>(); + item.put("sid", m.getId()); + item.put("id", m.getUserId()); + fillUserInfo(item, m); + return item; + }).collect(Collectors.toList()); + + Map result = new LinkedHashMap<>(); + result.put("list", listData); + result.put("total", total); + return result; + } + + /** + * 补全成员姓名:落库值为空时从用户中心按 userId 反查。 + * 查到的值会回写落库自愈,避免历史空数据每次读取都重复远程调用。 + */ + private void fillUserInfo(Map item, ContactsGroupUser m) { + String name = m.getUserName(); + if (m.getUserId() != null && !StringUtils.hasText(name)) { + BoaUser user = null; + try { + user = YschBaseOpenApi.getUserInfo(m.getUserId()); + } catch (Exception e) { + log.warn("[fillUserInfo] 查询用户中心用户失败, userId={}", m.getUserId(), e); + } + if (user == null) { + log.warn("[fillUserInfo] 用户中心未查到用户(历史脏数据?), userId={}", m.getUserId()); + } else { + if (!StringUtils.hasText(name)) { + name = user.getName(); + } + // 回写落库:下次读取直接命中快照 + if (!StringUtils.hasText(m.getUserName()) && StringUtils.hasText(name)) { + m.setUserName(name); + try { + groupUserRepository.save(m); + } catch (Exception e) { + log.warn("[fillUserInfo] 回写用户信息失败, userId={}", m.getUserId(), e); + } + } + } + } + item.put("name", name != null ? name : ""); + } + + // ============================ GetGroupTree ============================ + + @Override + public List> getGroupTree(Integer type) { + if (type == null) { + type = 1; + } + Integer creatorId = currentUserId(); + + // 查询当前用户的联系组 + List groups = groupRepository.findByCreatorId(creatorId); + List groupIds = groups.stream().map(ContactsGroup::getId).collect(Collectors.toList()); + + // 查询全部成员 + List allMembers = groupIds.isEmpty() ? Collections.emptyList() + : groupUserRepository.findByGroupIdIn(groupIds); + + // 构建树节点 + List> treeNodes = new ArrayList<>(); + + // 组节点 (type=1, pid=0) + for (ContactsGroup g : groups) { + Map node = new LinkedHashMap<>(); + node.put("id", g.getId()); + node.put("name", g.getGroupName()); + node.put("pid", 0); + node.put("no", 0); + node.put("type", 1); + node.put("isParent", true); + node.put("photo", ""); + node.put("children", new ArrayList>()); + treeNodes.add(node); + } + + // 用户节点 (type=2, pid=groupId),姓名缺失时从用户中心补全 + for (ContactsGroupUser m : allMembers) { + Map node = new LinkedHashMap<>(); + node.put("id", m.getUserId()); + node.put("name", ""); + node.put("pid", m.getGroupId()); + node.put("no", 0); + node.put("type", 2); + node.put("isParent", false); + node.put("photo", ""); + fillUserInfo(node, m); + treeNodes.add(node); + } + + // 递归构建树:各自定义组为顶级节点(空组自动剔除),组合成森林 + return formatUserTree(treeNodes, 0); + } + + // ============================ GetDeptRoleGroupTree ============================ + + @Override + public List> getDeptRoleGroupTree() { + // C# 从外部API获取部门树+用户角色映射。 + // Java版暂未接入用户中心OpenAPI,返回空列表。 + log.info("[getDeptRoleGroupTree] 暂未接入用户中心API,返回空列表"); + return Collections.emptyList(); + } + + // ============================ 辅助方法 ============================ + + /** + * 递归构建用户树(对应 C# HttpUserApi.FormatUserTree)。 + */ + @SuppressWarnings("unchecked") + private List> formatUserTree(List> treeList, int pid) { + List> data; + if (pid == 0) { + // 根层:取没有父节点引用的节点(pid=0 或 pid不在列表中的) + Set allIds = treeList.stream() + .map(n -> (Integer) n.get("id")) + .collect(Collectors.toSet()); + data = treeList.stream() + .filter(n -> { + int p = (Integer) n.get("pid"); + return p == 0 || !allIds.contains(p); + }) + .collect(Collectors.toList()); + } else { + data = treeList.stream() + .filter(n -> (Integer) n.get("pid") == pid) + .collect(Collectors.toList()); + } + + List> result = new ArrayList<>(); + for (Map item : data) { + int itemId = (Integer) item.get("id"); + int itemType = (Integer) item.get("type"); + + List> children = formatUserTree(treeList, itemId); + + // type=1(组) 且无子节点 → 不加入(C# 逻辑) + if (itemType == 1 && children.isEmpty()) { + continue; + } + + Map node = new LinkedHashMap<>(item); + // 仅组节点携带 children;叶子不带该字段,避免 ztree 误判 isParent + if (itemType == 1) { + node.put("children", children); + } + result.add(node); + } + + // 按 no 排序 + result.sort(Comparator.comparingInt(n -> { + Object no = n.get("no"); + if (no instanceof Integer) return (Integer) no; + if (no instanceof String) { + try { return Integer.parseInt((String) no); } catch (NumberFormatException e) { return 9999; } + } + return 9999; + })); + + return result; + } + + /** + * 当前登录人ID(平台用户ID,由 ControllerUserContextAspect 填充;无登录态时为 null)。 + */ + private Integer currentUserId() { + return UserContext.getUserId(); + } +} diff --git a/src/main/java/com/yunhai/familyactivity/service/impl/DictService.java b/src/main/java/com/yunhai/familyactivity/service/impl/DictService.java new file mode 100644 index 0000000..2fb6e69 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/service/impl/DictService.java @@ -0,0 +1,285 @@ +package com.yunhai.familyactivity.service.impl; + +import com.yunhai.familyactivity.entity.SysDict; +import com.yunhai.familyactivity.repository.ActivityRepository; +import com.yunhai.familyactivity.repository.SysDictRepository; +import com.yunhai.familyactivity.service.IDictService; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 系统字典服务实现。 + * 对应 C# CommonController + GetSysDict.cs 中的字典管理逻辑。 + * 支持两类字典:dept=主办部门、category=活动分类,CRUD 逻辑同构。 + */ +@Service +public class DictService implements IDictService { + + private static final Logger log = LoggerFactory.getLogger(DictService.class); + + /** 字典类型常量:主办部门 */ + private static final String DICT_TYPE_DEPT = "dept"; + /** 字典类型常量:活动分类 */ + private static final String DICT_TYPE_CATEGORY = "category"; + + @Autowired + private SysDictRepository sysDictRepository; + + @Autowired + private ActivityRepository activityRepository; + + // ============================ GetSysDic ============================ + + @Override + public List> getSysDic(Integer type) { + String dictType = mapDictType(type); + if (dictType == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + List list = sysDictRepository.findByDictTypeAndEnableFlagOrderBySortOrderAsc(dictType, 1); + if (list.isEmpty()) { + throw new BizException(ErrorCode.FORBIDDEN, emptyTip(dictType)); + } + // 按 sortOrder 升序返回 {k, v} + return list.stream() + .sorted(Comparator.comparingInt(d -> d.getSortOrder() == null ? Integer.MAX_VALUE : d.getSortOrder())) + .map(d -> { + Map item = new LinkedHashMap<>(); + item.put("k", d.getDictKey()); + item.put("v", d.getDictValue()); + return item; + }) + .collect(Collectors.toList()); + } + + // ============================ GetDictMangeList ============================ + + @Override + public List> getDictMangeList(String type) { + String dictType = normalizeManageType(type); + if (dictType == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + List list = sysDictRepository.findByDictTypeOrderBySortOrderAsc(dictType); + return list.stream() + .sorted(Comparator.comparingInt(d -> d.getSortOrder() == null ? 999 : d.getSortOrder())) + .map(d -> { + Map item = new LinkedHashMap<>(); + item.put("SerID", d.getId()); + item.put("CodeID", d.getDictKey()); + item.put("Name", d.getDictValue()); + item.put("Des", d.getSortOrder() != null ? d.getSortOrder().toString() : ""); + item.put("Enable", d.getEnableFlag()); + return item; + }) + .collect(Collectors.toList()); + } + + // ============================ AddDeptDic ============================ + + @Override + @Transactional + public void addDict(String dictType, String name) { + String type = normalizeManageType(dictType); + if (type == null || !StringUtils.hasText(name)) { + throw new BizException(ErrorCode.PARAM_ERROR, "输入字符串格式不正确!"); + } + // 校验重名 + List existing = sysDictRepository.findByDictTypeOrderBySortOrderAsc(type); + boolean duplicate = existing.stream().anyMatch(d -> + type.equals(d.getDictType()) && name.trim().equals(d.getDictValue()) && d.getEnableFlag() != 3); + if (duplicate) { + throw new BizException(ErrorCode.FORBIDDEN, "已存在该分类,请重新填写!"); + } + + // 计算 sortOrder = max + 1 + int maxSort = existing.stream() + .filter(d -> d.getEnableFlag() != 3) + .mapToInt(d -> d.getSortOrder() != null ? d.getSortOrder() : 0) + .max().orElse(0); + + SysDict dict = new SysDict(); + dict.setDictType(type); + dict.setDictValue(name.trim()); + dict.setSortOrder(maxSort + 1); + dict.setDictKey(type + (maxSort + 1)); + dict.setEnableFlag(1); + sysDictRepository.save(dict); + } + + // ============================ UpdateDeptDic ============================ + + @Override + @Transactional + public void updateDict(String dictType, Integer serID, String name, Integer enable) { + String type = normalizeManageType(dictType); + if (type == null || serID == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误!"); + } + // 校验重名(排除自身) + List existing = sysDictRepository.findByDictTypeOrderBySortOrderAsc(type); + boolean duplicate = existing.stream().anyMatch(d -> + !d.getId().equals(serID) && name != null && name.trim().equals(d.getDictValue()) && d.getEnableFlag() != 3); + if (duplicate) { + throw new BizException(ErrorCode.FORBIDDEN, "已存在该分类,请重新填写!"); + } + + SysDict dict = sysDictRepository.findById(serID) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "字典记录不存在")); + if (StringUtils.hasText(name)) { + dict.setDictValue(name.trim()); + } + if (enable != null) { + dict.setEnableFlag(enable); + } + sysDictRepository.save(dict); + } + + // ============================ DelDept ============================ + + @Override + @Transactional + public void delDict(String dictType, Integer serID) { + String type = normalizeManageType(dictType); + if (type == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误!"); + } + SysDict dict = sysDictRepository.findById(serID) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "字典记录不存在")); + if (DICT_TYPE_CATEGORY.equals(type)) { + // 活动分类:fa_activity.category 冗余存分类名称,按名称判断引用 + if (activityRepository.existsByCategory(dict.getDictValue())) { + throw new BizException(ErrorCode.FORBIDDEN, "已引用该分类,无法删除!"); + } + } else { + // 主办部门:按 deptId = dictKey 匹配 + if (activityRepository.existsByDeptId(dict.getDictKey())) { + throw new BizException(ErrorCode.FORBIDDEN, "已引用该部门,无法删除!"); + } + } + sysDictRepository.delete(dict); + } + + // ============================ OrdbyDicIds ============================ + + @Override + @Transactional + public void sortDict(String dictType, String ids) { + String type = normalizeManageType(dictType); + if (type == null || !StringUtils.hasText(ids)) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + List list = sysDictRepository.findByDictTypeOrderBySortOrderAsc(type); + if (list.isEmpty()) { + throw new BizException(ErrorCode.FORBIDDEN, "字典类型错误,未找到该字典类型"); + } + + // C# 逻辑:取当前最大 Sort 为起始序号,逆序遍历 ids 递减赋值 Des + int[] idArr = Arrays.stream(ids.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .mapToInt(Integer::parseInt) + .toArray(); + + int maxSort = list.stream() + .mapToInt(d -> d.getSortOrder() != null ? d.getSortOrder() : idArr.length) + .max().orElse(idArr.length); + + // lambda 中不能修改局部变量,用数组包装 + final int[] indexOrd = { maxSort }; + + // 逆序遍历,从最大序号递减 + for (int i = idArr.length - 1; i >= 0; i--) { + int id = idArr[i]; + sysDictRepository.findById(id).ifPresent(dict -> { + dict.setSortOrder(indexOrd[0]); + sysDictRepository.save(dict); + indexOrd[0]--; + }); + } + } + + // ============================ GetSchModuleIsSms ============================ + + @Override + public Boolean getSchModuleIsSms() { + // C# 通过外部 ISchIsSmsBLL 查询学校短信开关。 + // Java 版暂不接入外部短信平台,默认返回 false。 + String account = currentAccount(); + if (account == null) { + return false; + } + // 后续接入短信平台后补全查询逻辑 + return false; + } + + // ============================ 辅助方法 ============================ + + /** + * 按主办部门字典 key 解析部门名称(对应 C# GetDeptName)。 + */ + @Override + public String resolveDeptName(String deptId) { + if (!StringUtils.hasText(deptId)) { + return null; + } + return sysDictRepository.findByDictTypeAndDictKey(DICT_TYPE_DEPT, deptId.trim()) + .map(SysDict::getDictValue) + .orElse(null); + } + + /** + * 将前端 Type 参数映射到字典类型字符串。 + * C# 中使用枚举:1=DEPT(主办部门),2=CATEGORY(活动分类)。 + */ + private String mapDictType(Integer type) { + if (type == null) return null; + switch (type) { + case 1: return DICT_TYPE_DEPT; + case 2: return DICT_TYPE_CATEGORY; + // 其余类型(文印/请假/物品等)本系统不涉及 + default: return null; + } + } + + /** + * 管理端 type 参数归一化:"dept"/"category"(大小写不敏感,容忍首尾空白), + * 并兼容旧前端的数字编码:1=dept、2=category(与 GetSysDic 的 type 枚举一致),其余返回 null。 + */ + private String normalizeManageType(String type) { + if (type == null) return null; + String t = type.trim(); + if (t.isEmpty()) return null; + if (DICT_TYPE_DEPT.equalsIgnoreCase(t) || "1".equals(t)) return DICT_TYPE_DEPT; + if (DICT_TYPE_CATEGORY.equalsIgnoreCase(t) || "2".equals(t)) return DICT_TYPE_CATEGORY; + return null; + } + + /** + * 字典为空时按类型给出对应提示。 + */ + private String emptyTip(String dictType) { + return DICT_TYPE_CATEGORY.equals(dictType) + ? "尚未添加活动分类,请联系管理员在「应用设置」中添加!" + : "尚未添加主办部门,请联系管理员添加!"; + } + + /** + * 当前登录人账号(来自平台 UserContextHolder,无登录态时为 null)。 + */ + private String currentAccount() { + LoginUser user = UserContextHolder.get(); + return user == null ? null : user.getLoginName(); + } +} diff --git a/src/main/java/com/yunhai/familyactivity/service/impl/FileService.java b/src/main/java/com/yunhai/familyactivity/service/impl/FileService.java new file mode 100644 index 0000000..6c88a8a --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/service/impl/FileService.java @@ -0,0 +1,326 @@ +package com.yunhai.familyactivity.service.impl; + +import com.yunhai.familyactivity.entity.UpFile; +import com.yunhai.familyactivity.model.UpFileVo; +import com.yunhai.familyactivity.model.UpFileResultVo; +import com.yunhai.familyactivity.repository.UpFileRepository; +import com.yunhai.familyactivity.service.IFileService; +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.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.io.File; +import java.io.FileInputStream; +import java.io.OutputStream; +import java.net.URLConnection; +import java.net.URLEncoder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.ArrayList; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * 文件上传/下载/删除业务实现。 + *

+ * 存储路径规则:{uploadPath}/{appType}/{yyyyMMdd}/{uuid}.{ext} + * 与 C# UpLoadFile.FileSave 逻辑等价,但去除分片上传和 Office 转 PDF(后续按需补充)。 + */ +@Service +public class FileService implements IFileService { + + private static final Logger log = LoggerFactory.getLogger(FileService.class); + + @Autowired + private UpFileRepository upFileRepository; + + @Value("${file.upload-path}") + private String uploadPath; + + private static final String DEFAULT_APP_TYPE = "activity"; + + // ============================ 上传 ============================ + + @Override + @Transactional + public UpFileVo upload(MultipartFile file, String appType, Integer topicId) { + if (file == null || file.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不得为空"); + } + String originalName = file.getOriginalFilename(); + if (!StringUtils.hasText(originalName)) { + throw new BizException(ErrorCode.PARAM_ERROR, "文件名为空"); + } + // 提取后缀 + String ext = ""; + int dotIdx = originalName.lastIndexOf('.'); + if (dotIdx >= 0) { + ext = originalName.substring(dotIdx + 1).toLowerCase(); + } + + // 构造存储子路径:{appType}/{yyyyMMdd}/{uuid}.{ext} + String dateDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String savedName = UUID.randomUUID().toString().replace("-", "") + (ext.isEmpty() ? "" : "." + ext); + String relativePath = (appType == null ? DEFAULT_APP_TYPE : appType) + "/" + dateDir + "/" + savedName; + + // 物理写入 + Path fullPath = Paths.get(uploadPath, relativePath); + try { + Files.createDirectories(fullPath.getParent()); + file.transferTo(fullPath.toFile()); + } catch (Exception e) { + log.error("[upload] 文件保存失败: {}", fullPath, e); + throw new BizException(ErrorCode.ERROR, "文件保存失败", e); + } + + // 入库 + UpFile entity = new UpFile(); + entity.setTopicType(appType == null ? DEFAULT_APP_TYPE : appType); + entity.setTopicId(topicId); + entity.setFileTrueName(originalName); + entity.setFileName(relativePath); + entity.setFileSize((int) file.getSize()); + entity.setFileType(ext); + upFileRepository.save(entity); + + return toVo(entity); + } + + // ============================ 绑定主题 ============================ + + @Override + @Transactional + public void bindToTopic(List fileIds, Integer topicId) { + if (fileIds == null || fileIds.isEmpty() || topicId == null) { + return; + } + for (Integer fileId : fileIds) { + upFileRepository.findById(fileId).ifPresent(f -> { + f.setTopicId(topicId); + upFileRepository.save(f); + }); + } + } + + // ============================ 查询附件列表 ============================ + + @Override + public List listByTopic(String appType, Integer topicId) { + String type = appType == null ? DEFAULT_APP_TYPE : appType; + return upFileRepository.findByTopicTypeAndTopicId(type, topicId).stream() + .map(this::toVo) + .collect(Collectors.toList()); + } + + // ============================ 下载 ============================ + + @Override + public void download(Integer fileId, HttpServletResponse response) { + writeFile(fileId, response, false); + } + + @Override + public void preview(Integer fileId, HttpServletResponse response) { + writeFile(fileId, response, true); + } + + /** + * 文件输出。inline=true 时 Content-Disposition 用 inline 并按扩展名输出真实 Content-Type + * (浏览器/前端组件可直接渲染),否则 attachment 强制下载。 + */ + private void writeFile(Integer fileId, HttpServletResponse response, boolean inline) { + UpFile file = upFileRepository.findById(fileId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId)); + Path fullPath = Paths.get(uploadPath, file.getFileName()); + if (!Files.exists(fullPath)) { + throw new BizException(ErrorCode.FORBIDDEN, "物理文件不存在: " + file.getFileName()); + } + try (FileInputStream fis = new FileInputStream(fullPath.toFile()); + OutputStream os = response.getOutputStream()) { + String encodedName = URLEncoder.encode(file.getFileTrueName(), "UTF-8") + .replaceAll("\\+", "%20"); + if (inline) { + response.setContentType(resolveContentType(file.getFileName())); + response.setHeader("Content-Disposition", "inline; filename=\"" + encodedName + "\""); + } else { + response.setContentType("application/octet-stream"); + response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedName + "\""); + } + response.setContentLengthLong(file.getFileSize()); + byte[] buffer = new byte[4096]; + int len; + while ((len = fis.read(buffer)) != -1) { + os.write(buffer, 0, len); + } + os.flush(); + } catch (Exception e) { + log.error("[writeFile] 文件输出失败: {}", file.getFileName(), e); + throw new BizException(ErrorCode.ERROR, "文件下载失败", e); + } + } + + /** + * 按扩展名推断 Content-Type(JDK 自带表 + 常见办公/图片格式兜底),未知类型回退流格式。 + */ + private String resolveContentType(String fileName) { + String name = String.valueOf(fileName).toLowerCase(); + Map table = new HashMap<>(); + table.put("pdf", "application/pdf"); + table.put("png", "image/png"); + table.put("jpg", "image/jpeg"); + table.put("jpeg", "image/jpeg"); + table.put("gif", "image/gif"); + table.put("bmp", "image/bmp"); + table.put("webp", "image/webp"); + table.put("svg", "image/svg+xml"); + table.put("txt", "text/plain;charset=UTF-8"); + table.put("mp4", "video/mp4"); + table.put("webm", "video/webm"); + table.put("ogg", "video/ogg"); + table.put("doc", "application/msword"); + table.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + table.put("xls", "application/vnd.ms-excel"); + table.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + table.put("ppt", "application/vnd.ms-powerpoint"); + table.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"); + String type = table.get(name.substring(name.lastIndexOf('.') + 1)); + if (type != null) { + return type; + } + String guessed = URLConnection.guessContentTypeFromName(name); + return guessed != null ? guessed : "application/octet-stream"; + } + + // ============================ 删除 ============================ + + @Override + @Transactional + public void delete(Integer fileId) { + UpFile file = upFileRepository.findById(fileId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId)); + // 删物理文件 + Path fullPath = Paths.get(uploadPath, file.getFileName()); + try { + Files.deleteIfExists(fullPath); + } catch (Exception e) { + log.warn("[delete] 物理文件删除失败: {}(数据库记录仍会删除)", fullPath, e); + } + // 删数据库记录 + upFileRepository.delete(file); + } + + // ============================ 打包下载 ZIP ============================ + + @Override + public void downloadZip(List files, String zipName, HttpServletResponse response) { + if (files == null || files.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "无附件可下载"); + } + try { + response.setContentType("application/zip"); + String name = URLEncoder.encode(zipName == null ? "附件" : zipName, "UTF-8") + .replaceAll("\\+", "%20"); + response.setHeader("Content-Disposition", "attachment; filename=\"" + name + ".zip\""); + try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) { + Set used = new HashSet<>(); + for (UpFile f : files) { + Path p = Paths.get(uploadPath, f.getFileName()); + if (!Files.exists(p)) { + log.warn("[downloadZip] 物理文件缺失,跳过: {}", f.getFileName()); + continue; + } + String entryName = sanitizeEntryName(f.getFileTrueName()); + if (used.contains(entryName)) { + entryName = f.getId() + "_" + entryName; + } + used.add(entryName); + zos.putNextEntry(new ZipEntry(entryName)); + Files.copy(p, zos); + zos.closeEntry(); + } + } + } catch (Exception e) { + log.error("[downloadZip] 打包下载失败", e); + throw new BizException(ErrorCode.ERROR, "打包下载失败", e); + } + } + + /** + * 净化 ZIP 条目名:去除路径分隔符等非法字符,防止 Zip Slip。 + */ + private String sanitizeEntryName(String name) { + if (name == null || name.trim().isEmpty()) { + return "file"; + } + return name.replaceAll("[\\\\/:*?\"<>|]", "_"); + } + + // ============================ 按主题打包下载 ============================ + + @Override + public void downloadZipByTopic(String topicType, Integer topicId, String zipName, + HttpServletResponse response) { + String type = topicType == null ? DEFAULT_APP_TYPE : topicType; + List files = upFileRepository.findByTopicTypeAndTopicId(type, topicId); + downloadZip(files, zipName, response); + } + + // ============================ 批量上传图片 ============================ + + @Override + @Transactional + public List uploadImages(MultipartFile[] files, String appType) { + List results = new ArrayList<>(); + if (files == null || files.length == 0) { + return results; + } + String type = appType != null ? appType : DEFAULT_APP_TYPE; + for (MultipartFile file : files) { + results.add(upload(file, type, null)); + } + return results; + } + + // ============================ 上传(返回前端兼容 VO) ============================ + + @Override + @Transactional + public UpFileResultVo uploadToResult(MultipartFile file, String appType) { + UpFileVo vo = upload(file, appType, null); + UpFileResultVo result = new UpFileResultVo(); + result.setFileid(vo.getId()); + result.setName(vo.getFileTrueName()); + result.setFileurl(vo.getFileName()); + result.setHost(""); + result.setType(vo.getFileType()); + result.setSize(vo.getFileSize()); + return result; + } + + // ============================ 转换 ============================ + + private UpFileVo toVo(UpFile entity) { + UpFileVo vo = new UpFileVo(); + BeanUtils.copyProperties(entity, vo); + return vo; + } +} diff --git a/src/main/java/com/yunhai/familyactivity/service/impl/UserCenterService.java b/src/main/java/com/yunhai/familyactivity/service/impl/UserCenterService.java new file mode 100644 index 0000000..d49de92 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/service/impl/UserCenterService.java @@ -0,0 +1,263 @@ +package com.yunhai.familyactivity.service.impl; + +import com.yunhai.familyactivity.model.SchTeaList; +import com.yunhai.familyactivity.service.IClassService; +import com.yunhai.familyactivity.service.IUserCenterService; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.client.YschBaseOpenApi; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.platform.utils.model.BoaModule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 用户中心门面服务实现。 + *

原 BaseApiController.getSchoolAll 的组树组装、教师挂组、递归建树、排序逻辑整体下沉至此; + * GetGroupUser 空桩改为显式报错(未接入用户中心对应开放接口时提示,而非静默返回空列表)。 + * 跨仓调用统一收敛到 {@link YschBaseOpenApi} 门面,Controller 不再直接静态调用。

+ */ +@Service +public class UserCenterService implements IUserCenterService { + + private static final Logger log = LoggerFactory.getLogger(UserCenterService.class); + + /** 学生组(UserBase GroupTypeEnum.STUDENT_GROUP),组树中剔除 */ + private static final int GROUP_TYPE_STUDENT = 8; + + @Autowired + private IClassService classService; + + // ============================ 学校组织树 ============================ + + @Override + public List> getSchoolAllTree(Integer schId) { + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败"); + } + + // 1. userbase 组树(整棵学校子树),剔除学生组 + List> flatNodes = new ArrayList<>(); + BoaGroup root = YschBaseOpenApi.getGroupById(schId, true); + if (root == null) { + log.warn("[getSchoolAll] 用户中心未找到学校组, schId={}", schId); + return Collections.emptyList(); + } + flattenGroup(root, 0, flatNodes); + + // 2. bschclass 全部教师,按 GroupIds 挂到对应组下;无有效组的兜底挂学校根节点 + Set treeGroupIds = new HashSet<>(); + for (Map node : flatNodes) { + treeGroupIds.add((Integer) node.get("id")); + } + Map teacherMap = new LinkedHashMap<>(); + Map> teacherPids = new LinkedHashMap<>(); + List teachers = classService.getSchTeaList(schId); + for (SchTeaList tea : teachers) { + if (tea.getUserTeaSerID() == null) { + continue; + } + // 同一教师多行(多组/接口重复数据)合并,按 (教师, 组) 去重 + teacherMap.putIfAbsent(tea.getUserTeaSerID(), tea); + // 接口现行字段为单个 GroupID,兼容旧字段 GroupIds + List teaGroupIds = tea.getGroupIds(); + if ((teaGroupIds == null || teaGroupIds.isEmpty()) && tea.getGroupId() != null) { + teaGroupIds = Collections.singletonList(tea.getGroupId()); + } + if (teaGroupIds != null) { + for (Integer gid : teaGroupIds) { + if (gid != null && treeGroupIds.contains(gid)) { + teacherPids.computeIfAbsent(tea.getUserTeaSerID(), k -> new HashSet<>()).add(gid); + } + } + } + } + for (Map.Entry entry : teacherMap.entrySet()) { + int teaId = entry.getKey(); + SchTeaList tea = entry.getValue(); + Set pids = teacherPids.get(teaId); + if (pids == null || pids.isEmpty()) { + // 教师所属组均被剔除(如学生组)或无组信息,挂学校根节点 + pids = new HashSet<>(Collections.singletonList(treeGroupIds.contains(schId) ? schId : 0)); + } + for (Integer pid : pids) { + Map node = new LinkedHashMap<>(); + node.put("id", teaId); + node.put("name", tea.getTeaName() != null ? tea.getTeaName() : ""); + node.put("pid", pid); + node.put("no", tea.getTeaNo() != null ? tea.getTeaNo() : 0); + node.put("type", 2); + node.put("isParent", false); + node.put("photo", ""); + flatNodes.add(node); + } + } + + // 3. 扁平节点递归组装为树 + return formatUserTree(flatNodes, 0); + } + + /** + * 将 BoaGroup 子树扁平化为组节点列表(type=1);学生组(type=8)整棵剔除。 + * isParent=true 标识组节点,前端 ztree 据此渲染为文件夹。 + */ + private void flattenGroup(BoaGroup group, int pid, List> nodes) { + if (group == null || group.getId() == null) { + return; + } + if (group.getType() != null && group.getType() == GROUP_TYPE_STUDENT) { + return; + } + Map node = new LinkedHashMap<>(); + node.put("id", group.getId()); + node.put("name", group.getName() != null ? group.getName() : ""); + node.put("pid", pid); + node.put("no", group.getSort() != null ? group.getSort() : 0); + node.put("type", 1); + node.put("isParent", true); + node.put("photo", ""); + nodes.add(node); + + if (group.getChildren() != null) { + for (BoaGroup child : group.getChildren()) { + flattenGroup(child, group.getId(), nodes); + } + } + } + + /** + * 递归构建用户树(对应 C# HttpUserApi.FormatUserTree)。 + * 根层取 pid=0 或父节点不在列表中的节点;type=1 且无子节点的空组剔除。 + */ + private List> formatUserTree(List> treeList, int pid) { + List> data; + if (pid == 0) { + Set allIds = new HashSet<>(); + for (Map n : treeList) { + allIds.add((Integer) n.get("id")); + } + data = new ArrayList<>(); + for (Map n : treeList) { + int p = (Integer) n.get("pid"); + if (p == 0 || !allIds.contains(p)) { + data.add(n); + } + } + } else { + data = new ArrayList<>(); + for (Map n : treeList) { + if ((Integer) n.get("pid") == pid) { + data.add(n); + } + } + } + + List> result = new ArrayList<>(); + for (Map item : data) { + int itemId = (Integer) item.get("id"); + int itemType = (Integer) item.get("type"); + + List> children = formatUserTree(treeList, itemId); + + // type=1(组) 且无子节点 → 不加入(C# 逻辑) + if (itemType == 1 && children.isEmpty()) { + continue; + } + + Map node = new LinkedHashMap<>(item); + // 仅组节点携带 children;叶子不带该字段,避免 ztree 误判 isParent + if (itemType == 1) { + node.put("children", children); + } + result.add(node); + } + + // 按 no 排序(数字优先,无法解析的排最后) + result.sort(Comparator.comparingInt(n -> { + Object no = n.get("no"); + if (no instanceof Integer) { + return (Integer) no; + } + if (no instanceof String) { + try { + return Integer.parseInt((String) no); + } catch (NumberFormatException e) { + return 9999; + } + } + return 9999; + })); + + return result; + } + + // ============================ 组内用户 ============================ + + @Override + public List> getGroupUser(Integer gid, Integer role) { + if (gid == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "组ID不得为空"); + } + log.info("[getGroupUser] gid={}, role={}, 用户中心组内用户开放接口尚未接入", gid, role); + // 显式报错而非静默返回空列表:原空桩会让前端误以为组内无用户 + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "组内用户查询接口暂未接入用户中心,请联系管理员配置后重试"); + } + + // ============================ 用户模块树 ============================ + + @Override + public List getUserModuleTree(Integer userId, Integer schId) { + if (userId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "用户ID不得为空"); + } + return YschBaseOpenApi.getUserModule(userId, schId); + } + + /** + * 将扁平模块列表组装为树(对应原 NoticeController.buildModuleTree)。 + * 顶层取 parentId=0 或父节点不在列表中的节点,子节点按 orderNumber 排序。 + */ + private List> buildModuleTree(List modules) { + Map moduleMap = modules.stream() + .collect(Collectors.toMap(BoaModule::getId, m -> m, (a, b) -> a)); + + // 每个模块节点(含 children 占位) + Map> nodeMap = new LinkedHashMap<>(); + for (BoaModule m : modules) { + Map node = new LinkedHashMap<>(); + node.put("id", m.getId()); + node.put("modId", m.getId()); + node.put("name", m.getName()); + node.put("parentId", m.getParentId() != null ? m.getParentId() : 0); + node.put("page", m.getPath() != null ? m.getPath() : ""); + node.put("level", m.getLevel() != null ? m.getLevel() : 0); + node.put("icon", m.getIcon() != null ? m.getIcon() : ""); + node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0); + node.put("usable", true); + node.put("children", new ArrayList<>()); + nodeMap.put(m.getId(), node); + } + + // 挂父子关系 + List> roots = new ArrayList<>(); + for (BoaModule m : modules) { + Map node = nodeMap.get(m.getId()); + int parentId = m.getParentId() != null ? m.getParentId() : 0; + if (parentId == 0 || !moduleMap.containsKey(parentId)) { + roots.add(node); + } else { + @SuppressWarnings("unchecked") + List> children = (List>) nodeMap.get(parentId).get("children"); + children.add(node); + } + } + + // 保持 SDK(用户中心)返回顺序,不再按 orderNumber 重排 + return roots; + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/familyactivity/utils/PageUtils.java b/src/main/java/com/yunhai/familyactivity/utils/PageUtils.java new file mode 100644 index 0000000..1eef08c --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/utils/PageUtils.java @@ -0,0 +1,37 @@ +package com.yunhai.familyactivity.utils; + +import com.yunhai.familyactivity.model.BasePageQuery; +import com.yunhai.familyactivity.model.BasePageResult; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +/** + * 分页辅助类:BasePageQuery <-> Pageable,Page <-> BasePageResult。 + */ +public class PageUtils { + + private PageUtils() { + } + + public static Pageable toPageable(BasePageQuery query, Sort sort) { + int page = Math.max(query.getPageIndex() - 1, 0); + int size = Math.max(query.getPageSize(), 1); + if (sort == null) { + sort = Sort.unsorted(); + } + return PageRequest.of(page, size, sort); + } + + public static BasePageResult toResult(Page page) { + return new BasePageResult( + page.getContent(), + (int) page.getTotalElements(), + page.getNumber() + 1, + page.getSize(), + page.getTotalPages(), + page.hasNext() + ); + } +} diff --git a/src/main/java/com/yunhai/familyactivity/utils/UserContext.java b/src/main/java/com/yunhai/familyactivity/utils/UserContext.java new file mode 100644 index 0000000..a9298d6 --- /dev/null +++ b/src/main/java/com/yunhai/familyactivity/utils/UserContext.java @@ -0,0 +1,131 @@ +package com.yunhai.familyactivity.utils; + +import lombok.Value; + +/** + * 当前登录用户上下文(ThreadLocal)。 + *

由 {@link com.yunhai.familyactivity.aspect.ControllerUserContextAspect} 在请求进入控制器前填充、 + * 请求结束后清理;Service 层统一通过本类获取当前用户(发布人账号、schId 数据隔离、操作日志)。 + * 参考实现:UserclassService {@code com.yunhai.bschclassmanage.utils.UserContext}。

+ */ +public class UserContext { + + private static final ThreadLocal USER_ID = new ThreadLocal<>(); + private static final ThreadLocal USER_NAME = new ThreadLocal<>(); + private static final ThreadLocal ACCOUNT = new ThreadLocal<>(); + private static final ThreadLocal ROLE_ID = new ThreadLocal<>(); + private static final ThreadLocal SCH_ID = new ThreadLocal<>(); + private static final ThreadLocal IP = new ThreadLocal<>(); + private static final ThreadLocal TYPE = new ThreadLocal<>(); + + public static void setUserId(Integer userId) { + USER_ID.set(userId); + } + public static Integer getUserId() { + return USER_ID.get(); + } + + public static void setUserName(String userName) { + USER_NAME.set(userName); + } + public static String getUserName() { + return USER_NAME.get(); + } + + public static void setAccount(String account) { + ACCOUNT.set(account); + } + /** 当前登录账号(家长端=学生账号;createBy/updateBy、publisherAccount 取自此) */ + public static String getAccount() { + return ACCOUNT.get(); + } + + public static void setRoleId(Integer roleId) { + ROLE_ID.set(roleId); + } + public static Integer getRoleId() { + return ROLE_ID.get(); + } + + public static void setSchId(Integer schId) { + SCH_ID.set(schId); + } + /** 当前学校ID(数据隔离口径;超管固定为 0) */ + public static Integer getSchId() { + return SCH_ID.get(); + } + + public static void setIp(String ip) { + IP.set(ip); + } + public static String getIp() { + return IP.get(); + } + + public static void setType(Integer type) { + TYPE.set(type); + } + /** 用户类型(平台 BoaUser.type;1=超级管理员) */ + public static Integer getType() { + return TYPE.get(); + } + + @Value + public static class UserInfo { + Integer userId; + String userName; + String account; + Integer roleId; + Integer schId; + String ip; + Integer type; + } + + public static UserInfo getCurrentUser() { + return new UserInfo( + USER_ID.get(), + USER_NAME.get(), + ACCOUNT.get(), + ROLE_ID.get(), + SCH_ID.get(), + IP.get(), + TYPE.get() + ); + } + + public static void setUserInfo(UserInfo info) { + if (info == null) return; + USER_ID.set(info.getUserId()); + USER_NAME.set(info.getUserName()); + ACCOUNT.set(info.getAccount()); + ROLE_ID.set(info.getRoleId()); + SCH_ID.set(info.getSchId()); + IP.set(info.getIp()); + TYPE.set(info.getType()); + } + + /** + * 包装异步任务:把当前用户上下文带入子线程执行,执行完自动清理,避免线程复用串号。 + */ + public static Runnable wrap(Runnable task) { + UserInfo info = getCurrentUser(); + return () -> { + try { + setUserInfo(info); + task.run(); + } finally { + clear(); + } + }; + } + + public static void clear() { + USER_ID.remove(); + USER_NAME.remove(); + ACCOUNT.remove(); + ROLE_ID.remove(); + SCH_ID.remove(); + IP.remove(); + TYPE.remove(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..f5a4ca0 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,72 @@ +server: + port: 8104 + +spring: + mvc: + date-format: yyyy-MM-dd HH:mm:ss + jackson: + time-zone: GMT+8 + date-format: yyyy-MM-dd HH:mm:ss + + datasource: + url: jdbc:mysql://localhost:3306/family_activity?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_general_ci&serverTimezone=Asia/Shanghai + username: root + password: 123123 + driver-class-name: com.mysql.cj.jdbc.Driver + min-idle: 10 + max-active: 30 + #jpa + jpa: + database: MySQL + show-sql: true + hibernate: + # 开发期使用 update 自动建表;生产环境设置成 none + ddl-auto: update + database-platform: org.hibernate.dialect.MySQL5InnoDBDialect + properties: + hibernate.order_by.default_null_ordering: last + open-in-view: true + + #文件上传 + servlet: + multipart: + max-file-size: 100MB + max-request-size: 100MB + + #本地关闭 rabbit + rabbitmq: + listener: + direct: + auto-startup: false + simple: + auto-startup: false + +# 文件上传存储路径(Docker 部署时与 volume 挂载一致) +file: + upload-path: D:/data/upload + +# 短信模块配置 +sms: + enabled: true + module-id: notice + +# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) +yh: + sso: + server-url-prefix: http://sso.yunhaixingtong.com + client-host-url: http://localhost:8104 + allow-headers: all + allow-origins: all + url-pattern: /api/* + mobi-api-pattern: /mobiapi/* + app-auth: + enabled: true + # 本应用自身身份(客户端签名用) + app-key: 100010 + app-secret: 07e3526c655b484caade38ce0484d917 + open: + enabled: true + base-host: http://base.yunhaixingtong.com + user-center-url: http://base.yunhaixingtong.com/userbase + # 班级管理(bschclass)服务基址 + bschclass: http://base.yunhaixingtong.com/userclass diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml new file mode 100644 index 0000000..2724e1a --- /dev/null +++ b/src/main/resources/bootstrap.yml @@ -0,0 +1,33 @@ +spring: + application: + name: notice + profiles: + active: ${SPRING_PROFILES_ACTIVE:local} + cloud: + nacos: + config: + enabled: ${NACOS_CONFIG_ENABLED:false} + server-addr: ${NACOS_ADDR:nacos.yunhaixingtong.com:8848} + namespace: ${SPRING_CLOUD_NACOS_CONFIG_NAMESPACE:6196f0de-a28d-4894-8f10-e2982b57c6bc} + # 应用自身配置所在分组 + group: OA_APP + # 配置文件格式;最终主配置 dataId = ${spring.application.name}.${file-extension},即 notice.yaml + file-extension: yaml + encode: UTF-8 + # nacos 不可达时不强制退出,回退到本地 application.yml 兜底 + fail-fast: false + # 只读账号 + username: ${SPRING_CLOUD_NACOS_USERNAME:yh-reader} + password: ${SPRING_CLOUD_NACOS_PASSWORD:123456} + # 共享公共配置:public 空间下的 common.properties + shared-configs: + # ① 1 号(最低优先级):public 命名空间(namespace="" 固定写空字符串)的 application-default.yaml + - data-id: application-default.yaml + group: DEFAULT_GROUP + namespace: "" # ⭐ 固定 = "",强制指向 Nacos "public" 命名空间,不读上面的全局 prod namespace + refresh: false + + # ② 2 号(中等优先级):prod 命名空间(不写 namespace = 继承顶部全局 prod namespace id)的 application-infra.yaml + - data-id: application-infra.yaml + group: DEFAULT_GROUP + refresh: false diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..20612de --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,35 @@ + + + + + + + + + ${CONSOLE_LOG_PATTERN} + ${CONSOLE_LOG_CHARSET} + + + + + + + /app/logs/app.log + + /app/logs/app.%d{yyyy-MM-dd}.log + 30 + 5GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n + + + + + + + + + + + \ No newline at end of file