commit
build / build-push (push) Failing after 10s

This commit is contained in:
wanghu
2026-09-20 09:11:53 +08:00
parent 273bd254b5
commit 36a3be8294
89 changed files with 8285 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
target/
.idea/
*.iml
*.log
.DS_Store
+52
View File
@@ -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
+9
View File
@@ -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
@@ -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 自动生成的同名约束(二者等价,冗余无害,可择机合并)。
+198
View File
@@ -0,0 +1,198 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.yunhai</groupId>
<artifactId>notice</artifactId>
<version>0.0.1</version>
<name>FamilyActivity</name>
<description>武校家校信息发布 · 活动信息发布子应用</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencyManagement>
<!-- 基础平台版本管理(含 yh-* 架包与 spring-boot-dependencies 对齐) -->
<dependencies>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-platform-dependencies</artifactId>
<version>1.0.0-SNAPSHOT</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Spring Cloud Alibaba(Nacos 配置中心,对齐 Boot 2.7) -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2021.0.5.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- 仅引入 Spring Security 的加密模块(BCryptPasswordEncoder),不引入整套安全框架 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Nacos 配置中心(替代 Spring Cloud Config)。Spring Cloud Alibaba 2021.0.5 适配 Spring Boot 2.7 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- Boot 2.4+ 默认不再处理 bootstrap.yml,需显式引入以继续从 Nacos 拉取配置 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
<version>3.1.8</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- JSON 序列化(fastjson,主类开启安全模式) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.80</version>
</dependency>
<!-- Swagger / OpenAPI 文档(springdoc,兼容 Boot 2.7) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.7.0</version>
</dependency>
<!-- 基础平台全家桶(聚合 starter):认证(CAS/会话/SSO)+ 应用间签名鉴权 + 用户中心静态门面
+ 统一响应 + actuator 健康探活,一行带齐(模块构成见 BasePlatform/后端项目知识库.md)。
约定:标准业务服务一律引聚合 starter;平台能力提供方(如 UserBase)按需单引。 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-spring-boot-starter</artifactId>
</dependency>
<!-- CAS 客户端(浏览器 SSO 重定向 / ticket 校验 / getRemoteUser 包装)。
版本 3.5.1 已在本地 .m2 仓库。 -->
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-core</artifactId>
<version>3.5.1</version>
</dependency>
<!-- Excel 导入导出(EasyExcel 3.x,注解驱动、流式写入内存友好) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.3.2</version>
</dependency>
<!-- 不引 spring-boot-starter-data-redis / cache:代码零使用,data-redis 会自动装配
RedisHealthIndicator,连不上 redis 会把 /actuator/health 整体拉成 DOWN(503)。 -->
</dependencies>
<build>
<finalName>notice</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<!-- 关闭增量编译,避免 Windows 下 maven-status 状态文件被锁导致
"Error while storing the mojo status / 拒绝访问" -->
<useIncrementalCompilation>false</useIncrementalCompilation>
</configuration>
</plugin>
<!-- 打可执行 fat jar(Docker 镜像直接 java -jar 启动) -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.yunhai.familyactivity.FamilyActivityApplication</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>nexus-public</id>
<name>bsch/nexus public</name>
<url>http://nexus.yunhaixingtong.com/repository/maven-public/</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>
<!--
ci 构建档案:将构建产物重定向到 IDE(IntelliJ/Eclipse)索引目录之外,
避免 IDE 后台进程持续锁定 target/maven-status/.../createdFiles.lst 导致
"Error while storing the mojo status / 拒绝访问"。
输出目录需从未被 IDE 打开过。常规本地构建无需激活此 profile。
-->
<profiles>
<profile>
<id>ci</id>
<build>
<directory>C:/notice-build/notice</directory>
</build>
</profile>
</profiles>
</project>
@@ -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);
}
}
@@ -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 "";
}
@@ -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},请求结束后清理。
* <p>身份来源优先级(参考 UserclassService 的 ControllerUserContextAspect):</p>
* <ol>
* <li>控制器方法参数中平台参数解析器绑定的 {@link BoaUser}(兼容现有写法);</li>
* <li>平台静态持有者 {@link BoaUserContext#get()};</li>
* <li>request attribute(外部认证框架注入的 BoaUser);</li>
* <li>{@link UserContextHolder} 的 {@link LoginUser}(仅补 account/userName,无 id/schId)。</li>
* </ol>
* 超管(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<String> 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();
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -0,0 +1,10 @@
package com.yunhai.familyactivity.common.validation;
/**
* JSR-303 分组校验标记接口(对齐 UserBase 规范)。
* Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。
*/
public interface ValidationGroups {
interface Create {}
interface Update {}
}
@@ -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";
}
@@ -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));
}
}
@@ -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"));
}
}
@@ -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)。
* <p>返回裸对象,由平台 ApiResponseAdvice 自动包装为 {code, msg, data};
* 写操作标注 {@link OperationLog} 落库 operation_log。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/activity/")
public class ActivityController {
@Autowired
private IActivityService activityService;
// ============================ 家长端收件箱 ============================
/** 已发布活动列表(家长/学生视角,含未读总数) */
@GetMapping("List")
public ActivityPageResult<ActivityVo> list(ActivityQuery query) {
return activityService.list(query);
}
/** 清单:一键全部已读 */
@PostMapping("InitializeRed")
@OperationLog(module = "活动管理", content = "一键全部已读")
public InitializeRedVo initializeRed() {
return activityService.initializeRed();
}
// ============================ 我发布的 / 全部活动 ============================
/** 我发布的 / 全部活动列表(scope="user" 只看自己) */
@GetMapping("SendList")
public BasePageResult<ActivityVo> 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<ReadGroup> 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<Integer> 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<String, Object> remind(@RequestParam("serid") Integer serid) {
return activityService.remind(serid);
}
/** 导出回执名单(JSON 包装 {csv},前端取 csv 字段生成文件下载) */
@GetMapping("ExportSignups")
public Map<String, Object> 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<String, Object> 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);
}
}
@@ -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/)。
* <p>路径前缀:{@code Config.API_PATH + "/base/"}(原硬编码已修正)。
* 承载平台代理接口:GetUserInfo / GetUserMod / GetSchoolAll / GetClassTree / SetRole / GetGroupUser / Logout,
* 业务逻辑已下沉 {@link IUserCenterService},本控制器仅做"取参 → 调 Service → 返回"。
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。</p>
*/
@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<BoaModule> getUserMod(BoaUser authUserWithDept) {
Integer schId = authUserWithDept.getCurrentRole() != null
? authUserWithDept.getCurrentRole().getSchId() : null;
return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId);
}
// ============================ GetSchoolAll ============================
/**
* 获取当前用户学校组及用户(树形结构)。
* 组树组装/教师挂组/递归建树逻辑已下沉 IUserCenterService。
*/
@GetMapping("GetSchoolAll")
public List<Map<String, Object>> getSchoolAll(BoaUser authUserWithDept) {
Integer schId = authUserWithDept.getCurrentRole() != null
? authUserWithDept.getCurrentRole().getSchId() : null;
return userCenterService.getSchoolAllTree(schId);
}
// ============================ GetGroupUser ============================
/**
* 获取指定组内用户(教师 + 管理员)。
* 空桩已改为显式报错(避免前端误以为组内无用户),由 IUserCenterService 统一处理。
*/
@GetMapping("GetGroupUser")
public List<Map<String, Object>> 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<Map<String, Object>> 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<SchGradeDto> grades = classService.getSchGradeList(sch.getSchSerId());
List<SchClassDto> classes = classService.getSchClassList(sch.getSchSerId());
// 每班学生数(一次取全校学生统计,失败降级为不计学生数)
Map<Integer, Long> stuCount = new HashMap<>();
try {
List<SchStudentDto> 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<Integer, Long> finalStuCount = stuCount;
Map<Integer, List<SchClassDto>> byGrade = classes.stream()
.filter(c -> c.getGradeId() != null)
.collect(Collectors.groupingBy(SchClassDto::getGradeId));
List<Map<String, Object>> tree = new ArrayList<>();
for (SchGradeDto grade : grades) {
Map<String, Object> gradeNode = new LinkedHashMap<>();
gradeNode.put("id", grade.getId());
gradeNode.put("name", grade.getName());
gradeNode.put("isParent", true);
List<Map<String, Object>> 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<String, Object> 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<String, Object> setRole(@RequestBody(required = false) Map<String, String> 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<String, Object> 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());
}
}
@@ -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/)。
* <p>路径前缀:{@code Config.API_PATH + "/contacts/"}(原硬编码已修正为 Config 拼接)。
* 方法名已为 PascalCase 且写操作已是 POST,保持不动。
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。</p>
*/
@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<String, Object> 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<String, Object> 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<Map<String, Object>> getGroupTree(@RequestParam("type") Integer type) {
return contactsService.getGroupTree(type);
}
/**
* 获取部门角色组树。
*/
@GetMapping("GetDeptRoleGroupTree")
public List<Map<String, Object>> getDeptRoleGroupTree() {
return contactsService.getDeptRoleGroupTree();
}
}
@@ -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;
/**
* 系统字典接口(主办部门 / 活动分类域)。
* <p>路径前缀:{@code Config.API_PATH + "/dict/"}(原 base/ 前缀已修正)。
* 写操作一律 POST(原 DelDept/OrdbyDicIds 为 GET 写操作,已 POST 化)。
* <p>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<Map<String, Object>> list(@RequestParam(value = "type", defaultValue = "0") Integer type) {
return dictService.getSysDic(type);
}
/**
* 获取系统字典管理列表(type=dept 主办部门 / type=category 活动分类)。
*/
@GetMapping("ManageList")
public List<Map<String, Object>> 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);
}
}
@@ -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/)。
* <p>路径前缀:{@code Config.API_PATH + "/file/"}(原 base/ 前缀已修正为独立文件域)。
* 方法名统一 PascalCase;写操作一律 POST。
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。</p>
*/
@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<UpFileVo> 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<UpFileVo> list(@RequestParam("topicId") Integer topicId) {
return fileService.listByTopic("activity", topicId);
}
/**
* 批量绑定附件到通知。
*/
@PostMapping("Bind")
public void bind(@RequestParam("fileIds") List<Integer> fileIds,
@RequestParam("topicId") Integer topicId) {
fileService.bindToTopic(fileIds, topicId);
}
}
@@ -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;
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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 表。
* <p>
* 存储文件元信息(真实文件名、存储路径、大小、类型),物理文件存储在
* 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;
}
@@ -0,0 +1,69 @@
package com.yunhai.familyactivity.enums;
/**
* 活动流程状态(对齐前端 ActivityStatus)。
* <ul>
* <li>draft 草稿 → signup 报名中 → closed 已截止 → finished 已结束</li>
* </ul>
* 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;
}
}
@@ -0,0 +1,43 @@
package com.yunhai.familyactivity.enums;
/**
* 回执方式(对齐前端 ReceiptMode)。
* <ul>
* <li>none 仅通知(阅读即知悉,只统计已读)</li>
* <li>signup 需报名(家长填报参加人数,可带随行)</li>
* <li>confirm 需确认(家长端打开活动即自动确认)</li>
* </ul>
*/
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;
}
}
@@ -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;
}
}
@@ -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;
}
@@ -0,0 +1,33 @@
package com.yunhai.familyactivity.model;
import lombok.Data;
import java.util.List;
/**
* 统一分页响应外壳。
*/
@Data
public class BasePageResult<T> {
/** 总记录数 */
private int total;
/** 当前页数据 */
private List<T> content;
/** 当前页码(从 1 起) */
private int pageIndex;
/** 每页条数 */
private int pageSize;
/** 总页数 */
private int totalPages;
/** 是否还有下一页 */
private boolean hasNext;
public BasePageResult(List<T> 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;
}
}
@@ -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<Integer> 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<Integer> getGroupIds() {
return groupIds;
}
public void setGroupIds(List<Integer> groupIds) {
this.groupIds = groupIds;
}
}
@@ -0,0 +1,30 @@
package com.yunhai.familyactivity.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* 文件上传结果视图对象(替代原 Map&lt;String,Object&gt; 返回)。
* 使用 @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;
}
@@ -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;
}
@@ -0,0 +1,122 @@
package com.yunhai.familyactivity.model;
import java.util.ArrayList;
import java.util.List;
/**
* 用户可访问模块(左菜单)视图对象。
* <p>将基础平台返回的扁平 {@code List<BoaModule>} 转换为前端 layout(参考 UserBase)期望的树结构:
* {@code modId}(路由高亮匹配)/{@code page}(跳转路径)/{@code children}(子菜单)。</p>
*/
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<UserModuleVo> 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<UserModuleVo> getChildren() {
return children;
}
public void setChildren(List<UserModuleVo> children) {
this.children = children;
}
}
@@ -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;
}
@@ -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<ActivityDetailFileItem> chaoSend;
/** 接收对象列表:"all" 或 班级 id 数组 */
private Object tlist;
/** 接收对象类型:class=班级 */
private String receiveType;
/** 接收对象可读文本 */
private String receiveText;
}
@@ -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<T> extends BasePageResult<T> {
/** 当前用户未读活动总数 */
private long unreadCount;
public ActivityPageResult(List<T> content, int total, int pageIndex, int pageSize,
int totalPages, boolean hasNext, long unreadCount) {
super(content, total, pageIndex, pageSize, totalPages, hasNext);
this.unreadCount = unreadCount;
}
}
@@ -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);
}
}
@@ -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;
}
@@ -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;
}
@@ -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<PublisherCount> byPublisher;
/** 按月份 */
private List<MonthCount> byMonth;
/** 按活动分类分布 */
private List<CategoryCount> byCategory;
/** 我发布的活动·回执与已读 */
private List<MyActivityItem> 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;
}
}
@@ -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<Integer> fileIds;
}
@@ -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;
}
@@ -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<ParentActivityVo> list;
/**
* 分桶计数(对齐前端 buckets)。
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class Buckets {
/** 待回执 */
private int todo;
/** 已报名 */
private int signed;
/** 已结束 */
private int finished;
/** 全部 */
private int all;
}
}
@@ -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;
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<ReadGroupUser> 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;
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<SignupBrief> students;
}
@@ -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;
}
@@ -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<SignupGroup> groups;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<SchTreeNodeDto> children;
}
@@ -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<ActivityRead, Integer> {
Optional<ActivityRead> findByActivityIdAndClassId(Integer activityId, String classId);
List<ActivityRead> findByActivityId(Integer activityId);
List<ActivityRead> findByActivityIdIn(Collection<Integer> activityIds);
long deleteByActivityId(Integer activityId);
}
@@ -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<Activity, Integer>,
JpaSpecificationExecutor<Activity> {
/** 某学校已发布且未撤回的活动(家长端收件箱候选集,数据量小,内存匹配接收班级) */
List<Activity> findBySchIdAndStatusAndIsPulled(Integer schId, Integer status, Integer isPulled);
/** 某学校全部活动(管理视角详情导航,含草稿/撤回) */
List<Activity> findBySchId(Integer schId);
/** 我发布的活动(发布人ID) */
List<Activity> findBySchIdAndPublisherId(Integer schId, Integer publisherId);
/** 按源活动查复用生成的活动 */
List<Activity> 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<Activity> findByIdForUpdate(@Param("id") Integer id);
}
@@ -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<ActivitySignup, Integer> {
List<ActivitySignup> findByActivityId(Integer activityId);
List<ActivitySignup> findByActivityIdAndStatusIn(Integer activityId, Collection<String> statuses);
List<ActivitySignup> findByActivityIdIn(Collection<Integer> activityIds);
Optional<ActivitySignup> findByActivityIdAndStudentId(Integer activityId, Integer studentId);
List<ActivitySignup> findByStudentIdAndActivityIdIn(Integer studentId, Collection<Integer> activityIds);
long deleteByActivityId(Integer activityId);
}
@@ -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<ContactsGroup, Integer> {
Page<ContactsGroup> findAllByOrderByCreateTimeDesc(Pageable pageable);
/**
* 按创建人ID查询联系组列表。
*/
List<ContactsGroup> findByCreatorId(Integer creatorId);
/**
* 按创建人ID查询联系组列表(分页)。
*/
Page<ContactsGroup> findByCreatorId(Integer creatorId, Pageable pageable);
/**
* 查询全部联系组(按创建时间降序)。
*/
List<ContactsGroup> findAllByOrderByCreateTimeDesc();
}
@@ -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<ContactsGroupUser, Integer> {
Page<ContactsGroupUser> findByGroupId(Integer groupId, Pageable pageable);
List<ContactsGroupUser> 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<Integer> groupIds);
/**
* 按多个组ID查询所有成员(用于统计每组人数、组树构建)。
*/
List<ContactsGroupUser> findByGroupIdIn(List<Integer> groupIds);
/**
* 按组ID和用户ID查询(判断是否已存在)。
*/
boolean existsByGroupIdAndUserId(Integer groupId, Integer userId);
}
@@ -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<OperationLogRecord, Long> {
}
@@ -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<SysDict, Integer> {
List<SysDict> findByDictTypeOrderBySortOrderAsc(String dictType);
List<SysDict> findByDictTypeAndEnableFlagOrderBySortOrderAsc(String dictType, Integer enableFlag);
/** 按类型+key 取单条字典(发文部门名称解析用) */
Optional<SysDict> findByDictTypeAndDictKey(String dictType, String dictKey);
}
@@ -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<UpFile, Integer> {
/**
* 按主题类型和主题ID查询附件列表。
*/
List<UpFile> findByTopicTypeAndTopicId(String topicType, Integer topicId);
/**
* 按主题类型和多个主题ID批量查询附件(消除 N+1)。
*/
List<UpFile> findByTopicTypeAndTopicIdIn(String topicType, List<Integer> topicIds);
/**
* 按主题类型和主题ID删除附件(通知删除时级联清理)。
*/
void deleteByTopicTypeAndTopicId(String topicType, Integer topicId);
}
@@ -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<ActivityVo> list(ActivityQuery query);
/** 清单:一键全部已读 */
InitializeRedVo initializeRed();
// ========== 我发布的 / 全部活动 ==========
/** 我发布的 / 全部活动列表(scope="user" 只看自己) */
BasePageResult<ActivityVo> 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<ReadGroup> readDetail(Integer serid);
/** 追加接收班级(补发,自动补回执记录) */
void receiveClasses(Integer serid, String classes);
// ========== 发布/编辑/删除 ==========
/** 新建活动(draft=true 存草稿),返回活动 id */
Integer create(ActivityRequest request);
/** 编辑活动(含 sourceId 复用场景) */
void edit(ActivityRequest request);
/** 批量删除(级联清回执/已读/附件) */
void delete(List<Integer> 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<String, Object> remind(Integer serid);
/** 导出回执名单,返回 {csv: CSV文本(含BOM)}(走 JSON 包装,前端取 csv 字段生成文件) */
Map<String, Object> exportSignups(Integer serid);
// ========== 家长端(v2) ==========
/** 活动广场(当前家长/学生视角,含我的报名态 + 分桶计数) */
MyActivitiesVo myActivities(ActivityQuery query);
/** 我对某活动的详情(活动信息 + 我的报名,同时标读;confirm 模式自动确认) */
MyActivityVo mySignup(Integer serid);
/** 报名 / 修改报名,返回 {signupId, status} */
Map<String, Object> signup(SignupRequest request);
/** 取消报名 */
void cancelSignup(Integer serid);
// ========== 统计 / 附件 ==========
/** 活动统计(startTime/endTime 为 yyyy-MM-dd 短日期,可空) */
ActivityStatisticsVo statistics(String startTime, String endTime);
/** 全部附件打包下载 */
void allFileDown(Integer dataid, HttpServletResponse response);
}
@@ -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)开放接口客户端门面。
* <p>基址取配置 {@code yh.open.bschclass}(/oapi/** 无需应用签名,独立 RestTemplate 直连)。
* 班级树、圈人(发布对象/回执名单)、学生身份解析统一经本门面取数。
* 参考 CampusNotice 同名门面。</p>
*/
public interface IClassService {
/**
* 获取指定学校全部教师(含所属组 GroupIds)。
* 对应 bschclass 开放接口:GET {@code {bschclass}/oapi/DbClass/GetSchteaList?Group={schId}}
*
* @param schId 学校ID(即用户当前身份的 schId)
*/
List<SchTeaList> 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<SchSystemDto> getSchLenList(String schSerId);
/**
* 学校年级列表(Id=gradeId、Name=gradeNickname、OwnerId=学段id)。
* 对应:GET {@code {bschclass}/oapi/Schoolen/GetSchLenLevellist?SchSerID=}
*/
List<SchGradeDto> getSchGradeList(String schSerId);
/**
* 学校全部在读班级(ClassID/ClassName/Levell=gradeId)。
* 对应:GET {@code {bschclass}/oapi/VSchClass/GetVClassList?SchSerID=}
*/
List<SchClassDto> getSchClassList(String schSerId);
/**
* 按校全部学生(发布对象预生成回执、家长班级身份解析)。
* 对应:GET {@code {bschclass}/oapi/OAStu/GetSchStulist?schserid=}
*/
List<SchStudentDto> getSchStuList(String schSerId);
/**
* 按班级批量查学生(补发班级、回执名单圈人)。
* 对应:GET {@code {bschclass}/oapi/OAStu/GetClaArrStulist?ClassIDs=1,2,3}
*/
List<SchStudentDto> getClaArrStuList(List<Integer> classIds);
/**
* 学校学生树:学校→学段→年级→班级→学生(单元素列表,根为学校节点)。
* 对应:GET {@code {bschclass}/oapi/SchGroup/GetStuTree?schId=&toUser=}
*/
List<SchTreeNodeDto> getStuTree(Integer schId, boolean toUser);
/**
* 学校教师树:学校→部门/组→教师。
* 对应:GET {@code {bschclass}/oapi/SchGroup/GetTeaTree?schId=&toUser=}
*/
List<SchTreeNodeDto> getTeaTree(Integer schId, boolean toUser);
}
@@ -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<String, Object> 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<String, Object> getContactsTea(String gid, Integer page, Integer count);
/**
* 获取联系组树(组节点+用户节点,递归构建)。
*
* @param type 类型(1 自定义组, 2 明信片)
* @return 树形结构 List&lt;Map&gt;
*/
List<Map<String, Object>> getGroupTree(Integer type);
/**
* 获取部门角色组树。
* C# 从外部API获取部门树+用户角色映射。Java版预留。
*
* @return 树形结构 List&lt;Map&gt;
*/
List<Map<String, Object>> getDeptRoleGroupTree();
}
@@ -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&lt;Map&gt; 每项 {k, v}
*/
List<Map<String, Object>> getSysDic(Integer type);
/**
* 获取系统字典管理列表(含 SerID/CodeID/Name/Des/Enable)。
*
* @param type 字典类型("dept"=主办部门,"category"=活动分类)
* @return List&lt;Map&gt;
*/
List<Map<String, Object>> 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);
}
@@ -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<Integer> fileIds, Integer topicId);
/**
* 查询某主题下的附件列表。
*
* @param appType 应用类型
* @param topicId 主题ID
* @return 附件列表
*/
List<UpFileVo> 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<UpFile> 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<UpFileVo> uploadImages(MultipartFile[] files, String appType);
/**
* 上传单个文件并返回前端兼容的结果 VO(含 fileid/name/fileurl/host/type/size)。
*/
UpFileResultVo uploadToResult(MultipartFile file, String appType);
}
@@ -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} 模式)。
* <p>封装 {@code YschBaseOpenApi} / {@code OpenApiUserClient} 静态门面调用,
* 把原本散落在 Controller 的组树组装、教师挂组、成员解析等业务逻辑下沉到 Service 层,
* Controller 只保留"取参 → 调 Service → 返回"的薄壳。</p>
*/
public interface IUserCenterService {
/**
* 获取当前用户学校组及用户(树形结构)。
* <p>组装规则(从原 BaseApiController.getSchoolAll 下沉):</p>
* <ol>
* <li>userbase 读取当前学校整棵组树(getGroupById(schId, withSub=true)),剔除学生组(type=8)及其子组;</li>
* <li>bschclass 获取该校全部教师(/oapi/DbClass/GetSchteaList?Group=schId),按 GroupIds 挂到对应组下,
* 无有效组(如所属组全被剔除)的教师兜底挂学校根节点;</li>
* <li>扁平节点按 pid 递归组装为树(空组剔除,同 C# FormatUserTree)。</li>
* </ol>
*
* @param schId 学校ID(用户当前角色所属学校)
* @return 用户树形结构(组节点 type=1 / 教师节点 type=2)
*/
List<Map<String, Object>> getSchoolAllTree(Integer schId);
/**
* 获取指定组内用户(教师 + 管理员)。
* <p>对应前端 base/GetGroupUser。原实现为空桩返回空列表,现按 C# 语义
* (ITeainfoBLL.GetGroupTeainfo + IAdminfoBLL.GetGroupAdminfo)接入用户中心。
* 若用户中心不可用或未配置对应开放接口,则抛出带语义的异常(显式报错而非静默失能)。</p>
*
* @param gid 组ID
* @param role 角色(默认 2=教师;1=管理员等)
* @return 组内用户列表
*/
List<Map<String, Object>> getGroupUser(Integer gid, Integer role);
/**
* 获取当前用户可访问的模块树(左侧菜单)。
* <p>从基础平台拉取当前用户模块列表(含学校上下文),组装为前端 layout 期望的树结构
* ({@code modId/page/children}),对应前端 GetUserMod 接口。</p>
*
* @param userId 用户ID
* @param schId 学校ID(可为 null)
* @return 模块树(顶层 List,含 children 嵌套)
*/
List<BoaModule> getUserModuleTree(Integer userId, Integer schId);
}
File diff suppressed because it is too large Load Diff
@@ -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 直连。
* <p>学段 GetSchLenlist、年级 GetSchLenLevellist、班级 GetVClassList、
* 学生 GetSchStulist / GetClaArrStulist、组织树 SchGroup/GetStuTree、GetTeaTree。</p>
*/
@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<SchTeaList> getSchTeaList(int schId) {
return exchangeList(bschClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId,
new ParameterizedTypeReference<List<SchTeaList>>() {
}, "获取教师数据", "schId=" + schId);
}
@Override
public SchInfoDto getSchFirst(int schId) {
requireHost();
String url = bschClassHost + "/oapi/SchInfo/GetUserSchFirst?UserSchID=" + schId;
try {
ResponseEntity<SchInfoDto> 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<SchSystemDto> getSchLenList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/Schoolen/GetSchLenlist?SchSerID=" + schSerId,
new ParameterizedTypeReference<List<SchSystemDto>>() {
}, "获取学段列表", "schSerId=" + schSerId);
}
@Override
public List<SchGradeDto> getSchGradeList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/Schoolen/GetSchLenLevellist?SchSerID=" + schSerId,
new ParameterizedTypeReference<List<SchGradeDto>>() {
}, "获取年级列表", "schSerId=" + schSerId);
}
@Override
public List<SchClassDto> getSchClassList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/VSchClass/GetVClassList?SchSerID=" + schSerId,
new ParameterizedTypeReference<List<SchClassDto>>() {
}, "获取班级列表", "schSerId=" + schSerId);
}
@Override
public List<SchStudentDto> getSchStuList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/OAStu/GetSchStulist?schserid=" + schSerId,
new ParameterizedTypeReference<List<SchStudentDto>>() {
}, "获取学校学生", "schSerId=" + schSerId);
}
@Override
public List<SchStudentDto> getClaArrStuList(List<Integer> classIds) {
String csv = joinIds(classIds);
return exchangeList(bschClassHost + "/oapi/OAStu/GetClaArrStulist?ClassIDs=" + csv,
new ParameterizedTypeReference<List<SchStudentDto>>() {
}, "获取班级学生", "classIds=" + csv);
}
@Override
public List<SchTreeNodeDto> getStuTree(Integer schId, boolean toUser) {
return exchangeList(bschClassHost + "/oapi/SchGroup/GetStuTree?schId=" + schId + "&toUser=" + toUser,
new ParameterizedTypeReference<List<SchTreeNodeDto>>() {
}, "获取学生树", "schId=" + schId);
}
@Override
public List<SchTreeNodeDto> getTeaTree(Integer schId, boolean toUser) {
return exchangeList(bschClassHost + "/oapi/SchGroup/GetTeaTree?schId=" + schId + "&toUser=" + toUser,
new ParameterizedTypeReference<List<SchTreeNodeDto>>() {
}, "获取教师树", "schId=" + schId);
}
// ============================ 通用调用 ============================
private <T> List<T> exchangeList(String url, ParameterizedTypeReference<List<T>> type,
String action, String arg) {
requireHost();
try {
// GET + Accept: application/json
HttpHeaders headers = new HttpHeaders();
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
ResponseEntity<List<T>> resp = restTemplate.exchange(
url, HttpMethod.GET, new HttpEntity<>(headers), type);
List<T> 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<Integer> ids) {
if (ids == null || ids.isEmpty()) {
return "";
}
return ids.stream().map(String::valueOf).collect(Collectors.joining(","));
}
}
@@ -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<ContactsGroup> 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<String, Object> getGroup(Integer page, Integer count, Integer type) {
if (type == null) {
type = 1;
}
Integer creatorId = currentUserId();
// 查询当前用户创建的全部联系组
List<ContactsGroup> allGroups = groupRepository.findByCreatorId(creatorId);
// 统计每组人数
List<Integer> groupIds = allGroups.stream().map(ContactsGroup::getId).collect(Collectors.toList());
List<ContactsGroupUser> allMembers = groupIds.isEmpty() ? Collections.emptyList()
: groupUserRepository.findByGroupIdIn(groupIds);
Map<Integer, Long> countByGroup = allMembers.stream()
.collect(Collectors.groupingBy(ContactsGroupUser::getGroupId, Collectors.counting()));
// 构建列表数据
List<Map<String, Object>> listData = allGroups.stream().map(g -> {
Map<String, Object> 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<String, Object> 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<ContactsGroup> 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<Integer> 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<Integer> 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<String, Object> getContactsTea(String gid, Integer page, Integer count) {
Integer groupId = 0;
try {
groupId = Integer.parseInt(gid);
} catch (NumberFormatException e) {
groupId = 0;
}
if (groupId == 0) {
Map<String, Object> empty = new LinkedHashMap<>();
empty.put("list", Collections.emptyList());
empty.put("total", 0);
return empty;
}
List<ContactsGroupUser> allMembers = groupUserRepository.findByGroupId(groupId);
int total = allMembers.size();
// 先分页再补全用户信息(避免为未分页数据调用用户中心)
List<ContactsGroupUser> 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<Map<String, Object>> listData = pageMembers.stream().map(m -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("sid", m.getId());
item.put("id", m.getUserId());
fillUserInfo(item, m);
return item;
}).collect(Collectors.toList());
Map<String, Object> result = new LinkedHashMap<>();
result.put("list", listData);
result.put("total", total);
return result;
}
/**
* 补全成员姓名:落库值为空时从用户中心按 userId 反查。
* 查到的值会回写落库自愈,避免历史空数据每次读取都重复远程调用。
*/
private void fillUserInfo(Map<String, Object> 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<Map<String, Object>> getGroupTree(Integer type) {
if (type == null) {
type = 1;
}
Integer creatorId = currentUserId();
// 查询当前用户的联系组
List<ContactsGroup> groups = groupRepository.findByCreatorId(creatorId);
List<Integer> groupIds = groups.stream().map(ContactsGroup::getId).collect(Collectors.toList());
// 查询全部成员
List<ContactsGroupUser> allMembers = groupIds.isEmpty() ? Collections.emptyList()
: groupUserRepository.findByGroupIdIn(groupIds);
// 构建树节点
List<Map<String, Object>> treeNodes = new ArrayList<>();
// 组节点 (type=1, pid=0)
for (ContactsGroup g : groups) {
Map<String, Object> 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<Map<String, Object>>());
treeNodes.add(node);
}
// 用户节点 (type=2, pid=groupId),姓名缺失时从用户中心补全
for (ContactsGroupUser m : allMembers) {
Map<String, Object> 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<Map<String, Object>> getDeptRoleGroupTree() {
// C# 从外部API获取部门树+用户角色映射。
// Java版暂未接入用户中心OpenAPI,返回空列表。
log.info("[getDeptRoleGroupTree] 暂未接入用户中心API,返回空列表");
return Collections.emptyList();
}
// ============================ 辅助方法 ============================
/**
* 递归构建用户树(对应 C# HttpUserApi.FormatUserTree)。
*/
@SuppressWarnings("unchecked")
private List<Map<String, Object>> formatUserTree(List<Map<String, Object>> treeList, int pid) {
List<Map<String, Object>> data;
if (pid == 0) {
// 根层:取没有父节点引用的节点(pid=0 或 pid不在列表中的)
Set<Integer> 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<Map<String, Object>> result = new ArrayList<>();
for (Map<String, Object> item : data) {
int itemId = (Integer) item.get("id");
int itemType = (Integer) item.get("type");
List<Map<String, Object>> children = formatUserTree(treeList, itemId);
// type=1(组) 且无子节点 → 不加入(C# 逻辑)
if (itemType == 1 && children.isEmpty()) {
continue;
}
Map<String, Object> 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();
}
}
@@ -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<Map<String, Object>> getSysDic(Integer type) {
String dictType = mapDictType(type);
if (dictType == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误");
}
List<SysDict> 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<String, Object> item = new LinkedHashMap<>();
item.put("k", d.getDictKey());
item.put("v", d.getDictValue());
return item;
})
.collect(Collectors.toList());
}
// ============================ GetDictMangeList ============================
@Override
public List<Map<String, Object>> getDictMangeList(String type) {
String dictType = normalizeManageType(type);
if (dictType == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误");
}
List<SysDict> list = sysDictRepository.findByDictTypeOrderBySortOrderAsc(dictType);
return list.stream()
.sorted(Comparator.comparingInt(d -> d.getSortOrder() == null ? 999 : d.getSortOrder()))
.map(d -> {
Map<String, Object> 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<SysDict> 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<SysDict> 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<SysDict> 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();
}
}
@@ -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;
/**
* 文件上传/下载/删除业务实现。
* <p>
* 存储路径规则:{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<Integer> 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<UpFileVo> 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<String, String> 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<UpFile> 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<String> 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<UpFile> files = upFileRepository.findByTopicTypeAndTopicId(type, topicId);
downloadZip(files, zipName, response);
}
// ============================ 批量上传图片 ============================
@Override
@Transactional
public List<UpFileVo> uploadImages(MultipartFile[] files, String appType) {
List<UpFileVo> 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;
}
}
@@ -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;
/**
* 用户中心门面服务实现。
* <p>原 BaseApiController.getSchoolAll 的组树组装、教师挂组、递归建树、排序逻辑整体下沉至此;
* GetGroupUser 空桩改为显式报错(未接入用户中心对应开放接口时提示,而非静默返回空列表)。
* 跨仓调用统一收敛到 {@link YschBaseOpenApi} 门面,Controller 不再直接静态调用。</p>
*/
@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<Map<String, Object>> getSchoolAllTree(Integer schId) {
if (schId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败");
}
// 1. userbase 组树(整棵学校子树),剔除学生组
List<Map<String, Object>> 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<Integer> treeGroupIds = new HashSet<>();
for (Map<String, Object> node : flatNodes) {
treeGroupIds.add((Integer) node.get("id"));
}
Map<Integer, SchTeaList> teacherMap = new LinkedHashMap<>();
Map<Integer, Set<Integer>> teacherPids = new LinkedHashMap<>();
List<SchTeaList> teachers = classService.getSchTeaList(schId);
for (SchTeaList tea : teachers) {
if (tea.getUserTeaSerID() == null) {
continue;
}
// 同一教师多行(多组/接口重复数据)合并,按 (教师, 组) 去重
teacherMap.putIfAbsent(tea.getUserTeaSerID(), tea);
// 接口现行字段为单个 GroupID,兼容旧字段 GroupIds
List<Integer> 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<Integer, SchTeaList> entry : teacherMap.entrySet()) {
int teaId = entry.getKey();
SchTeaList tea = entry.getValue();
Set<Integer> pids = teacherPids.get(teaId);
if (pids == null || pids.isEmpty()) {
// 教师所属组均被剔除(如学生组)或无组信息,挂学校根节点
pids = new HashSet<>(Collections.singletonList(treeGroupIds.contains(schId) ? schId : 0));
}
for (Integer pid : pids) {
Map<String, Object> 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<Map<String, Object>> nodes) {
if (group == null || group.getId() == null) {
return;
}
if (group.getType() != null && group.getType() == GROUP_TYPE_STUDENT) {
return;
}
Map<String, Object> 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<Map<String, Object>> formatUserTree(List<Map<String, Object>> treeList, int pid) {
List<Map<String, Object>> data;
if (pid == 0) {
Set<Integer> allIds = new HashSet<>();
for (Map<String, Object> n : treeList) {
allIds.add((Integer) n.get("id"));
}
data = new ArrayList<>();
for (Map<String, Object> n : treeList) {
int p = (Integer) n.get("pid");
if (p == 0 || !allIds.contains(p)) {
data.add(n);
}
}
} else {
data = new ArrayList<>();
for (Map<String, Object> n : treeList) {
if ((Integer) n.get("pid") == pid) {
data.add(n);
}
}
}
List<Map<String, Object>> result = new ArrayList<>();
for (Map<String, Object> item : data) {
int itemId = (Integer) item.get("id");
int itemType = (Integer) item.get("type");
List<Map<String, Object>> children = formatUserTree(treeList, itemId);
// type=1(组) 且无子节点 → 不加入(C# 逻辑)
if (itemType == 1 && children.isEmpty()) {
continue;
}
Map<String, Object> 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<Map<String, Object>> 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<BoaModule> 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<Map<String, Object>> buildModuleTree(List<BoaModule> modules) {
Map<Integer, BoaModule> moduleMap = modules.stream()
.collect(Collectors.toMap(BoaModule::getId, m -> m, (a, b) -> a));
// 每个模块节点(含 children 占位)
Map<Integer, Map<String, Object>> nodeMap = new LinkedHashMap<>();
for (BoaModule m : modules) {
Map<String, Object> 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<Map<String, Object>> roots = new ArrayList<>();
for (BoaModule m : modules) {
Map<String, Object> 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<Map<String, Object>> children = (List<Map<String, Object>>) nodeMap.get(parentId).get("children");
children.add(node);
}
}
// 保持 SDK(用户中心)返回顺序,不再按 orderNumber 重排
return roots;
}
}
@@ -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 <T> BasePageResult<T> toResult(Page<T> page) {
return new BasePageResult<T>(
page.getContent(),
(int) page.getTotalElements(),
page.getNumber() + 1,
page.getSize(),
page.getTotalPages(),
page.hasNext()
);
}
}
@@ -0,0 +1,131 @@
package com.yunhai.familyactivity.utils;
import lombok.Value;
/**
* 当前登录用户上下文(ThreadLocal)。
* <p>由 {@link com.yunhai.familyactivity.aspect.ControllerUserContextAspect} 在请求进入控制器前填充、
* 请求结束后清理;Service 层统一通过本类获取当前用户(发布人账号、schId 数据隔离、操作日志)。
* 参考实现:UserclassService {@code com.yunhai.bschclassmanage.utils.UserContext}。</p>
*/
public class UserContext {
private static final ThreadLocal<Integer> USER_ID = new ThreadLocal<>();
private static final ThreadLocal<String> USER_NAME = new ThreadLocal<>();
private static final ThreadLocal<String> ACCOUNT = new ThreadLocal<>();
private static final ThreadLocal<Integer> ROLE_ID = new ThreadLocal<>();
private static final ThreadLocal<Integer> SCH_ID = new ThreadLocal<>();
private static final ThreadLocal<String> IP = new ThreadLocal<>();
private static final ThreadLocal<Integer> 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();
}
}
+72
View File
@@ -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
+33
View File
@@ -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
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 引入 Spring Boot 默认彩色日志变量 -->
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<!-- 控制台:所有环境都有,且带颜色2 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>${CONSOLE_LOG_CHARSET}</charset>
</encoder>
</appender>
<!-- 文件日志:只有非 local 环境才启用 -->
<springProfile name="!local">
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/app/logs/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>/app/logs/app.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
</encoder>
</appender>
</springProfile>
<root level="INFO">
<appender-ref ref="CONSOLE" />
<springProfile name="!local">
<appender-ref ref="FILE" />
</springProfile>
</root>
</configuration>