Compare commits

...
Author SHA1 Message Date
chenxing d99ce81cc0 refactor(file): 附件根目录对齐平台标准 FILE_PATH——优先级 FILE_PATH(共享 application-platform.yml 一份)>FILE_UPLOAD_PATH(notice 过渡键)>file.upload-path(本地开发)
build / build-push (push) Successful in 2m31s
其他子应用(userhome/userbase/flow-center)代码即读 FILE_PATH,各子应用值相同
(/data/upload),收敛到共享 application-platform.yml 一份后子应用无需再各自维护;
notice 的 FILE_WEB_PATH/UPLOAD_WEB_PATH_HTTP 为 projman 模板键,代码无消费点。
2026-10-10 16:04:31 +08:00
chenxing b96de59b6d docs(file): FILE_UPLOAD_PATH 注释改为配置文件顶层键口径(与 flow-center 的 FILE_PATH 同玩法,环境变量亦可)
build / build-push (push) Successful in 2m38s
2026-10-10 15:54:07 +08:00
chenxing a4e8702dc0 fix(file): 附件存储根目录优先读平台标准环境变量 FILE_UPLOAD_PATH——修复生产附件随容器重建丢失
build / build-push (push) Successful in 1m27s
生产容器 env 只有旧 dotnet 风格 FILE_PATH 顶层键(Spring 不识别),@Value(${file.upload-path})
回落到打包默认 D:/data/upload,Linux 下被锚定为 /app/D:/data/upload 写进容器可写层,
每次 projman 重新部署附件全部丢失(2026-10 事故,卷里仅剩 9/1 前的 3 个文件)。

新优先级:FILE_UPLOAD_PATH 环境变量(子应用平台新标准,projman 注入 /data/upload)
> file.upload-path(application.yml,仅本地 Windows 开发)> /data/upload 兜底。
deploy/notice.yaml 与 docker-compose.notice.yml 文档块同步更新。
2026-10-10 15:46:07 +08:00
chenxing 48cd8884d8 fix(notice): 已读明细空态 receivers 不可变列表修复 + SDK 暴露面测试补齐
build / build-push (push) Successful in 1m2s
- NoticeService.getReadDetailGrouped:receivers 空态(全员通知)返回不可变 emptyList,追加已读者前先拷贝为 ArrayList,避免 UnsupportedOperationException
- 新增 NoticeReadDetailGroupedAllTypeTest、NoticeSdkAdviceExposureTest
2026-10-10 15:17:08 +08:00
chenxing b85f5e16fb feat(notice): 移动端 API 接口面(/mobiapi)——路径常量闭集、访问拦截器与控制器
- Config:API_PATH 由通配 /{platform} 收敛为 /api(PC 管理端,CAS 会话),新增 MOBI_API_PATH=/mobiapi(移动端,平台 Bearer token)
- 新增 MobiAccessInterceptor:移动端访问拦截(token 校验)
- 新增 MobiWebMvcConfig:挂载 /mobiapi 前缀与拦截器
- 新增 BaseMobiController / NoticeMobiController:移动端通知接口
- 随附测试:MobiAccessInterceptorTest / MobiWebMvcConfigTest / NoticeMobiControllerTest
2026-10-10 15:17:06 +08:00
chenxing 81a988f96a feat(notice): 流程快照增约定键 summaryFields(通知标题/来源部门/通知对象)
build / build-push (push) Successful in 1m4s
供 flow-center 嵌入页「业务信息」直出中文键名,零后端改动(bizSnapshot 透传)。
通知对象:全员 ⇒「全部」;指定 ⇒ 前 3 个姓名以「、」连接,多于 3 人再追「 等N人」。
原有键(title/summary/attachmentNames 等)照旧保留。
2026-10-10 14:56:10 +08:00
chenxing 5efe9c09fd Merge remote-tracking branch 'origin/main' into feat/flow-embed
build / build-push (push) Successful in 2m19s
2026-10-09 20:56:28 +08:00
chenxing e6e91157be feat(notice): 新增 Flow/DiagramDef 端点——flowGuid 口径后端中转 PNG(SDK diagramUrl 双路径+token 复用),供绑定页/发通知页 Drawer 修复 404 坏链;守卫与绑定读取同口径(CAS 登录即可) 2026-10-09 20:04:28 +08:00
chenxing 09eb27c6cf fix(appearance): 503 改 sendError 直写——绕过 GlobalExceptionHandler 兜成 200+code:6 信封(线上实测发现)
build / build-push (push) Failing after 1m3s
2026-10-09 00:40:13 +08:00
chenxing 0721cf3b7f fix(appearance): 503 改 sendError 直写——绕过 GlobalExceptionHandler 兜成 200+code:6 信封(线上实测发现) 2026-10-09 00:39:47 +08:00
chenxing 0d05986f8c feat(appearance): 外观偏好同源读代理端点 GET /api/appearance(oapi 代理轮)
build / build-push (push) Successful in 3m30s
- YschOpenCall(target=userhome) 经 open-gateway 签名直调 /oapi/appearance,JsonNode 整体透传(哑管道)
- 降级铁律:下游不可达 503(init 侧静默不动本地键),绝不伪装 200+全 null 清键
2026-10-08 23:59:25 +08:00
chenxing 867809fe37 feat(appearance): 外观偏好同源读代理端点 GET /api/appearance(oapi 代理轮)
- YschOpenCall(target=userhome) 经 open-gateway 签名直调 /oapi/appearance,JsonNode 整体透传(哑管道)
- 降级铁律:下游不可达 503(init 侧静默不动本地键),绝不伪装 200+全 null 清键
2026-10-08 23:27:33 +08:00
chenxing a103d661fc feat(notice): 接入流程进度嵌入页(守卫端点 + Drawer iframe) 2026-10-03 16:59:41 +08:00
chenxing 0216d0655c feat(msg): JumpTarget 标准化接入——两处跳转换 SDK 结构化门面(app+jump 链式/setBizJump),jumpPath 与 bizUrl 输出逐字节零差异;随车自指地址 Phase2 镜像收口(front-base-url 与 callback-* 键删除、裸相对路径、jumpApp 归属声明,测试同步);application.yml 服务目录键 flowcenter→flow-center 归一(D1 P3 尾巴)
build / build-push (push) Successful in 2m15s
2026-09-29 11:00:05 +08:00
chenxing 082ef0af0a fix(test): NoticeApprovalClientTest 漏改 FLOWCENTER→FLOW_CENTER(D1 补漏)——test/notice/flow 包不在首轮扫描路径,CI testCompile 拦获
build / build-push (push) Successful in 2m25s
2026-09-28 18:39:48 +08:00
chenxing 4ac3626b92 refactor(flow): 目录键归一 flowcenter→flow-center(D1)——PlatformService.FLOW_CENTER 机械跟随,FlowCenterClientTest 失败文案断言同步新键;NoticeFlowProperties 注释残留随移动门户线 WIP 后续处理
build / build-push (push) Failing after 7s
2026-09-28 17:26:30 +08:00
chenxing a7e93cb22d refactor(flow): 回调端点收敛为 starter 默认 /oapi/flow/callback + 开漂移自动核对
build / build-push (push) Successful in 1m5s
网关契约 /{target}/oapi/** 的首段已由 target 承担,应用内端点再加一段
<target> 是冗余(Notice 的 /oapi/notice/flow/callback 属历史遗留:其 oapi
Controller 统一建在 /oapi/notice/** 下)。

- application.yml: notice.flow.callback-path 与 yh.flow-callback.path
  由 /oapi/notice/flow/callback 收敛为 /oapi/flow/callback
- application.yml: 摘除显式 callback-url,注册地址改由 starter 推导
  (open-host ?: yh.open.base-host) + /{target ?: base-path} + path
  ⇒ 生产 = http://open-gateway-service:10013/notice/oapi/flow/callback
  回滚绳:把全地址写回 callback-url 即可,无需回滚镜像
- application.yml: 开 reconcile-on-register=true —— 端点/ctx/base-path
  变更后镜像行有效但登记 URL 不一致时自动重注册,替代"删镜像行→重新保存
  绑定"的手工恢复路径(注意:重注册会轮换 secret)
- NoticeFlowProperties: callbackPath 默认值同步
- 测试 4 处同步冻结字面量/硬编码路径/断言

测试:mvn -o test → 114/114 通过(含 NoticeFlowApiIT 9 例、FlowCallbackTransientIT
1 例在真实过滤链上按新端点 200)
2026-09-24 11:57:01 +08:00
chenxing 58aadaa95a feat(flow): 迁 yh-flow-callback starter + 对接抄送与条件路由判断端点
build / build-push (push) Successful in 1m8s
迁 starter(2026-09-20 方案):删手写 OapiFlowCallbackController/FlowCallbackService,改 NoticeFlowCallbackHandler 实现 SPI;验签/eventId 幂等/乱序门交 starter 管线,yml 切中央密钥链(trust-registry)
抄送(2026-09-24):flow.cc.sent 落 notice_cc 表 + GET /api/notice/cc/My 应用内「抄送给我的」;未发布通知可见守卫加第 4 条「被抄送人」
条件路由(2026-09-24):POST /oapi/notice/flow/route/dept-judge(纯读幂等;formData 优先免查库;未命中 outlets 抛错绝不默认放行);apply 的 formData 带发文部门(deptId/deptName)
IT 验签确定性:NoticeFlowApiIT/FlowCallbackTransientIT 属性覆盖回落白名单(trusted-apps.100019),不再依赖本地 Redis 密钥内容
生产 DDL:deploy/sql/20260924_add_notice_cc.sql;mvn test 114 全绿
2026-09-24 01:19:26 +08:00
chenxing df9b9e0aa5 feat(approval): 待审核代理——Todo/Instances/Approve/Reject(operator=CAS 登录态)
build / build-push (push) Successful in 1m2s
- NoticeApprovalClient:YschOpenCall 泛化调 flow-center /oapi/flow/tasks|task/**
  (SDK 四方法未发版,泛化调用零发版依赖,同 resolveTarget 解析链经网关)
- NoticeApprovalController:operatorId/Name 一律取 BoaUser 登录态,请求不收身份字段;
  reject 空 opinion 前置业务码拒绝;未接入流程中心降级 FORBIDDEN
- 9 用例:URL/参数逐字断言+bizType 固定+未接入+异常透传(90 存量单测无回归)
2026-09-23 00:28:32 +08:00
chenxing 5db629cfdb feat(msg): 接入消息中心——NoticeUserMsgNotifier(发布/转发/催办→UserMsg WS 实时推送,afterCommit 容错+分片+幂等)+ SDK 门面客户端 + 用户目录归一(09-19 二期验收 9/9)
build / build-push (push) Successful in 57s
2026-09-19 18:55:17 +08:00
chenxing a5204680e6 ci: 重触发构建(上次 runner OOM exit137)
build / build-push (push) Successful in 1m7s
2026-09-17 01:16:32 +08:00
chenxing 08e9531b5f feat(flow): 接入新流程体系——SDK withdraw/progress/diagramUrl:撤回后端中转+同源流程图端点+Trace 结构化 steps(治前端直调外溢)
build / build-push (push) Failing after 33s
2026-09-17 01:10:36 +08:00
chenxing b3c5326163 fix(flow): 回调路径过期值修正 + 地址缺失 fail-fast + 服务目录显式化
build / build-push (push) Successful in 2m10s
- application.yml:notice.flow.callback-path 由过期的 /callback/flow 改回
  /oapi/notice/flow/callback(/callback/* 已随 X-Flw-* 过滤器下线,旧值会让本地注册的
  回调地址 404);新增 yh.open.services.flowcenter,删 notice.flow.base-url。
- NoticeFlowProperties.baseUrl 默认值去掉(localhost:8085 静默回落是隐患);
  FlowCenterClient 在 enabled=true 且「目录与 legacy 键都缺」时构造期 fail-fast
  (enabled=false 不校验——显式未接入本就不需要地址)。
- 测试:新增 NoticeFlowCallbackConfigContractTest(反射拼控制器映射,冻结
  callback-path/callback-base-path 与控制器/Runtime ctx 的咬合);FlowCenterClientTest
  补 fail-fast 与 disabled 两态。68 测试全绿。
2026-09-16 21:06:17 +08:00
chenxing 8852c7ab02 refactor(sdk)!: 适配 SDK 领域分包——流程/班级/用户中心门面切新包
build / build-push (push) Successful in 55s
- FlowModels / FlowCenterClient / FlowCenterClientTest:
  openapi.dto.FlowDto → openapi.flow.model.*(按需引入 FlowApplyBody/Result、FlowCallbackRegBody/Result、
  FlowInstance/FlowSummary);TargetSpec/YschCallProperties/YschOpenCall → openapi.core.*
- ClassService(+IClassService):切新班级域门面 YschClassOpenApi + clazz.model.SchTea,
  替换原先用 openapi.client.YschBaseOpenApi 直连基址的写法
- ContactsService / NoticeService / UserCenterService:YschBaseOpenApi → openapi.base.*
- application.yml:随服务目录兜底去掉 open.enabled / user-center-url / userclass 三个 legacy 键
  (userclass 已由 PlatformService 枚举兜底 = base-host + /userclass)

验证:clean test 全绿 64 个非 IT 用例(含本次改动的 FlowCenterClientTest 5/5)。
12 个 *IT 在本机因无 MySQL(jdbc:mysql://localhost:3306/bsch_notice_test)在
entityManagerFactory 阶段 Connection refused,属环境性失败,与本次迁移无关。

说明:这批改动是工作区在途内容(适配 SDK 领域分包所必需),不落库则 Notice 无法对
当前 nexus 上的 SDK 构建通过——即"消费方提交态编译不过"的根因。
2026-09-16 19:26:27 +08:00
chenxing 01ce87ed7a feat(app-auth): Notice 入站验签由白名单改走中央密钥链(注册表模式)
- pom 显式引 spring-boot-starter-data-redis(yh-app-auth 的该依赖是 optional、不传递);
  删掉原先"不引 redis"的注释
- application.yml:去掉 trusted-apps 白名单,改 trust-registry: true —— 共享 Redis
  sign:app:* 单一真源,凡在 UserBase 注册过的 appKey 自动可验,新增调用方不必再改配置+重启;
  并关掉 management.health.redis.enabled(防 Redis 抖动把 /actuator/health 拉成 DOWN 503)
- 配套(projman 配置,非本仓):notice 应用勾选 redis 中间件(注入 SPRING_REDIS_*)+
  application-prod.yml 同步去白名单/开 trust-registry。缺任一环都会 fail-closed 到全 401

前置:UserBase 已部署 ①(密钥链落 db0,refreshAll synced=10),flow-center 生产身份 100009 已在
application_info 注册,故回调验签可从注册表解析。
2026-09-16 18:56:30 +08:00
chenxing bfb50a205d feat: 流程对接切 SDK 类型化门面 YschFlowOpenApi(服务目录 flowcenter 优先,legacy base-url 回落)
build / build-push (push) Failing after 26s
- FlowModels 全部改为 extends SDK FlowDto(字段单一事实源收敛到 SDK,消费方签名零改动)
- FlowCenterClient:目录显式配置 flowcenter(yh.call.targets/yh.open.services)走门面命名目标,
  未配置回落 notice.flow.base-url ad-hoc(迁移期兼容)
- 含 2026-09-13/14 存量:重提清旧驳回意见(F4)/消息中心/文件上传改造
2026-09-16 01:21:23 +08:00
chenxing 138b226ab0 先审后发
build / build-push (push) Successful in 2m29s
2026-09-15 22:09:43 +08:00
chenxing fffcf1c66d refactor(base): GetUserMod 不再本地组树,改为映射用户中心下发的 children
build / build-push (push) Successful in 2m27s
用户中心已将模块层级经平台 BoaModule.children 下发,本仓原先按 parentId 重建父子
关系的 buildModuleTree 变成空转(且每个子应用各抄一份),现收敛为 toFrontendTree:
只做前端 layout 需要的字段改名(path→page、id→modId、补 usable/orderNumber)并递归
映射 children,层级与兄弟顺序一律以上游为准,本侧不重排。

前端契约不变(children 恒为数组),未触碰任何 .vue/.ts;收到旧版用户中心的扁平结果
(children=null)时退化为与今日一致的扁平菜单,不抛 NPE。
2026-09-15 16:20:28 +08:00
chenxing 69c2a41dd2 refactor: 删除 PropertyEnvConfigSource,env-config 数据源由平台默认挂载文件源兜底(yh-env-config starter B2 下沉)
build / build-push (push) Successful in 1m14s
2026-09-09 11:27:35 +08:00
chenxing 5384a08475 接入平台 env-config:新增 PropertyEnvConfigSource 激活免登录 GET /api/env-config?target=front(线上值经 YH_ENV_CONFIG_SOURCE_FRONT / Nacos 键注入,projman 维护);新增 EnvConfigApiIT 真实端口验证匿名旁路 CAS(surefire 追加 *IT,连本地 bsch_notice_test 自动建库)
build / build-push (push) Successful in 2m30s
2026-09-08 22:10:56 +08:00
chenxing d5f25b8029 字典接口补注册 /dict/Sync(迁移自 base/GetSchModuleIsSms,修复发文页 404)
build / build-push (push) Successful in 2m8s
2026-09-08 14:51:40 +08:00
chenxing 7cf17528ef 班级管理地址修订
build / build-push (push) Successful in 54s
2026-09-08 14:18:27 +08:00
chenxing 2c8125a549 移除未使用的 redis/cache 依赖,修复 /actuator/health 整体 DOWN
build / build-push (push) Successful in 53s
2026-09-03 11:40:50 +08:00
chenxing 9351d6e9ab 健康检测
build / build-push (push) Successful in 1m16s
2026-09-03 10:06:58 +08:00
chenxing 5cc6730941 ci
build / build-push (push) Successful in 1m16s
2026-09-02 09:34:27 +08:00
chenxing 41849defc9 通知修订 2026-09-01 19:55:12 +08:00
chenxing c387f8eb76 merge projman re-injected files (Dockerfile 10002 + build.yml network option)
build / build-push (push) Successful in 2m15s
2026-09-01 14:37:20 +08:00
chenxing 6375562c8d 通知修订 2026-09-01 14:09:24 +08:00
chenxing 83975ad93b ci 2026-08-24 20:02:51 +08:00
chenxing 88456e4fc8 ci 2026-08-24 18:02:11 +08:00
chenxing 379d073df2 fix: Dockerfile COPY 路径 target/notice.jar -> target/app.jar 对齐 CI 产物名 2026-08-24 18:01:40 +08:00
chenxing 81f32026c3 ci 2026-08-24 17:57:39 +08:00
admin 9114f44331 ci: init build workflow [skip ci] 2026-08-24 17:41:15 +08:00
chenxing bf973e5979 merge projman injected files 2026-08-24 17:40:23 +08:00
chenxing 40dd7fadea init local code 2026-08-24 17:37:10 +08:00
admin c892461405 init: readme guide [skip ci] 2026-08-24 17:24:47 +08:00
admin 4d86472913 Initial commit 2026-08-24 17:24:46 +08:00
142 changed files with 13284 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
target/
.idea/
*.iml
*.log
.DS_Store
+54
View File
@@ -0,0 +1,54 @@
# 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;库名为 bsch_notice(需先在 mysql8 上建库)
url: jdbc:mysql://mysql8:3306/bsch_notice?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.upload-path(Java 端 @Value 按此读取;旧 FILE_PATH 顶层键不生效)
file:
upload-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
+222
View File
@@ -0,0 +1,222 @@
<?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>Notice</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>
<!-- 流程中心回调接收端 starter(2026-09-20 迁移):验签外置(/oapi/* 平台验签),
接收管线(幂等/乱序门/同事务推进)+ 登记注册器组件化,业务映射见 NoticeFlowCallbackHandler -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-flow-callback-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>
<!-- 入站验签走中央密钥链(注册表模式)必须显式引 data-redis:yh-app-auth 的该依赖是
optional、不传递。副作用是 RedisHealthIndicator 会被装配,故 yml 里关掉
management.health.redis.enabled,防 Redis 抖动把 /actuator/health 拉成 DOWN(503)。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</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.notice.NoticeApplication</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- 本工程测试命名约定:纯单元 *Test + 集成 *IT(连本地 MySQL),统一由 surefire 执行
(surefire 默认只匹配 *Test.java,不追加则 *IT 永不执行) -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<includes>
<include>**/*Test.java</include>
<include>**/*IT.java</include>
</includes>
</configuration>
</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,20 @@
package com.yunhai.notice;
import com.alibaba.fastjson.parser.ParserConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication(scanBasePackages = "com.yunhai.notice")
@EnableTransactionManagement
@EnableScheduling
public class NoticeApplication {
public static void main(String[] args) {
// 开启 fastjson 安全模式
ParserConfig.getGlobalInstance().setSafeMode(true);
SpringApplication.run(NoticeApplication.class, args);
}
}
@@ -0,0 +1,34 @@
package com.yunhai.notice.common.interceptor;
import com.yunhai.platform.auth.BoaUserContext;
import com.yunhai.platform.auth.UserContextHolder;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 移动端 /mobiapi 访问拦截(口径 = 登录即可访问)。
* <p>不做访问级别判级——移动端主体含学生(旧 SUPER/BUREAU/SCHOOL/NONE 判级照抄会全 403)。
* token 解析、401+40101 信封与 pre-token 豁免由平台 SDK {@code LoginUserInterceptor} 承担
* (tokenSurfaces 缺省 /mobiapi);本拦截器是业务侧兜底:SDK 之后的任何 /mobiapi 业务请求,
* 身份上下文为空即 401,防止依赖链路变化时出现"匿名可达的业务端点"。</p>
*/
public class MobiAccessInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (UserContextHolder.get() == null && BoaUserContext.get() == null) {
try {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"code\":40101,\"msg\":\"未登录或登录已过期\",\"data\":null}");
} catch (Exception ignored) {
// 响应已提交等场景:状态码已设置,写体失败不掩盖原始语义
}
return false;
}
return true;
}
}
@@ -0,0 +1,47 @@
package com.yunhai.notice.common.listener;
import com.yunhai.notice.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.notice.common.validation;
/**
* JSR-303 分组校验标记接口(对齐 UserBase 规范)。
* Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。
*/
public interface ValidationGroups {
interface Create {}
interface Update {}
}
@@ -0,0 +1,14 @@
package com.yunhai.notice.config;
/**
* 全局路径常量(三层接口面,闭集):/api PC 管理端(CAS)、/mobiapi 移动端(token)、/oapi 开放接口(应用签名)。
* 其余前缀(如旧 /manage、/{platform} 通配)一律 404(方案 §4.1 路径闭集)。
*/
public enum Config {
Inst;
// 末尾不带斜杠:各控制器用 API_PATH + "/xxx/" 拼路径,若这里带尾斜杠会拼出字面双斜杠而 404
public static final String API_PATH = "/api"; // PC 管理端(CAS 会话)
public static final String MOBI_API_PATH = "/mobiapi"; // 移动端(平台 Bearer token)
public static final String OPEN_API_PATH = "/oapi";
}
@@ -0,0 +1,35 @@
package com.yunhai.notice.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,27 @@
package com.yunhai.notice.config;
import com.yunhai.notice.common.interceptor.MobiAccessInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 移动端 /mobiapi 拦截装配。
* <p>{@code LOWEST_PRECEDENCE} 保证本配置器在 SDK 的 {@code YhAuthWebMvcConfigurer}
* (无 @Order)之后注册,登录态解析先于本兜底拦截执行。</p>
* <p>排除 {@code /mobiapi/auth/**}:其中 login/bind/exchange 是 SDK 定义的 pre-token 端点
* ({@code LoginUserInterceptor.PRE_TOKEN_ENDPOINTS}),设计上匿名可达——它们是登录链本身,
* 不是"业务免登录端点";logout/jsapiSignature 仍由 SDK 按 token 强制。业务端点无任何白名单。</p>
*/
@Configuration
public class MobiWebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new MobiAccessInterceptor())
.addPathPatterns("/mobiapi/**")
.excludePathPatterns("/mobiapi/auth/**")
.order(Ordered.LOWEST_PRECEDENCE);
}
}
@@ -0,0 +1,22 @@
package com.yunhai.notice.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 noticeOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("通知公告接口文档")
.description("通知公告子应用:发通知、列表、删除、编辑、管理员查看、统计(springdoc-openapi)")
.version("1.0"));
}
}
@@ -0,0 +1,64 @@
package com.yunhai.notice.controller.api;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.notice.config.Config;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.utils.model.BoaUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
/**
* 外观偏好同源读代理(oapi 代理轮 2026-10-08,全端统一形态)。
*
* <p>浏览器(通道 init 异步段)只与本应用【同源】取数——CAS 会话天然有效,无需 iframe 引导;
* 后端经 open-gateway 签名直调 UserHome(target=userhome,寻址走 yh.open.services,
* 勿写死 host)。设置入口收敛在 UserHome 设置中心,本端点【只读不写】。</p>
*
* <p>契约 = UserHome /oapi/appearance 出参【整体透传】(JsonNode 哑管道):字段加减只在
* UserHome + 通道 init 两端演进,本应用零改动。现行字段 {@code {theme, avatar, logoUrl}}——
* theme/avatar 为服务端合成后的【生效值】(个人→学校→平台级联,不拆 my/sch);
* null=未配置,init 侧清对应本地键回落默认观感。</p>
*
* <p>降级铁律:UserHome/网关不可达时【必须】以非 2xx 返回(503),绝不能伪装成 200+全 null——
* init 会把全 null 收敛成清键,等于 UserHome 停服一次就清空全员本地观感
* (对齐直连时代「fetch 失败静默、不动键」的语义)。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH)
public class AppearanceProxyController {
private static final Logger log = LoggerFactory.getLogger(AppearanceProxyController.class);
/** open_api_target 注册表中的 userhome;寻址 yh.call.targets → yh.open.services → 枚举兜底 */
private static final String TARGET_USERHOME = "userhome";
private final YschOpenCall openCall;
public AppearanceProxyController(YschOpenCall openCall) {
this.openCall = openCall;
}
@GetMapping("appearance")
public JsonNode appearance(BoaUser authUserWithDept, HttpServletResponse response) throws IOException {
try {
Map<String, String> q = new HashMap<>();
q.put("userId", String.valueOf(authUserWithDept.getId())); // 服务端取登录人,不收前端参数
return openCall.get(TARGET_USERHOME, "/oapi/appearance", q, JsonNode.class);
} catch (Exception e) {
// 降级铁律:sendError 直写 503 绕过 GlobalExceptionHandler(否则被兜成 200+code:6 信封,
// init 侧靠信封防御兜底,但语义上必须让浏览器拿到非 2xx)
log.warn("[appearance] UserHome 外观偏好不可达(503,init 侧静默不动本地键)userId={}:{}",
authUserWithDept.getId(), e.toString());
response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable");
return null;
}
}
}
@@ -0,0 +1,111 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.model.notice.UpFileResultVo;
import com.yunhai.notice.service.IFileService;
import com.yunhai.notice.service.IUserCenterService;
import com.yunhai.platform.auth.LoginUser;
import com.yunhai.platform.auth.UserContextHolder;
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.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
/**
* 平台代理域接口(base/)。
* <p>路径前缀:{@code Config.API_PATH + "/base/"}(原硬编码已修正)。
* 承载平台代理接口:GetUserInfo / GetUserMod / GetSchoolAll / 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);
@Autowired
private IUserCenterService userCenterService;
@Autowired
private IFileService fileService;
// ============================ 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, "notice");
}
// ============================ GetUserInfo ============================
/**
* 获取当前登录用户信息(CAS 会话注入)。
*/
@GetMapping("GetUserInfo")
public BoaUser getUserInfo(BoaUser authUserWithDept) {
return authUserWithDept;
}
// ============================ GetUserMod ============================
/**
* 获取当前用户已授权的模块列表(左侧菜单树)。
* 模块树组装已下沉 IUserCenterService。
*/
@GetMapping("GetUserMod")
public List<Map<String, Object>> 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);
}
// ============================ 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.notice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.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,81 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.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 化)。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/dict/")
public class DictController {
@Autowired
private IDictService dictService;
/**
* 获取系统字典(下拉框用)。
*/
@GetMapping("List")
public List<Map<String, Object>> list(@RequestParam(value = "type", defaultValue = "0") Integer type) {
return dictService.getSysDic(type);
}
/**
* 获取系统字典管理列表。
*/
@GetMapping("ManageList")
public List<Map<String, Object>> manageList(@RequestParam(value = "type", defaultValue = "dept") String type) {
return dictService.getDictMangeList(type);
}
/**
* 添加发文单位。
*/
@PostMapping("Add")
public void add(@RequestParam("name") String name) {
dictService.addDeptDic(name);
}
/**
* 更新发文单位。
*/
@PostMapping("Update")
public void update(
@RequestParam("SerID") Integer serID,
@RequestParam("Name") String name,
@RequestParam(value = "Enable", required = false, defaultValue = "1") Integer enable) {
dictService.updateDeptDic(serID, name, enable);
}
/**
* 删除发文单位(原 GET DelDept,已 POST 化)。
*/
@PostMapping("Delete")
public void delete(@RequestParam("serid") Integer serID) {
dictService.delDept(serID);
}
/**
* 发文单位排序(原 GET OrdbyDicIds,已 POST 化)。
*/
@PostMapping("Sort")
public void sort(@RequestParam("ids") String ids) {
dictService.ordbyDicIds(ids);
}
/**
* 是否开通短信(前端 getSchModuleIsSms,迁移自 base/GetSchModuleIsSms)。
*/
@GetMapping("Sync")
public Boolean sync() {
return dictService.getSchModuleIsSms();
}
}
@@ -0,0 +1,88 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.model.notice.UpFileResultVo;
import com.yunhai.notice.model.notice.UpFileVo;
import com.yunhai.notice.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, "notice");
}
/**
* 图片上传(多文件,返回数组)。
* 前端: POST file/UploadImage (form-data, accept=image/*)
*/
@PostMapping("UploadImage")
public List<UpFileVo> uploadImage(@RequestParam("file") MultipartFile[] files) {
return fileService.uploadImages(files, "notice");
}
/**
* 文件下载(浏览器直接 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("notice", topicId);
}
/**
* 批量绑定附件到通知。
*/
@PostMapping("Bind")
public void bind(@RequestParam("fileIds") List<Integer> fileIds,
@RequestParam("topicId") Integer topicId) {
fileService.bindToTopic(fileIds, topicId);
}
}
@@ -0,0 +1,98 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.service.flow.NoticeApprovalClient;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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 java.util.Map;
/**
* 应用内审批(第三步 T-C4):"我的待审核"数据面。前端不直调 oapi——
* operatorId/operatorName 一律取 CAS 登录态(BoaUser),请求参数里不收身份字段,
* 防伪造办理人;对端 flow-center 再按任务办理人复核(双层)。
* <ul>
* <li>GET Approval/Todo:待办分页(bizType=notice 固定);</li>
* <li>GET Approval/Instances:办件分页(status 可选);</li>
* <li>POST Approval/Approve / Reject:办理(body 仅 opinion/edgeName)。</li>
* </ul>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/notice/approval/")
@RequiredArgsConstructor
public class NoticeApprovalController {
private final NoticeApprovalClient approvalClient;
/** 我的待审核(列表页数据源) */
@GetMapping("Todo")
public Map<String, Object> todo(BoaUser user,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size) {
Operator op = requireOperator(user);
return approvalClient.todoTasks(op.schId, op.userId, page, size);
}
/** 办件视角(对账/历史;status 10 审批中/20 完成/30 拒绝/40 撤回) */
@GetMapping("Instances")
public Map<String, Object> instances(BoaUser user,
@RequestParam(value = "status", required = false) Integer status,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size) {
Operator op = requireOperator(user);
return approvalClient.instances(op.schId, status, page, size);
}
/** 通过(body: opinion 可选 / edgeName 多出口可选) */
@PostMapping("Approve")
public void approve(@RequestParam("taskId") Long taskId, BoaUser user,
@RequestBody(required = false) Map<String, String> body) {
Operator op = requireOperator(user);
String opinion = body == null ? null : body.get("opinion");
String edgeName = body == null ? null : body.get("edgeName");
approvalClient.approve(taskId, op.schId, op.userId, op.name, opinion, edgeName);
}
/** 拒绝(opinion 必填) */
@PostMapping("Reject")
public void reject(@RequestParam("taskId") Long taskId, BoaUser user,
@RequestBody(required = false) Map<String, String> body) {
Operator op = requireOperator(user);
approvalClient.reject(taskId, op.schId, op.userId, op.name,
body == null ? null : body.get("opinion"));
}
/** 登录态提参:无身份字段可被请求注入;姓名兜底账号(与 NoticeFlowService 提交人口径一致) */
private static Operator requireOperator(BoaUser user) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.FORBIDDEN, "未登录");
}
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "当前身份无学校归属");
}
String name = StringUtils.hasText(user.getName()) ? user.getName() : user.getAccount();
return new Operator(user.getId(), user.getCurrentRole().getSchId(), name);
}
private static final class Operator {
final Integer userId;
final Integer schId;
final String name;
Operator(Integer userId, Integer schId, String name) {
this.userId = userId;
this.schId = schId;
this.name = name;
}
}
}
@@ -0,0 +1,96 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.entity.NoticeCc;
import com.yunhai.notice.enums.FlowAuditStatusEnum;
import com.yunhai.notice.repository.NoticeCcRepository;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 应用内「抄送给我的」(2026-09-24):数据源是 flow.cc.sent 回调落的 notice_cc 表
* (NOTIFY 节点激活即抄送,审批过程中就可见——此时通知尚未发布,属知会不是可读)。
* 身份一律取 CAS 登录态(BoaUser),与 NoticeApprovalController 同口径,防伪造查询人。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/notice/cc/")
@RequiredArgsConstructor
public class NoticeCcController {
private final NoticeCcRepository noticeCcRepository;
private final NoticeRepository noticeRepository;
/** 抄送给我的(分页,sent_time 倒序;行内回填通知标题/部门/审核状态供列表直渲染) */
@GetMapping("My")
public Map<String, Object> my(BoaUser user,
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "size", defaultValue = "10") Integer size) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.FORBIDDEN, "未登录");
}
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "当前身份无学校归属");
}
int pageIndex = page == null || page < 1 ? 1 : page;
int pageSize = size == null || size < 1 ? 10 : Math.min(size, 100);
Page<NoticeCc> ccPage = noticeCcRepository.findBySchIdAndCcUserIdOrderBySentTimeDescIdDesc(
user.getCurrentRole().getSchId(), user.getId(), PageRequest.of(pageIndex - 1, pageSize));
// 批量回填通知信息(被删通知保留抄送行,标题置空由前端兜底)
Map<Integer, Notice> notices = ccPage.getContent().isEmpty()
? new HashMap<>()
: noticeRepository.findAllById(ccPage.getContent().stream()
.map(NoticeCc::getNoticeId).collect(Collectors.toSet())).stream()
.collect(Collectors.toMap(Notice::getId, Function.identity()));
List<Map<String, Object>> content = new ArrayList<>();
for (NoticeCc cc : ccPage.getContent()) {
Map<String, Object> row = new HashMap<>();
row.put("id", cc.getId());
row.put("noticeId", cc.getNoticeId());
row.put("flowInstanceId", cc.getFlowInstanceId());
row.put("nodeId", cc.getNodeId());
row.put("nodeName", cc.getNodeName());
row.put("sentTime", cc.getSentTime());
Notice notice = notices.get(cc.getNoticeId());
if (notice != null) {
row.put("title", notice.getTitle());
row.put("deptName", notice.getDeptName());
row.put("publisherName", notice.getPublisherName());
row.put("noticeStatus", notice.getStatus());
row.put("auditStatus", notice.getAuditStatus());
row.put("auditStatusDesc", FlowAuditStatusEnum.descOf(notice.getAuditStatus()));
} else {
row.put("title", null);
row.put("noticeStatus", null);
row.put("auditStatus", null);
row.put("auditStatusDesc", "通知已删除");
}
content.add(row);
}
Map<String, Object> result = new HashMap<>();
result.put("total", ccPage.getTotalElements());
result.put("content", content);
result.put("pageIndex", pageIndex);
result.put("pageSize", pageSize);
result.put("totalPages", ccPage.getTotalPages());
result.put("hasNext", ccPage.hasNext());
return result;
}
}
@@ -0,0 +1,217 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.common.validation.ValidationGroups;
import com.yunhai.notice.model.BasePageResult;
import com.yunhai.notice.model.notice.*;
import com.yunhai.notice.service.INoticeService;
import com.yunhai.notice.utils.PageUtils;
import com.yunhai.platform.utils.model.BoaUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 通知公告接口(合并 NoticeApiController + 旧 NoticeController)。
* <p>路径前缀:{@code Config.API_PATH + "/notice/"}(platform = api / manage / mobiapi)。
* 方法动词式 PascalCase(对齐 UserBase);写操作一律 POST。
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/notice/")
public class NoticeController {
@Autowired
private INoticeService noticeService;
// ============================ 收件箱列表 ============================
/**
* 已收通知列表(用户端)。
*/
@GetMapping("List")
public NoticePageResult<NoticeVo> list(NoticeQuery query,
@SortDefault(sort = {"topmost", "createTime"},
direction = Sort.Direction.DESC) Sort sort) {
return noticeService.userList(query, PageUtils.toPageable(query, sort));
}
// ============================ 已发/全部通知列表 ============================
/**
* 已发/全部通知列表(管理端)。type=user 只看自己。
*/
@GetMapping("SendList")
public BasePageResult<NoticeVo> sendList(NoticeQuery query,
@SortDefault(sort = "createTime",
direction = Sort.Direction.DESC) Sort sort) {
return noticeService.adminList(query, PageUtils.toPageable(query, sort));
}
// ============================ 查看详情(标读) ============================
/**
* 查看通知详情(type=1 普通查看标记已读, type=0 编辑模式不标记已读)。
*/
@GetMapping("Detail")
public NoticeDetailVo detail(@RequestParam("serid") Integer serid,
@RequestParam(value = "type", defaultValue = "1") Integer type) {
return noticeService.detailMap(serid, type);
}
// ============================ 编辑回显(不标读) ============================
/**
* 获取通知详情(编辑/转发用,不标记已读)。
*/
@GetMapping("GetDetail")
public NoticeDetailVo getDetail(@RequestParam("serid") Integer serid) {
return noticeService.getDetailMap(serid);
}
// ============================ 新建 ============================
/**
* 新建通知(JSON body)。fileIds 为附件ID逗号串,smsFlag 为短信标记。
*/
@PostMapping("Create")
public Integer create(@RequestBody @Validated(ValidationGroups.Create.class) NoticeRequest request) {
return noticeService.publish(request);
}
// ============================ 编辑 ============================
/**
* 编辑通知(JSON body,含 id)。
*/
@PostMapping("Edit")
public void edit(@RequestBody @Validated(ValidationGroups.Update.class) NoticeRequest request) {
noticeService.edit(request.getSourceId() != null ? request.getSourceId() : 0, request);
}
// ============================ 批量删除 ============================
/**
* 批量删除通知(ids 数组)。
*/
@PostMapping("Delete")
public void delete(@RequestBody List<Integer> ids) {
noticeService.deleteBatch(ids);
}
// ============================ 撤回 ============================
/**
* 撤回通知。
*/
@PostMapping("UserPull")
public void userPull(@RequestParam("serid") Integer serid) {
noticeService.pull(serid);
}
// ============================ 置顶 ============================
/**
* 置顶/取消置顶。
*/
@PostMapping("SetTopmost")
public void setTopmost(@RequestParam("serid") Integer serid,
@RequestParam(value = "topmost", defaultValue = "1") Integer topmost) {
noticeService.setTopmost(serid, topmost);
}
// ============================ 追加接收人 ============================
/**
* 追加接收人。
*/
@PostMapping("ReceiveUsers")
public void receiveUsers(@RequestParam("serid") Integer serid,
@RequestParam("users") String users) {
noticeService.receiveUsers(serid, users);
}
// ============================ 催收 ============================
/**
* 催收未读。
*/
@PostMapping("Expedite")
public void expedite(@RequestParam("serid") Integer serid) {
noticeService.expedite(serid);
}
// ============================ 一键已读 ============================
/**
* 一键全部已读(POST 化,原为 GET 写操作)。
*/
@PostMapping("InitializeRed")
public InitializeRedVo initializeRed() {
return noticeService.initializeRedResult();
}
// ============================ 阅读情况 ============================
/**
* 阅读情况(按已读/未读分组)。
*/
@GetMapping("ReadDetail")
public List<Map<String, Object>> readDetail(@RequestParam("serid") Integer serid) {
return noticeService.getReadDetailGrouped(serid);
}
// ============================ 上一篇/下一篇 ============================
/**
* 上一篇/下一篇通知ID。
*/
@GetMapping("GetNextPrev")
public Map<String, Object> getNextPrev(@RequestParam("serid") Integer serid,
@RequestParam("type") String type) {
return noticeService.getNextPrev(serid, type);
}
// ============================ 附件打包下载 ============================
/**
* 批量下载通知全部附件(打包 zip)。
*/
@GetMapping("AllFileDown")
public void allFileDown(@RequestParam("dataid") Integer dataid,
HttpServletResponse response) {
noticeService.downloadAllFiles(dataid, response);
}
// ============================ 统计 ============================
/**
* 通知统计。
*/
@GetMapping("Statis")
public NoticeStatisticsVo statis(
@RequestParam(value = "starttime", required = false)
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime start,
@RequestParam(value = "endtime", required = false)
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime end) {
return noticeService.statistics(new NoticeQuery(), start, end);
}
// ============================ 转发(合并旧 REST 壳 forward) ============================
/**
* 转发通知(基于源通知创建新通知)。sourceId 在 request body 中传递。
*/
@PostMapping("Forward")
public Integer forward(@RequestBody @Validated(ValidationGroups.Create.class) NoticeRequest request) {
return noticeService.forward(request.getSourceId(), request);
}
}
@@ -0,0 +1,118 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.model.flow.FlowModels;
import com.yunhai.notice.service.flow.NoticeFlowService;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* 通知-流程中心接缝接口(先审后发)。
* <ul>
* <li>GET Flow/Options:编辑页"审核流程"选择器(代理 FlowClient.listFlows,前端不直调 oapi);</li>
* <li>GET Flow/Binding:应用设置 &gt; 流程绑定 的读取(登录可读;发通知页据此判断是否走审核);</li>
* <li>POST Flow/Binding/Save:保存/解除应用级绑定(仅本校管理员);</li>
* <li>POST Flow/TrySubmit:提交/重提审核(幂等,bizId=notice.id;apply 失败保持待提交可重推);</li>
* <li>GET Flow/Trace:审批进度(后端 getInstanceByBiz 组装,前端不直调 oapi——放行判定必须收在后端)。</li>
* <li>GET Flow/Embed:办件进度嵌入页地址(Drawer iframe 直嵌,可见性与 Trace 同口径)。</li>
* <li>GET Flow/DiagramDef:流程定义示意图(绑定页/发通知页 Drawer,登录即可读——与 Binding 同口径)。</li>
* </ul>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/notice/flow/")
@RequiredArgsConstructor
public class NoticeFlowController {
private final NoticeFlowService noticeFlowService;
private final com.yunhai.notice.service.flow.NoticeFlowEmbedService noticeFlowEmbedService;
private final com.yunhai.notice.service.flow.FlowClient flowClient;
/** 审核流程下拉选项(流程中心未启用时返回空列表,前端隐藏选择器) */
@GetMapping("Options")
public List<FlowModels.FlowSummaryVo> options(BoaUser user,
@RequestParam(value = "schId", required = false) Integer schId) {
return noticeFlowService.options(schId != null ? schId : currentSchId(user));
}
/**
* 应用设置 &gt; 流程绑定:本校绑定视图(bound/flowGuid/flowName/flowEnabled)。
* 登录即可读——发通知页靠它决定「提交审核」还是「发送」。
*/
@GetMapping("Binding")
public Map<String, Object> binding(BoaUser user) {
return noticeFlowService.bindingView(currentSchId(user), user);
}
/** 保存/解除绑定(flowGuid 空=解绑);仅本校管理员可改 */
@PostMapping("Binding/Save")
public Map<String, Object> saveBinding(@RequestParam(value = "flowGuid", required = false) String flowGuid,
BoaUser user) {
return noticeFlowService.saveBinding(user, flowGuid);
}
/** 提交/重新提交审核(编辑页重提按钮;audit=10 审批中将被拒绝;BP-R5-3 补属主守卫) */
@PostMapping("TrySubmit")
public void trySubmit(@RequestParam("serid") Integer serid, BoaUser user) {
noticeFlowService.trySubmitFlow(serid, user);
}
/** 审批进度(详情页进度卡片;未绑流程返回 bound=false 的未接入语义;未发布仅三类人可见) */
@GetMapping("Trace")
public Map<String, Object> trace(@RequestParam("serid") Integer serid, BoaUser user) {
return noticeFlowService.buildTrace(serid, user);
}
/**
* 撤回审核(2026-09-17 治理:原先前端直调 flow-center 用户面,现后端经 SDK 托管件中转;
* 仅创建人本人 + 审批中,撤回后状态由 flow.withdrawn 回调推进)。
*/
@PostMapping("Withdraw")
public void withdraw(@RequestParam("serid") Integer serid, BoaUser user) {
noticeFlowService.withdrawNotice(serid, user);
}
/** 流程示意图(PNG 流,同源直嵌 img;可见性与 Trace 同口径,token 不落前端) */
@GetMapping(value = "Diagram", produces = org.springframework.http.MediaType.IMAGE_PNG_VALUE)
public byte[] diagram(@RequestParam("serid") Integer serid, BoaUser user) {
return noticeFlowService.diagramImage(serid, user);
}
/**
* 流程定义示意图(PNG 流,绑定页/发通知页 Drawer 同源直嵌 img)。def 口径无办件,
* 不适用 NoticeFlowGuard 办件守卫——与绑定读取(Binding,登录即可读)同口径:CAS 登录即可,
* schId 从登录用户推导,本校限定由 flow-center 按 schId+flowGuid 收敛;
* 绑定已解除/无已发布版时对端报业务错(JSON 信封),前端 img onerror 转提示,不留 500。
*/
@GetMapping(value = "DiagramDef", produces = org.springframework.http.MediaType.IMAGE_PNG_VALUE)
public byte[] diagramDef(@RequestParam("flowGuid") String flowGuid, BoaUser user) {
if (flowGuid == null || flowGuid.trim().isEmpty()) {
throw new com.yunhai.platform.apistd.BizException(
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "缺少流程标识");
}
return flowClient.diagramImageByFlowGuid(currentSchId(user), flowGuid.trim());
}
/**
* 办件进度嵌入页地址(详情页 Drawer 内 iframe 直嵌 flow-center 统一进度页)。
* 可见性与 Trace/Diagram 同口径;pageUrl 含短时 token,现取现发不落前端缓存。
*/
@GetMapping("Embed")
public Map<String, String> embed(@RequestParam("serid") Integer serid, BoaUser user) {
return noticeFlowEmbedService.embedUrl(serid, user);
}
private Integer currentSchId(BoaUser user) {
if (user == null || user.getCurrentRole() == null) {
return null;
}
return user.getCurrentRole().getSchId();
}
}
@@ -0,0 +1,88 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.service.ISmsService;
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 + "/sms/"}(原 schsms/ 已修正)。
* 方法名 PascalCase(原全小写已修正);写操作一律 POST(原 setschallsms/setschmodulesms 为 GET 写操作,已 POST 化)。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/sms/")
public class SmsController {
@Autowired
private ISmsService smsService;
/**
* 获取短信模块列表。
*/
@GetMapping("GetModule")
public List<Map<String, Object>> getModule() {
return smsService.getModuleList();
}
/**
* 获取学校父类组列表。
*/
@GetMapping("GetGroup")
public List<Map<String, Object>> getGroup() {
return smsService.getSchParentGroup();
}
/**
* 分页获取学校短信开关列表。
*/
@GetMapping("GetSchoolSms")
public Map<String, Object> getSchoolSms(
@RequestParam(value = "schpid", required = false) String schpid,
@RequestParam("PageSize") Integer pageSize,
@RequestParam("PageIndex") Integer pageIndex,
@RequestParam(value = "SchName", required = false, defaultValue = "") String schName) {
return smsService.getSchIsSmsList(pageSize, pageIndex, schName);
}
/**
* 获取学校列表。
*/
@GetMapping("GetSchool")
public List<Map<String, Object>> getSchool() {
return smsService.getSchList();
}
/**
* 设置单校单模块短信开关。
*/
@PostMapping("SetSchoolSms")
public void setSchoolSms(@RequestParam("itemsModu") String itemsModuJson) {
smsService.setSchSmsFromJson(itemsModuJson);
}
/**
* 设置某校全部模块短信开关(原 GET setschallsms,已 POST 化)。
*/
@PostMapping("SetAllSchoolSms")
public void setAllSchoolSms(
@RequestParam("SchSerID") Integer schSerID,
@RequestParam("IsSms") Boolean isSms) {
smsService.setSchSmsAll(schSerID, isSms);
}
/**
* 批量设置多校多模块短信开关(原 GET setschmodulesms,已 POST 化)。
*/
@PostMapping("SetSchoolModuleSms")
public void setSchoolModuleSms(
@RequestParam("SchSerID") String schSerID,
@RequestParam("Module") String module,
@RequestParam("IsSms") Boolean isSms) {
smsService.setSchModuleSms(schSerID, module, isSms);
}
}
@@ -0,0 +1,88 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.service.ITemplateService;
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 + "/template/"}(原 notice/Template/ 三级路径已修正)。
* 写操作一律 POST(原 EditType/DelTemplate 为 GET 写操作,已 POST 化)。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/template/")
public class TemplateController {
@Autowired
private ITemplateService templateService;
/**
* 获取模板类型列表。
*/
@GetMapping("Typelist")
public List<Map<String, Object>> getTypelist() {
return templateService.getTypelist();
}
/**
* 新增/编辑模板类型(原 GET EditType,已 POST 化)。
*/
@PostMapping("TypeEdit")
public void typeEdit(
@RequestParam("typeid") Integer typeid,
@RequestParam("TypeName") String typeName) {
templateService.editType(typeid, typeName);
}
/**
* 删除模板类型(原 GET DelType,已 POST 化)。
*/
@PostMapping("TypeDelete")
public void typeDelete(@RequestParam("typeid") Integer typeid) {
templateService.delType(typeid);
}
/**
* 分页获取模板列表。
*/
@GetMapping("List")
public Map<String, Object> list(
@RequestParam("PageSize") Integer pageSize,
@RequestParam("PageIndex") Integer pageIndex,
@RequestParam(value = "typeid", defaultValue = "0") Integer typeid) {
return templateService.getTemplatelist(pageSize, pageIndex, typeid);
}
/**
* 新增/编辑模板。
*/
@PostMapping("TemplateEdit")
public void templateEdit(
@RequestParam(value = "SerID", required = false) Integer serID,
@RequestParam("Title") String title,
@RequestParam("Contents") String contents,
@RequestParam(value = "Type", required = false) Integer type,
@RequestParam(value = "Cover", required = false) Integer cover) {
templateService.editTemplate(serID, title, contents, type, cover);
}
/**
* 删除模板(原 GET DelTemplate,已 POST 化)。
*/
@PostMapping("TemplateDelete")
public void templateDelete(@RequestParam("SerID") Integer serid) {
templateService.delTemplate(serid);
}
/**
* 获取单个模板详情。
*/
@GetMapping("TemplateFirst")
public Map<String, Object> templateFirst(@RequestParam("serid") Integer serid) {
return templateService.getTemplateFist(serid);
}
}
@@ -0,0 +1,85 @@
package com.yunhai.notice.controller.mobiapi;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.model.notice.UpFileResultVo;
import com.yunhai.notice.service.IFileService;
import com.yunhai.notice.service.IUserCenterService;
import com.yunhai.platform.utils.model.BoaUser;
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;
import java.util.Map;
/**
* 平台代理域移动端接口(/mobiapi/base/,token 身份)。
* <p>端点从 {@code controller.api.BaseApiController} 按移动端需要迁出,业务逻辑仍在
* {@link IUserCenterService} / {@link IFileService},本控制器仅"取参 → 调 Service → 返回";
* 管理端专用端点留在 api/ 面,本期不删。</p>
* <p>出入参口径与 api 侧一致(路径名沿用旧移动端 base/FilePreview|FileDown 调用形状;
* 文件预览/下载在 api 侧实际位于 FileController 的 file/Down|Preview,此处对齐其
* IFileService 签名)。登出不在此暴露——SDK 已提供 {@code POST /mobiapi/auth/logout}。</p>
*/
@RestController
@RequestMapping(value = Config.MOBI_API_PATH + "/base/")
public class BaseMobiController {
@Autowired
private IUserCenterService userCenterService;
@Autowired
private IFileService fileService;
/** 获取当前登录用户信息(token 链注入)。 */
@GetMapping("GetUserInfo")
public BoaUser getUserInfo(BoaUser authUserWithDept) {
return authUserWithDept;
}
/** 获取当前用户已授权的模块列表(移动端仅用于发布权限判定)。 */
@GetMapping("GetUserMod")
public List<Map<String, Object>> getUserMod(BoaUser authUserWithDept) {
Integer schId = authUserWithDept.getCurrentRole() != null
? authUserWithDept.getCurrentRole().getSchId() : null;
return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId);
}
/** 获取当前用户学校组及用户(树形结构)。 */
@GetMapping("GetSchoolAll")
public List<Map<String, Object>> getSchoolAll(BoaUser authUserWithDept) {
Integer schId = authUserWithDept.getCurrentRole() != null
? authUserWithDept.getCurrentRole().getSchId() : null;
return userCenterService.getSchoolAllTree(schId);
}
/** 获取指定组内用户(教师 + 管理员)。 */
@GetMapping("GetGroupUser")
public List<Map<String, Object>> getGroupUser(
@RequestParam("gid") Integer gid,
@RequestParam(value = "role", defaultValue = "2") Integer role) {
return userCenterService.getGroupUser(gid, role);
}
/** 附件上传(单文件)。返回 { fileid, host, fileurl, name, type, size }。 */
@PostMapping("FileUpload")
public UpFileResultVo fileUpload(@RequestParam("file") MultipartFile file) {
return fileService.uploadToResult(file, "notice");
}
/** 附件在线预览(inline 输出,前端取 Blob 渲染;type 兼容旧调用参数)。 */
@GetMapping("FilePreview")
public void filePreview(@RequestParam("id") Integer id,
@RequestParam(value = "type", required = false) Integer type,
HttpServletResponse response) {
fileService.preview(id, response);
}
/** 附件下载。 */
@GetMapping("FileDown")
public void fileDown(@RequestParam("id") Integer id, HttpServletResponse response) {
fileService.download(id, response);
}
}
@@ -0,0 +1,86 @@
package com.yunhai.notice.controller.mobiapi;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.model.BasePageResult;
import com.yunhai.notice.model.notice.*;
import com.yunhai.notice.service.INoticeService;
import com.yunhai.notice.utils.PageUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
import java.util.Map;
/**
* 通知公告移动端接口(/mobiapi 面,token 身份)。
* <p>路径前缀:{@code Config.MOBI_API_PATH + "/notice/"};方法名/参数与
* {@code controller.api.NoticeController} 保持一致,注入同一 {@link INoticeService},
* 不改 Service 实现;返回裸对象,由 ApiResponseAdvice 统一包装 {code, msg, data}。</p>
* <p>口径:登录即可访问(移动端主体含学生),不按管理端访问级别判级;
* 拦截与 401 语义见 {@code config.MobiWebMvcConfig} / {@code common.interceptor.MobiAccessInterceptor}。</p>
*/
@RestController
@RequestMapping(value = Config.MOBI_API_PATH + "/notice/")
public class NoticeMobiController {
@Autowired
private INoticeService noticeService;
/** 已收通知列表。 */
@GetMapping("List")
public NoticePageResult<NoticeVo> list(NoticeQuery query,
@SortDefault(sort = {"topmost", "createTime"},
direction = Sort.Direction.DESC) Sort sort) {
return noticeService.userList(query, PageUtils.toPageable(query, sort));
}
/** 已发/全部通知列表(type=user 只看自己)。 */
@GetMapping("SendList")
public BasePageResult<NoticeVo> sendList(NoticeQuery query,
@SortDefault(sort = "createTime",
direction = Sort.Direction.DESC) Sort sort) {
return noticeService.adminList(query, PageUtils.toPageable(query, sort));
}
/** 查看通知详情(type=1 标记已读,type=0 不标记)。 */
@GetMapping("Detail")
public NoticeDetailVo detail(@RequestParam("serid") Integer serid,
@RequestParam(value = "type", defaultValue = "1") Integer type) {
return noticeService.detailMap(serid, type);
}
/** 阅读情况(按学校组分组)。 */
@GetMapping("ReadDetail")
public List<Map<String, Object>> readDetail(@RequestParam("serid") Integer serid) {
return noticeService.getReadDetailGrouped(serid);
}
/** 催收未读。 */
@PostMapping("Expedite")
public void expedite(@RequestParam("serid") Integer serid) {
noticeService.expedite(serid);
}
/** 一键全部已读。 */
@PostMapping("InitializeRed")
public InitializeRedVo initializeRed() {
return noticeService.initializeRedResult();
}
/** 上一篇/下一篇通知ID({prevId, nextId},0=无)。 */
@GetMapping("GetNextPrev")
public Map<String, Object> getNextPrev(@RequestParam("serid") Integer serid,
@RequestParam("type") String type) {
return noticeService.getNextPrev(serid, type);
}
/** 批量下载通知全部附件(打包 zip)。 */
@GetMapping("AllFileDown")
public void allFileDown(@RequestParam("dataid") Integer dataid,
HttpServletResponse response) {
noticeService.downloadAllFiles(dataid, response);
}
}
@@ -0,0 +1,30 @@
package com.yunhai.notice.controller.oapi;
import com.yunhai.notice.service.IDictService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* 开放接口 demo(两级模型试点 P2.1:Notice 作为提供方开放 /oapi/**)。
* 强制验签由 starter 提供(yml {@code yh.app-auth.server.enforce-patterns: [/oapi/*]}):
* 浏览器/匿名直访 401,注册应用带 X-App-* 签名三件套直调通过(trusted-apps 收调用方密钥)。
*/
@RestController
@RequestMapping("/oapi/notice")
@RequiredArgsConstructor
public class OapiDictController {
private final IDictService dictService;
/** 部门字典(demo:type=1 部门;调用方签名鉴权由 enforce-patterns 强制) */
@GetMapping("/getDict")
public List<Map<String, Object>> getDict(@RequestParam(value = "type", defaultValue = "1") Integer type) {
return dictService.getSysDic(type);
}
}
@@ -0,0 +1,92 @@
package com.yunhai.notice.controller.oapi;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.util.StringUtils;
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.RestController;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* 条件路由判断端点(2026-09-24 设计方案 §5.2):flow-center CONDITION 节点(routerKey=
* notice.dept-judge)激活时同步调用——发文部门=校长室走"校长室"线(免校长审核),其余走"其他部门"线。
* <ul>
* <li>验签:收在 /oapi/* 下由 AppAuthFilter 强制验 flow-center(100019)的应用签名,零新概念;</li>
* <li>纯读幂等:凭 bizId 查 notice 表比对发文部门(variables 里有 formData 的部门则免查库,两口径一致);
* <li>契约纪律:返回 outlet 必须命中 outlets——流程定义与端点的出口名是同一份契约,不命中抛业务错
* (flow-center 侧按判断失败挂起,绝不默认放行);</li>
* <li>轻实现:同步 3s 超时,只做一次主键查询。</li>
* </ul>
*/
@RestController
@RequestMapping("/oapi/notice/flow/route")
@RequiredArgsConstructor
public class OapiFlowRouteController {
private static final String OUTLET_PRINCIPAL = "校长室";
private static final String OUTLET_OTHER = "其他部门";
private final NoticeRepository noticeRepository;
/** 判断请求体(契约见《子应用接入流程中心规范.md》条件路由判断接口;字段忽略未知向前兼容) */
@Data
public static class JudgeBody {
private Long schId;
private Long instanceId;
private Integer instanceVersion;
private String bizType;
private String bizId;
private String nodeId;
private String nodeName;
private String routerKey;
private List<String> outlets;
private Map<String, Object> variables;
}
@PostMapping("/dept-judge")
public Map<String, Object> deptJudge(@RequestBody JudgeBody body) {
Integer noticeId;
try {
noticeId = Integer.valueOf(String.valueOf(body.getBizId()).trim());
} catch (NumberFormatException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "bizId 非法:" + body.getBizId());
}
// formData 优先(apply 时透传了发文部门则免查库),缺失回落查表——两口径同一数据源
String deptName = variablesDeptName(body);
String deptId = null;
if (!StringUtils.hasText(deptName)) {
Notice notice = noticeRepository.findById(noticeId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "通知不存在:" + noticeId));
deptId = notice.getDeptId();
deptName = notice.getDeptName();
}
String outlet = OUTLET_PRINCIPAL.equals(deptName) ? OUTLET_PRINCIPAL : OUTLET_OTHER;
List<String> outlets = body.getOutlets() == null ? Collections.emptyList() : body.getOutlets();
if (!outlets.contains(outlet)) {
// 出口契约不一致(流程定义改了出口名而端点没跟上):抛错让 flow-center 挂起,不默认放行
throw new BizException(ErrorCode.PARAM_ERROR,
"判断结果未命中出口集合:outlet=" + outlet + " outlets=" + outlets
+ "(deptId=" + deptId + ", deptName=" + deptName + ")");
}
return Collections.singletonMap("outlet", outlet);
}
/** formData 里的发文部门(apply 时 bizSnapshot 同源透传;缺字段返回空串回落查表) */
private static String variablesDeptName(JudgeBody body) {
Map<String, Object> variables = body.getVariables();
if (variables == null) {
return "";
}
Object v = variables.get("deptName");
return v == null ? "" : String.valueOf(v).trim();
}
}
@@ -0,0 +1,49 @@
package com.yunhai.notice.entity;
import com.yunhai.notice.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.notice.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;
/** 创建人账号 */
@Column(name = "creator_account", length = 64)
private String creatorAccount;
}
@@ -0,0 +1,39 @@
package com.yunhai.notice.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_account", length = 64)
private String userAccount;
/** 用户姓名 */
@Column(name = "user_name", length = 64)
private String userName;
}
@@ -0,0 +1,178 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 通知公告主表。对应原 .NET Notify_Message(已剔除短信/模板/门户等无关字段)。
* <p>
* 阅读状态不落主表:采用「懒标记」模型——读者首次查看时写入 {@link NoticeRead},
* 不存在阅读记录即视为未读,从而无需在发通知时枚举全校用户预插接收行。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "notice")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class Notice extends BaseEntity {
/**
* 通知标题
*/
@Column(name = "title", nullable = false, length = 200)
private String title;
/**
* 学校ID(多租户隔离:发布时从当前用户角色填充,查询时按校过滤)。
*/
@Column(name = "sch_id")
private Integer schId;
/**
* 通知内容
*/
@Column(name = "content", columnDefinition = "LONGTEXT")
private String content;
/**
* 通知类型:1 普通 / 2 重要(见 {@link com.yunhai.notice.enums.NoticeTypeEnum})
*/
@Column(name = "type", nullable = false)
private Integer type = 1;
/**
* 发布人登录账号
*/
@Column(name = "publisher_account", length = 64)
private String publisherAccount;
/**
* 发布人姓名
*/
@Column(name = "publisher_name", length = 64)
private String publisherName;
/**
* 发布部门ID(原 DeptID)
*/
@Column(name = "dept_id", length = 64)
private String deptId;
/**
* 发布部门名称
*/
@Column(name = "dept_name", length = 128)
private String deptName;
/**
* 状态:1 已发布(无草稿态,见 {@link com.yunhai.notice.enums.NoticeStatusEnum})
*/
@Column(name = "status", nullable = false)
private Integer status = 1;
/**
* 是否置顶:0 否 / 1 是(原 topmost)
*/
@Column(name = "topmost")
private Integer topmost = 0;
// ==================== 迁移补全字段(对应 C# Notify_Message) ====================
/**
* 接收人列表(JSON 数组,如 ["account1","account2"] 或接收组ID列表)。
* 对应 C# Notify_Message.ReceiveList
*/
@Column(name = "receive_list", columnDefinition = "LONGTEXT")
private String receiveList;
/**
* 密码发送(对应 C# Notify_Message.PwdSend)
*/
@Column(name = "pwd_send", length = 255)
private String pwdSend;
/**
* 抄送人列表(对应 C# Notify_Message.ChaoSend)
*/
@Column(name = "chao_send", length = 2000)
private String chaoSend;
/**
* 已读数(发送时初始化为 0,也可由 NoticeRead 表 COUNT 实时计算)。
* 对应 C# Notify_Message.readcount
*/
@Column(name = "read_count")
private Integer readCount = 0;
/**
* 应读数(接收人总数)。对应 C# Notify_Message.allowcount
*/
@Column(name = "allow_count")
private Integer allowCount = 0;
/**
* 父通知ID(转发时关联原通知)。对应 C# Notify_Message.fkid
*/
@Column(name = "parent_id")
private Integer parentId;
/**
* 回复ID。对应 C# Notify_Message.reid
*/
@Column(name = "reply_id", length = 255)
private String replyId;
/**
* 是否撤回:0 否 / 1 是。对应 C# Notify_Message.ispull
*/
@Column(name = "is_pulled")
private Integer isPulled = 0;
/**
* 定时发送时间(到时间后自动发布)。对应 C# Notify_Message.DsfsTime
*/
@Column(name = "timing_send_time")
private LocalDateTime timingSendTime;
/**
* 短信发送标记:0 不发短信 / 1 发短信。对应 C# Notify_Message.dxbit
*/
@Column(name = "sms_flag")
private Integer smsFlag = 0;
/**
* 短信实际发送时间(防重复发送的幂等标记):NULL=未发。
* 直发/审批通过/到点补发三条链路共用(SmsSendService 原子认领);本地随 ddl-auto=update
* 自动加列,生产执行 deploy/sql/20260913_add_sms_sent_at.sql。
*/
@Column(name = "sms_sent_at")
private LocalDateTime smsSentAt;
// ===== 先审后发(流程中心托管):全部可空,NULL=未绑流程(原直发链路,存量零迁移) =====
/** 绑定的流程 GUID;NULL=直发(原链路一字不改) */
@Column(name = "flow_guid", length = 32)
private String flowGuid;
/** 流程中心办件 id(apply 成功回填) */
@Column(name = "flow_instance_id")
private Long flowInstanceId;
/** 审核镜像状态:0 待提交 / 10 审批中 / 20 通过 / 30 驳回 / 40 已撤回(FlowAuditStatusEnum) */
@Column(name = "audit_status")
private Integer auditStatus;
/** 已消费的最大回调 instanceVersion(乱序丢弃闸门) */
@Column(name = "last_instance_version")
private Integer lastInstanceVersion;
/** 终态明细快照(result/opinion/rejectNode,详情页进度卡片展示) */
@Column(name = "flow_result_json", columnDefinition = "LONGTEXT")
private String flowResultJson;
}
@@ -0,0 +1,59 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 流程抄送留痕(2026-09-24):flow-center NOTIFY 节点的 flow.cc.sent 回调逐人一行,
* 应用内「抄送给我的」列表数据源。幂等靠 starter 的 flow_callback_event(uk event_id),
* 本表只增不改——同一通知多节点抄送/退回重走重复抄送各成一行,轨迹按时间归位。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "notice_cc",
indexes = {
@Index(name = "idx_notice_cc_user", columnList = "sch_id, cc_user_id, sent_time"),
@Index(name = "idx_notice_cc_notice", columnList = "notice_id")})
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class NoticeCc extends BaseEntity {
/** 学校ID(多租户隔离,与 notice.sch_id 同域) */
@Column(name = "sch_id")
private Integer schId;
/** 关联通知(信封 bizId) */
@Column(name = "notice_id", nullable = false)
private Integer noticeId;
/** flow-center 办件 id(信封 instanceId) */
@Column(name = "flow_instance_id")
private Long flowInstanceId;
/** 抄送发生节点 id(data.nodeId) */
@Column(name = "node_id", length = 64)
private String nodeId;
/** 抄送环节名快照(data.nodeName) */
@Column(name = "node_name", length = 128)
private String nodeName;
/** 被抄送人用户 id(data.ccUserId,平台统一用户目录) */
@Column(name = "cc_user_id", nullable = false)
private Integer ccUserId;
/** 被抄送人姓名快照(data.ccUserName) */
@Column(name = "cc_user_name", length = 64)
private String ccUserName;
/** 抄送时间(事件处理时刻)。DATETIME(3) 与 flow-center flw_cc.sent_time 同口径 */
@Column(name = "sent_time", nullable = false, columnDefinition = "DATETIME(3)")
private LocalDateTime sentTime;
}
@@ -0,0 +1,39 @@
package com.yunhai.notice.entity;
import com.yunhai.notice.common.listener.NoticeAuditListener;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EntityListeners;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 应用级审核流程绑定(应用设置 &gt; 流程绑定,按学校一条)。
* <p>
* 语义(先审后发规则单一事实源):「通知对象=全部用户」且本校已绑定流程时,发通知必须走审核
* (status=0 审核载体,回调 completed 才发布);「通知对象=指定人员」与未绑定一律直发。
* 行不存在 = 未绑定。通知落库时把 flow_guid 快照进 notice 行,故改绑/解绑不影响在途审批。
*/
@Entity
@Table(name = "notice_flow_binding",
uniqueConstraints = @UniqueConstraint(name = "uk_notice_flow_binding_sch", columnNames = {"sch_id"}))
@Getter
@Setter
@EntityListeners(NoticeAuditListener.class)
public class NoticeFlowBinding extends BaseEntity {
/** 租户键(一校一条绑定) */
@Column(name = "sch_id")
private Integer schId;
/** 绑定的流程 GUID(flow-center flw_def.flow_guid,跨版本稳定) */
@Column(name = "flow_guid", length = 32)
private String flowGuid;
/** 流程名快照(流程中心不可达时设置页/提示条仍可显示) */
@Column(name = "flow_name", length = 128)
private String flowName;
}
@@ -0,0 +1,46 @@
package com.yunhai.notice.entity;
import lombok.Getter;
import lombok.Setter;
import com.yunhai.notice.common.listener.NoticeAuditListener;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EntityListeners;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 流程中心回调事件去重表(照 svc-hall ServiceFlowEvent / flow-center 事件 uk 同构):
* eventId 唯一约束兜底并发;重复投递(flow-center 持续重试语义下必然发生)命中唯一键即跳过。
*/
@Entity
@Table(name = "notice_flow_event",
uniqueConstraints = @UniqueConstraint(name = "uk_notice_flow_event", columnNames = "event_id"),
indexes = @Index(name = "idx_notice_flow_event_inst", columnList = "instance_id"))
@Getter
@Setter
@EntityListeners(NoticeAuditListener.class)
public class NoticeFlowEvent extends BaseEntity {
/** 事件唯一键(32 位 uuid,flow-center 重试不变) */
@Column(name = "event_id", length = 40, nullable = false)
private String eventId;
/** 事件码:flow.submitted / task.created / task.handled / completed / rejected / withdrawn / ping */
@Column(name = "event", length = 32)
private String event;
@Column(name = "sch_id")
private Integer schId;
@Column(name = "instance_id")
private Long instanceId;
@Column(name = "receive_time")
private java.time.LocalDateTime receiveTime;
@Column(name = "payload_json", columnDefinition = "LONGTEXT")
private String payloadJson;
}
@@ -0,0 +1,51 @@
package com.yunhai.notice.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EntityListeners;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import com.yunhai.notice.common.listener.NoticeAuditListener;
/**
* 流程中心回调注册(照 svc-hall ServiceFlowReg / flow-center FlwCallbackReg 同构):
* sch+appKey+flowGuid 唯一;secret 为 flow-center reg 接口下发时落库的回调验签密钥。
* 注意:flow-center 重复注册会<b>轮换 secret</b>,故仅在缺失时注册(ensureRegistered),
* 绝不重复调用——失配恢复手段=管理端重新保存绑定。
*/
@Entity
@Table(name = "notice_flow_reg",
uniqueConstraints = @UniqueConstraint(name = "uk_notice_flow_reg", columnNames = {"sch_id", "app_key", "flow_guid"}))
@Getter
@Setter
@EntityListeners(NoticeAuditListener.class)
public class NoticeFlowReg extends BaseEntity {
@Column(name = "sch_id")
private Integer schId;
/** 本应用在流程中心侧的 appKey(yh.app-auth.app-key,出站签名同一身份) */
@Column(name = "app_key", length = 64)
private String appKey;
@Column(name = "flow_guid", length = 32)
private String flowGuid;
@Column(name = "callback_url", length = 500)
private String callbackUrl;
/** 订阅事件逗号分隔,空=全部 */
@Column(name = "event_types", length = 200)
private String eventTypes;
/** flow-center reg 接口下发的回调验签密钥 */
@Column(name = "secret", length = 64)
private String secret;
/** 0 启用 / 1 停用(预留) */
@Column(name = "status")
private Integer status = 0;
}
@@ -0,0 +1,49 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.time.LocalDateTime;
/**
* 通知阅读记录(懒标记)。对应原 .NET Notify_ReceiveList 的「逐用户阅读状态」语义,
* 但仅在读者首次查看时落库,避免发通知时预插全员。
* <p>
* 唯一约束 (notice_id, reader_account):同一用户对同一通知只记一条已读。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "notice_read",
uniqueConstraints = @UniqueConstraint(
name = "uk_notice_reader",
columnNames = {"notice_id", "reader_account"}))
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class NoticeRead extends BaseEntity {
/**
* 通知ID(关联 notice.id)
*/
@Column(name = "notice_id", nullable = false)
private Integer noticeId;
/**
* 读者登录账号
*/
@Column(name = "reader_account", nullable = false, length = 64)
private String readerAccount;
/**
* 读者姓名
*/
@Column(name = "reader_name", length = 64)
private String readerName;
/**
* 阅读时间
*/
@Column(name = "read_time")
private LocalDateTime readTime;
}
@@ -0,0 +1,40 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
/**
* 通知接收人关联表(替代旧 receive_list JSON 字符串存储)。
* <p>
* 每条记录代表一个通知的一个接收人。全员通知(receive_list="ALL")不写明细行,
* 读取时按 receive_list="ALL" 标识走旧逻辑兜底。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "notice_receiver",
indexes = {
@Index(name = "idx_notice_id", columnList = "notice_id"),
@Index(name = "idx_account", columnList = "account")
})
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class NoticeReceiver extends BaseEntity {
/** 通知ID(关联 notice.id) */
@Column(name = "notice_id", nullable = false)
private Integer noticeId;
/** 接收人登录账号 */
@Column(name = "account", nullable = false, length = 64)
private String account;
/** 接收类型:1 接收 / 2 抄送 */
@Column(name = "receive_type", nullable = false)
private Integer receiveType = 1;
}
@@ -0,0 +1,40 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 通知模板。对应原 .NET Notify_Template 表。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "notice_template")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class NoticeTemplate extends BaseEntity {
/** 模板标题 */
@Column(name = "title", length = 200)
private String title;
/** 模板内容(HTML) */
@Column(name = "content", columnDefinition = "LONGTEXT")
private String content;
/** 封面图片文件ID */
@Column(name = "cover")
private Integer cover;
/** 引用次数 */
@Column(name = "ref_count")
private Integer refCount = 0;
/** 模板类型ID */
@Column(name = "type_id")
private Integer typeId;
}
@@ -0,0 +1,28 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 短信模块配置。对应原 .NET SchSms 表。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "sms_module")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SmsModule extends BaseEntity {
/** 模块名称 */
@Column(name = "module_name", length = 100)
private String moduleName;
/** 模块标识 */
@Column(name = "module_code", length = 50)
private String moduleCode;
}
@@ -0,0 +1,37 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 学校短信开通配置。对应原 .NET SchSms_Set 表。
* 记录某学校某模块是否开通短信。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "sms_school_config")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SmsSchoolConfig extends BaseEntity {
/** 学校ID */
@Column(name = "school_id", nullable = false)
private Integer schoolId;
/** 学校名称 */
@Column(name = "school_name", length = 200)
private String schoolName;
/** 模块ID */
@Column(name = "module_id")
private Integer moduleId;
/** 是否开通短信 */
@Column(name = "is_sms")
private Boolean isSms = false;
}
@@ -0,0 +1,40 @@
package com.yunhai.notice.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,28 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 通知模板类型。对应原 .NET Notify_TemplateType 表。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "template_type")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class TemplateType extends BaseEntity {
/** 类型名称 */
@Column(name = "type_name", length = 100)
private String typeName;
/** 排序号 */
@Column(name = "sort_order")
private Integer sortOrder = 0;
}
@@ -0,0 +1,47 @@
package com.yunhai.notice.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,42 @@
package com.yunhai.notice.enums;
import lombok.Getter;
/**
* 通知审核状态(audit_status 列,对齐 flow-center FlwEnums.InstanceStatus 与 svc-hall ApplyFlowStatusEnum;
* 未提交 0 为 Notice 特有——apply 尚未成功的暂态)。仅在绑定了审核流程(flow_guid 非空)时有意义。
*/
@Getter
public enum FlowAuditStatusEnum {
PENDING_SUBMIT(0, "待提交"),
APPROVING(10, "审批中"),
APPROVED(20, "已通过"),
REJECTED(30, "已驳回"),
WITHDRAWN(40, "已撤回");
private final int code;
private final String desc;
FlowAuditStatusEnum(int code, String desc) {
this.code = code;
this.desc = desc;
}
public static FlowAuditStatusEnum of(Integer code) {
if (code == null) {
return null;
}
for (FlowAuditStatusEnum e : values()) {
if (e.code == code) {
return e;
}
}
return null;
}
public static String descOf(Integer code) {
FlowAuditStatusEnum e = of(code);
return e == null ? null : e.getDesc();
}
}
@@ -0,0 +1,36 @@
package com.yunhai.notice.enums;
import lombok.Getter;
/**
* 通知状态。status=1 已发布仍是用户端唯一可见态;
* 先审后发引入 status=0 未发布(审核载体:绑流程通知提交后置 0,回调 completed 才置 1,
* 审核进度独立成列 audit_status)。未绑流程的通知仍无草稿态(直发即 1),
* 定时发送通过 timingSendTime + 查询侧「已到点」过滤控制可见性。
*/
@Getter
public enum NoticeStatusEnum {
UNPUBLISHED(0, "未发布"),
PUBLISHED(1, "已发布");
private final int code;
private final String desc;
NoticeStatusEnum(int code, String desc) {
this.code = code;
this.desc = desc;
}
public static NoticeStatusEnum of(Integer code) {
if (code == null) {
return PUBLISHED;
}
for (NoticeStatusEnum e : values()) {
if (e.code == code) {
return e;
}
}
return PUBLISHED;
}
}
@@ -0,0 +1,33 @@
package com.yunhai.notice.enums;
import lombok.Getter;
/**
* 通知类型。对应原 .NET Notify_Message.type(原系统通知类=2,此处归一化)。
*/
@Getter
public enum NoticeTypeEnum {
NORMAL(1, "普通通知"),
IMPORTANT(2, "重要通知");
private final int code;
private final String desc;
NoticeTypeEnum(int code, String desc) {
this.code = code;
this.desc = desc;
}
public static NoticeTypeEnum of(Integer code) {
if (code == null) {
return NORMAL;
}
for (NoticeTypeEnum e : values()) {
if (e.code == code) {
return e;
}
}
return NORMAL;
}
}
@@ -0,0 +1,59 @@
package com.yunhai.notice.job;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.notice.service.sms.SmsSendService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.PageRequest;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
/**
* 短信到点补发调度(§3.2 / BP-R5-2):扫"已发布 + 要发短信 + 从未发过 + 已到定时点"的行补发。
* 覆盖场景:定时发送的通知在定时点之前审批通过——发布链路按设计跳过短信(可见性即时生效),
* 原实现无任何补发导致短信永久丢失。幂等由 SmsSendService 的 sms_sent_at 原子认领保证
* (并发/重复调度不双发);单条失败不阻断批次(归还认领,下轮重试)。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SmsCatchupJob {
/** 单批上限(防御性:避免超大存量发布时一次性拉满) */
private static final int BATCH = 200;
private final NoticeRepository noticeRepository;
private final SmsSendService smsSendService;
@Value("${notice.sms.catchup-enabled:true}")
private boolean enabled;
/** fixedDelay=5min(上一轮跑完起算,单实例内天然不重叠;跨实例由原子认领闸) */
@Scheduled(fixedDelayString = "${notice.sms.catchup-fixed-delay-ms:300000}", initialDelay = 30_000)
public void catchupDueSms() {
if (!enabled) {
return;
}
List<Notice> due = noticeRepository.findSmsDue(LocalDateTime.now(), PageRequest.of(0, BATCH));
if (due.isEmpty()) {
return;
}
log.info("[notice-sms] 到点补发扫描:{} 条待发", due.size());
int sent = 0;
for (Notice notice : due) {
try {
if (smsSendService.sendOnce(notice)) {
sent++;
}
} catch (Exception e) {
log.warn("[notice-sms] 补发单条失败(下轮重试)notice={}:{}", notice.getId(), e.toString());
}
}
log.info("[notice-sms] 到点补发完成:本轮发送 {} 条", sent);
}
}
@@ -0,0 +1,14 @@
package com.yunhai.notice.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.notice.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.notice.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* 班级管理接口-教师(userclass {@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,80 @@
package com.yunhai.notice.model.flow;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.yunhai.platform.openapi.flow.model.FlowSummary;
import com.yunhai.platform.openapi.flow.model.FlowApplyBody;
import com.yunhai.platform.openapi.flow.model.FlowApplyResult;
import com.yunhai.platform.openapi.flow.model.FlowInstance;
import com.yunhai.platform.openapi.flow.model.FlowCallbackRegBody;
import com.yunhai.platform.openapi.flow.model.FlowCallbackRegResult;
import com.yunhai.platform.openapi.flow.model.FlowDiagramUrl;
import com.yunhai.platform.openapi.flow.model.FlowProgress;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 流程中心 /oapi/flow/** 契约模型。<br>
* 2026-09-16 起全部改为 {@code extends} SDK 门面 DTO({@code yh-openapi-client FlowDto},
* 与 flow-center OapiModels 逐字对齐,契约锚点:《流程中心设计概览方案.md》§8 / M1 开工说明 §5)
* ——字段单一事实源收敛到 SDK,本类仅保留 Notice 侧类型名(消费方签名零改动),
* 并可直接作为 {@code YschFlowOpenApi} 泛型出入参传递。
*/
public final class FlowModels {
private FlowModels() {
}
/** 流程摘要(GET /oapi/flow/list 元素) */
@Data
@EqualsAndHashCode(callSuper = true)
@JsonIgnoreProperties(ignoreUnknown = true)
public static class FlowSummaryVo extends FlowSummary {
}
/** 代发起请求体(POST /oapi/flow/apply) */
@Data
@EqualsAndHashCode(callSuper = true)
public static class ApplyBody extends FlowApplyBody {
}
/** 代发起响应(biz uk 幂等:重复 apply 返回既有办件 duplicated=true;30/40 态 R4 重启推进) */
@Data
@EqualsAndHashCode(callSuper = true)
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ApplyResultVo extends FlowApplyResult {
}
/** 按业务键查办件响应(GET /oapi/flow/instance;不存在为 null) */
@Data
@EqualsAndHashCode(callSuper = true)
@JsonIgnoreProperties(ignoreUnknown = true)
public static class InstanceVo extends FlowInstance {
}
/** 回调注册请求体(POST /oapi/flow/callback/reg) */
@Data
@EqualsAndHashCode(callSuper = true)
public static class CallbackRegBody extends FlowCallbackRegBody {
}
/** 回调注册响应(secret 注册时生成下发;重复注册会轮换 secret,绝不重复调用) */
@Data
@EqualsAndHashCode(callSuper = true)
@JsonIgnoreProperties(ignoreUnknown = true)
public static class CallbackRegResultVo extends FlowCallbackRegResult {
}
/** 办件进度(GET /oapi/flow/progress;steps[0] 固定"发起申请",2026-09-17 接入) */
@Data
@EqualsAndHashCode(callSuper = true)
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ProgressVo extends FlowProgress {
}
/** 流程示意图换图地址(GET /oapi/flow/diagram/url;imgUrl 为 flow-center 免签相对路径) */
@Data
@EqualsAndHashCode(callSuper = true)
@JsonIgnoreProperties(ignoreUnknown = true)
public static class DiagramUrlVo extends FlowDiagramUrl {
}
}
@@ -0,0 +1,20 @@
package com.yunhai.notice.model.notice;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* 一键已读结果视图对象(替代原 Map&lt;String,Object&gt; 返回)。
*/
@Data
public class InitializeRedVo {
@JsonProperty("count")
private Integer count;
public InitializeRedVo() {}
public InitializeRedVo(Integer count) {
this.count = count;
}
}
@@ -0,0 +1,91 @@
package com.yunhai.notice.model.notice;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
/**
* 通知详情视图对象(替代原 Map&lt;String,Object&gt; 返回)。
* 使用 @JsonProperty 保持旧字段名兼容前端渲染。
*/
@Data
public class NoticeDetailVo {
@JsonProperty("serid")
private Integer serid;
@JsonProperty("title")
private String title;
@JsonProperty("Contents")
private String contents;
@JsonProperty("DeptID")
private String deptId;
@JsonProperty("DeptName")
private String deptName;
@JsonProperty("TeaName")
private String publisherName;
@JsonProperty("EditDate")
private String editDate;
@JsonProperty("numcount")
private Integer numCount;
@JsonProperty("readnumcount")
private Long readNumCount;
@JsonProperty("ReceiveList")
private String receiveList;
@JsonProperty("DsfsTime")
private String dsfsTime;
@JsonProperty("isDsfs")
private Boolean isDsfs;
@JsonProperty("ChaoSend")
private List<FileItem> chaoSend;
/** 接收人列表:"all" 或账号列表 */
@JsonProperty("tlist")
private Object tlist;
// ===== 先审后发(编辑页驳回/撤回态展示) =====
/** 绑定的审核流程 GUID(null=直发未接流程) */
private String flowGuid;
/** 审核状态:0 待提交 / 10 审批中 / 20 通过 / 30 驳回 / 40 已撤回(null=未绑) */
private Integer auditStatus;
/** 审核状态文案 */
private String auditStatusDesc;
/** 流程中心办件 id(撤回/催办直调 flow-center 用) */
private Long flowInstanceId;
/** 终态明细(result/opinion/rejectNode;null=无) */
private Object flowResult;
/**
* 附件项(旧 ChaoSend 数组元素)。
*/
@Data
public static class FileItem {
@JsonProperty("id")
private Integer id;
@JsonProperty("name")
private String name;
@JsonProperty("size")
private Integer size;
@JsonProperty("fileid")
private Integer fileid;
@JsonProperty("filename")
private String filename;
}
}
@@ -0,0 +1,26 @@
package com.yunhai.notice.model.notice;
import com.yunhai.notice.model.BasePageResult;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/**
* 通知分页结果(扩展 BasePageResult,额外携带 unreadCount)。
* <p>
* 对齐方案 M7:unreadCount 并入分页外壳扩展子类,不改基类契约。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class NoticePageResult<T> extends BasePageResult<T> {
/** 当前用户未读通知总数 */
private long unreadCount;
public NoticePageResult(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,55 @@
package com.yunhai.notice.model.notice;
import com.yunhai.notice.model.BasePageQuery;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
/**
* 通知列表查询条件。
* <p>
* - 管理端(adminList):用 status / type / deptId / publisherAccount / 时间范围过滤;
* - 用户端(userList):额外用 readType 过滤未读/已读(0 全部 / 1 未读 / 2 已读)。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class NoticeQuery extends BasePageQuery {
/** 状态过滤:null 不过滤;1 已发布 */
private Integer status;
/**
* 审核状态过滤(管理端列表「状态」筛选,对应列 audit_status):
* 0 待提交 / 10 审批中 / 20 已通过 / 30 已驳回 / 40 已撤回;
* <b>-1 = 无需审核</b>(audit_status 为 NULL,未走审核轨道的直发通知);null 不过滤。
*/
private Integer auditStatus;
/** 类型过滤:1 普通 / 2 重要 */
private Integer type;
/** 列表范围(管理端):"user" 只看自己发布的,null 看全部 */
private String scope;
/** 发布部门ID */
private String deptId;
/** 发布人账号(管理端按发布人查) */
private String publisherAccount;
/** 开始时间(创建时间 >=,前端传 yyyy-MM-dd 短日期) */
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate startTime;
/** 结束时间(创建时间 <= 当天 23:59:59,前端传 yyyy-MM-dd 短日期) */
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate endTime;
/** 阅读状态过滤(仅用户端):0 全部 / 1 未读 / 2 已读 */
private Integer readType = 0;
/** 排序:1 时间倒序(默认)/ 2 时间正序 */
private String orderByTime = "1";
}
@@ -0,0 +1,56 @@
package com.yunhai.notice.model.notice;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import com.yunhai.notice.common.validation.ValidationGroups;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;
/**
* 通知发布/编辑入参。发布人由当前登录用户上下文填充,不在此入参中传递。
*/
@Data
public class NoticeRequest {
/** 通知标题 */
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "通知标题不得为空")
private String title;
/** 通知内容 */
private String content;
/** 通知类型:1 普通 / 2 重要 */
@NotNull(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "通知类型不得为空")
private Integer type = 1;
/** 发布部门ID(原 DeptID,必填——对应原系统「发布部门不得为空」校验) */
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "发布部门不得为空")
private String deptId;
/** 发布部门名称 */
private String deptName;
/** 是否置顶:0 否 / 1 是 */
private Integer topmost = 0;
/** 接收人列表(JSON 数组字符串,如 ["account1","account2"] 或 "ALL" 表示全员) */
private String receiveList;
/** 附件文件ID列表(逗号分隔串,如 "1,2,3");旧名 chaoSend,已更名 fileIds(M6) */
private String fileIds;
/** 定时发送时间(为空表示立即发布) */
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime timingSendTime;
/** 短信发送标记:0 不发短信 / 1 发短信(旧名 topmost 实指发短信,已正名 smsFlag,M6) */
private Integer smsFlag = 0;
/** 转发源通知ID(非空时表示转发创建,对应旧 REST 壳 forward/{sourceId}) */
private Integer sourceId;
// 审核流程不再由请求传入:绑定在「应用设置 > 流程绑定」(notice_flow_binding,按学校一条),
// 由 NoticeService 按「已绑定 + 通知对象=全部用户」判定,避免前端/请求绕过审核规则。
}
@@ -0,0 +1,54 @@
package com.yunhai.notice.model.notice;
import lombok.Data;
import java.util.List;
/**
* 通知统计视图(对应原系统 GetStatisData / 按发布人统计)。
*/
@Data
public class NoticeStatisticsVo {
/** 通知总数(全部已发布) */
private long total;
/** 已发布数 */
private long publishedCount;
/** 阅读记录总数(累计已读人次) */
private long totalReadRecords;
/** 当前用户未读数(已到点的已发布 - 当前用户已读) */
private long unreadForCurrentUser;
/** 按发布人统计 */
private List<PublisherStat> byPublisher;
/** 按月统计(已发布通知数) */
private List<MonthStat> byMonth;
@Data
public static class PublisherStat {
private String publisherName;
private long count;
public PublisherStat(String publisherName, long count) {
this.publisherName = publisherName;
this.count = count;
}
}
@Data
public static class MonthStat {
private int year;
private int month;
private long count;
public MonthStat(int year, int month, long count) {
this.year = year;
this.month = month;
this.count = count;
}
}
}
@@ -0,0 +1,34 @@
package com.yunhai.notice.model.notice;
import lombok.Data;
import java.util.List;
/**
* 用户端通知列表结果:在分页数据之外额外携带「当前用户未读总数」,
* 供前端展示未读角标(对应原系统 OtherData.unReadCount)。
*/
@Data
public class NoticeUserListResult {
private int total;
private List<NoticeVo> content;
private int pageIndex;
private int pageSize;
private int totalPages;
private boolean hasNext;
/** 当前用户未读通知总数 */
private long unreadCount;
public NoticeUserListResult(List<NoticeVo> content, int total, int pageIndex, int pageSize,
int totalPages, boolean hasNext, long unreadCount) {
this.content = content;
this.total = total;
this.pageIndex = pageIndex;
this.pageSize = pageSize;
this.totalPages = totalPages;
this.hasNext = hasNext;
this.unreadCount = unreadCount;
}
}
@@ -0,0 +1,82 @@
package com.yunhai.notice.model.notice;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 通知视图对象(列表/详情返回)。
*/
@Data
public class NoticeVo {
private Integer id;
private String title;
private String content;
private Integer type;
private String typeDesc;
private String publisherAccount;
private String publisherName;
private String deptId;
private String deptName;
private Integer status;
private String statusDesc;
private Integer topmost;
/** 已读人数(基于阅读记录实时统计) */
private Long readCount = 0L;
/** 应读人数 */
private Integer allowCount;
/** 当前用户是否已读(用户端列表使用;管理端恒为 false 或不关注) */
private boolean read = false;
/** 是否撤回:0 否 / 1 是 */
private Integer isPulled;
/** 定时发送时间 */
private LocalDateTime timingSendTime;
/** 父通知ID(转发时关联原通知) */
private Integer parentId;
/** 短信发送标记 */
private Integer smsFlag;
/** 接收人列表(JSON) */
private String receiveList;
/** 附件ID串("1,2,3",列表页下载入口使用;对齐旧 .NET UpFile 字段) */
private String UpFile;
// ===== 先审后发(流程中心托管) =====
/** 绑定的审核流程 GUID(null=直发,未接流程中心) */
private String flowGuid;
/** 审核状态:0 待提交 / 10 审批中 / 20 通过 / 30 驳回 / 40 已撤回(null=未绑流程) */
private Integer auditStatus;
/** 审核状态文案(待提交/审批中/已通过/已驳回/已撤回;null=未绑流程) */
private String auditStatusDesc;
/** 流程中心办件 id(提交成功后非空;详情页撤回/催办直调 flow-center 用) */
private Long flowInstanceId;
private String createBy;
private LocalDateTime createTime;
private String updateBy;
private LocalDateTime updateTime;
/**
* 阅读明细视图(阅读列表查询用)。
*/
@Data
public static class ReadDetail {
private String account;
private String name;
private boolean read;
private LocalDateTime readTime;
}
}
@@ -0,0 +1,30 @@
package com.yunhai.notice.model.notice;
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.notice.model.notice;
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.notice.model.notice;
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,27 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.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);
/**
* 按创建人账号查询联系组列表。
*/
List<ContactsGroup> findByCreatorAccount(String creatorAccount);
/**
* 按创建人账号查询联系组列表(分页)。
*/
Page<ContactsGroup> findByCreatorAccount(String creatorAccount, Pageable pageable);
/**
* 查询全部联系组(按创建时间降序)。
*/
List<ContactsGroup> findAllByOrderByCreateTimeDesc();
}
@@ -0,0 +1,42 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.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,19 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.NoticeCc;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* 流程抄送留痕仓储(「抄送给我的」主路径)。
*/
public interface NoticeCcRepository extends JpaRepository<NoticeCc, Integer> {
/** 按学校 + 被抄送人分页(sent_time 倒序,倒序索引 idx_notice_cc_user 覆盖) */
Page<NoticeCc> findBySchIdAndCcUserIdOrderBySentTimeDescIdDesc(Integer schId, Integer ccUserId,
Pageable pageable);
/** 未发布通知可见守卫第 4 条:当前用户是否被抄送该通知(NoticeFlowGuard) */
boolean existsByNoticeIdAndCcUserId(Integer noticeId, Integer ccUserId);
}
@@ -0,0 +1,11 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.NoticeFlowBinding;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface NoticeFlowBindingRepository extends JpaRepository<NoticeFlowBinding, Integer> {
Optional<NoticeFlowBinding> findBySchId(Integer schId);
}
@@ -0,0 +1,9 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.NoticeFlowEvent;
import org.springframework.data.jpa.repository.JpaRepository;
public interface NoticeFlowEventRepository extends JpaRepository<NoticeFlowEvent, Integer> {
boolean existsByEventId(String eventId);
}
@@ -0,0 +1,16 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.NoticeFlowReg;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface NoticeFlowRegRepository extends JpaRepository<NoticeFlowReg, Integer> {
Optional<NoticeFlowReg> findBySchIdAndAppKeyAndFlowGuid(Integer schId, String appKey, String flowGuid);
Optional<NoticeFlowReg> findBySchIdAndFlowGuid(Integer schId, String flowGuid);
List<NoticeFlowReg> findBySchIdAndStatusOrderByUpdateTimeDesc(Integer schId, Integer status);
}
@@ -0,0 +1,52 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.NoticeRead;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
/**
* 通知阅读记录仓储(懒标记模型)。
*/
public interface NoticeReadRepository extends JpaRepository<NoticeRead, Integer> {
/**
* 某用户是否已读某通知。
*/
boolean existsByNoticeIdAndReaderAccount(Integer noticeId, String readerAccount);
/**
* 某通知的总已读人数。
*/
long countByNoticeId(Integer noticeId);
/**
* 删除某通知的全部阅读记录(通知删除时级联清理)。
*/
void deleteByNoticeId(Integer noticeId);
/**
* 某用户已读的通知总数(用于「我的未读」= 已发布总数 - 已读总数)。
*/
long countByReaderAccount(String readerAccount);
/**
* 查询某用户已读的通知ID列表(用于列表页批量判定未读)。
*/
@Query("select r.noticeId from NoticeRead r where r.readerAccount = :account")
List<Integer> findNoticeIdsByReaderAccount(@Param("account") String account);
/**
* 查询某用户对某通知的阅读记录(标记已读时幂等判断)。
*/
Optional<NoticeRead> findByNoticeIdAndReaderAccount(Integer noticeId, String readerAccount);
/**
* 查询某通知的全部阅读记录(按阅读时间倒序),用于阅读明细列表。
*/
@Query("select r from NoticeRead r where r.noticeId = :noticeId order by r.readTime desc")
List<NoticeRead> findByNoticeIdOrderByReadTimeDesc(@Param("noticeId") Integer noticeId);
}
@@ -0,0 +1,31 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.NoticeReceiver;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
/**
* 通知接收人关联表仓储。
*/
public interface NoticeReceiverRepository extends JpaRepository<NoticeReceiver, Integer> {
/** 按通知ID查询全部接收人 */
List<NoticeReceiver> findByNoticeId(Integer noticeId);
/** 按通知ID删除全部接收人 */
void deleteByNoticeId(Integer noticeId);
/** 按通知ID和接收类型查询接收人 */
List<NoticeReceiver> findByNoticeIdAndReceiveType(Integer noticeId, Integer receiveType);
/**
* 批量查 N 个通知的接收人,用于列表展示(避免 N+1)。
* @param noticeIds 通知ID列表
* @return 接收人关联记录列表
*/
@Query("select r from NoticeReceiver r where r.noticeId in :noticeIds")
List<NoticeReceiver> findByNoticeIds(@Param("noticeIds") List<Integer> noticeIds);
}
@@ -0,0 +1,135 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.Notice;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
/**
* 通知公告仓储。
*/
public interface NoticeRepository extends JpaRepository<Notice, Integer>,
JpaSpecificationExecutor<Notice> {
/**
* 按状态计数(用于统计已发布总数)。
*/
long countByStatus(Integer status);
/** 回调事件处理专用:行级悲观锁读(并发推送串行化,防整实体保存互相覆盖丢更新) */
@org.springframework.data.jpa.repository.Lock(javax.persistence.LockModeType.PESSIMISTIC_WRITE)
@org.springframework.data.jpa.repository.Query("select n from Notice n where n.id = :id")
Optional<Notice> findByIdForUpdate(@org.springframework.data.repository.query.Param("id") Integer id);
/**
* 提交审核后的原子回填(窄列更新,防旧快照整实体覆盖并发回调的推进):
* 置 flow_instance_id + audit=10;若回调已并发先行落定 audit=20(审核通过)则保持 20 不回退。
*/
@org.springframework.transaction.annotation.Transactional
@org.springframework.data.jpa.repository.Modifying
@org.springframework.data.jpa.repository.Query(
"update Notice n set n.flowInstanceId = :instanceId, "
+ "n.auditStatus = (case when n.auditStatus = 20 then n.auditStatus else 10 end) "
+ "where n.id = :id")
int markApproving(@org.springframework.data.repository.query.Param("id") Integer id,
@org.springframework.data.repository.query.Param("instanceId") Long instanceId);
/**
* 短信发送原子认领(§3.2 防重复核心):置 sms_sent_at,仅当当前为 NULL(未发)时生效。
* 返回 0=已被发送过(并发/重复调度双发的闸门)。发送失败由调用方 clearSmsSent 归还。
*/
@org.springframework.transaction.annotation.Transactional
@org.springframework.data.jpa.repository.Modifying
@org.springframework.data.jpa.repository.Query(
"update Notice n set n.smsSentAt = :sentAt where n.id = :id and n.smsSentAt is null")
int markSmsSent(@Param("id") Integer id, @Param("sentAt") LocalDateTime sentAt);
/** 发送失败归还认领(下轮补发调度可重试) */
@org.springframework.transaction.annotation.Transactional
@org.springframework.data.jpa.repository.Modifying
@org.springframework.data.jpa.repository.Query(
"update Notice n set n.smsSentAt = null where n.id = :id and n.smsSentAt is not null")
int clearSmsSent(@Param("id") Integer id);
/**
* 到点待补发短信清单(§3.2 SmsCatchupJob 扫描):已发布 + 要发短信 + 从未发过 + 已到定时点
* (无定时点即发;覆盖"审批通过早于定时点被跳过"的行——BP-R5-2 短信永久丢失缺口)。
*/
@Query("select n from Notice n where n.status = 1 and n.smsFlag = 1 and n.smsSentAt is null"
+ " and (n.timingSendTime is null or n.timingSendTime <= :now) order by n.id asc")
List<Notice> findSmsDue(@Param("now") LocalDateTime now,
org.springframework.data.domain.Pageable pageable);
/**
* 已发布且已到点(定时发送未到点不计入)的通知数,用于用户端未读数统计。
*/
@Query("select count(n) from Notice n where n.status = :status " +
"and (n.timingSendTime is null or n.timingSendTime <= :now)")
long countPublishedDue(@Param("status") Integer status,
@Param("now") LocalDateTime now);
/**
* 查询指定状态、指定时间范围内的通知创建时间(用于按月统计,避免加载大字段)。
*/
@Query("select n.createTime from Notice n " +
"where n.status = :status and n.createTime >= :start and n.createTime <= :end")
List<LocalDateTime> findCreateTimesByStatusAndRange(@Param("status") Integer status,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/**
* 查询指定状态、指定时间范围内的发布人姓名(用于按发布人统计)。
*/
@Query("select n.publisherName from Notice n " +
"where n.status = :status and n.createTime >= :start and n.createTime <= :end")
List<String> findPublisherNamesByStatusAndRange(@Param("status") Integer status,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/**
* 按学校和状态计数(多租户隔离统计)。
*/
long countBySchIdAndStatus(Integer schId, Integer status);
/**
* 按学校计数(多租户隔离统计:全部状态)。
*/
long countBySchId(Integer schId);
/**
* 已发布且已到点(定时发送未到点不计入)且属于指定学校的通知数(用户端未读数统计,多租户隔离)。
*/
@Query("select count(n) from Notice n where n.schId = :schId and n.status = :status " +
"and (n.timingSendTime is null or n.timingSendTime <= :now)")
long countPublishedDueBySchId(@Param("schId") Integer schId,
@Param("status") Integer status,
@Param("now") LocalDateTime now);
/**
* 查询指定学校、指定状态、指定时间范围内的通知创建时间(按月统计,多租户隔离)。
*/
@Query("select n.createTime from Notice n " +
"where n.schId = :schId and n.status = :status " +
"and n.createTime >= :start and n.createTime <= :end")
List<LocalDateTime> findCreateTimesBySchIdStatusAndRange(@Param("schId") Integer schId,
@Param("status") Integer status,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/**
* 查询指定学校、指定状态、指定时间范围内的发布人姓名(按发布人统计,多租户隔离)。
*/
@Query("select n.publisherName from Notice n " +
"where n.schId = :schId and n.status = :status " +
"and n.createTime >= :start and n.createTime <= :end")
List<String> findPublisherNamesBySchStatusAndRange(@Param("schId") Integer schId,
@Param("status") Integer status,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
}
@@ -0,0 +1,20 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.NoticeTemplate;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface NoticeTemplateRepository extends JpaRepository<NoticeTemplate, Integer> {
Page<NoticeTemplate> findByTypeId(Integer typeId, Pageable pageable);
/**
* 查询全部模板(按引用次数降序),分页。
*/
Page<NoticeTemplate> findAllByOrderByRefCountDesc(Pageable pageable);
/**
* 按类型ID查询全部模板(不分页,用于检查类型是否被引用)。
*/
long countByTypeId(Integer typeId);
}
@@ -0,0 +1,10 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.SmsModule;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface SmsModuleRepository extends JpaRepository<SmsModule, Integer> {
List<SmsModule> findAllByOrderByIdAsc();
}
@@ -0,0 +1,25 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.SmsSchoolConfig;
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 SmsSchoolConfigRepository extends JpaRepository<SmsSchoolConfig, Integer> {
Page<SmsSchoolConfig> findBySchoolNameContaining(String schoolName, Pageable pageable);
Page<SmsSchoolConfig> findAll(Pageable pageable);
List<SmsSchoolConfig> findBySchoolId(Integer schoolId);
List<SmsSchoolConfig> findBySchoolIdInAndModuleIdIn(List<Integer> schoolIds, List<Integer> moduleIds);
/**
* 按学校ID和模块ID查询单条配置(判断是否已存在)。
*/
SmsSchoolConfig findBySchoolIdAndModuleId(Integer schoolId, Integer moduleId);
/**
* 按学校ID查询全部模块配置(用于"设置某校全部模块")。
*/
List<SmsSchoolConfig> findBySchoolIdIn(List<Integer> schoolIds);
}
@@ -0,0 +1,15 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.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,20 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.TemplateType;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TemplateTypeRepository extends JpaRepository<TemplateType, Integer> {
List<TemplateType> findAllByOrderBySortOrderAsc();
/**
* 按类型名称查询(重名校验)。
*/
boolean existsByTypeName(String typeName);
/**
* 按类型名称查询(重名校验,排除自身)。
*/
boolean existsByTypeNameAndIdNot(String typeName, Integer id);
}
@@ -0,0 +1,26 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.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,20 @@
package com.yunhai.notice.service;
import com.yunhai.notice.model.SchTeaList;
import java.util.List;
/**
* 班级管理(userclass)开放接口客户端。
* 2026-09-16 起委托 SDK 类型化门面 {@code YschClassOpenApi}(服务目录 userclass),当前仅提供教师查询。
*/
public interface IClassService {
/**
* 获取指定学校全部教师(含所属组 GroupIds)。
* 对应 userclass 开放接口:GET {@code {userclass}/oapi/DbClass/GetSchteaList?Group={schId}}
*
* @param schId 学校ID(即用户当前身份的 schId)
*/
List<SchTeaList> getSchTeaList(int schId);
}
@@ -0,0 +1,79 @@
package com.yunhai.notice.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,74 @@
package com.yunhai.notice.service;
import java.util.List;
import java.util.Map;
/**
* 系统字典业务接口。
* 对应前端 Base 组接口:GetSysDic/GetDictMangeList/AddDeptDic/UpdateDeptDic/DelDept/OrdbyDicIds/GetDeptSync/GetSchModuleIsSms。
*/
public interface IDictService {
/**
* 获取系统字典(下拉框用)。
* C# GetSysDic: Type=1 部门类别, 其余类型略。
*
* @param type 字典类型编号(1=部门类别)
* @return List&lt;Map&gt; 每项 {k, v}
*/
List<Map<String, Object>> getSysDic(Integer type);
/**
* 获取系统字典管理列表(含 SerID/CodeID/Name/Des/Enable)。
*
* @param type 字典类型编号(0=部门类别)
* @return List&lt;Map&gt;
*/
List<Map<String, Object>> getDictMangeList(String type);
/**
* 添加发文单位。
*
* @param name 单位名称
*/
void addDeptDic(String name);
/**
* 更新发文单位。
*
* @param serID 记录ID
* @param name 名称
* @param enable 启用状态
*/
void updateDeptDic(Integer serID, String name, Integer enable);
/**
* 删除发文单位(校验是否被通知引用)。
*
* @param serID 记录ID
*/
void delDept(Integer serID);
/**
* 发文单位排序。
*
* @param ids 逗号分隔的 SerID 列表(按前端顺序)
*/
void ordbyDicIds(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.notice.service;
import com.yunhai.notice.entity.UpFile;
import com.yunhai.notice.model.notice.UpFileVo;
import com.yunhai.notice.model.notice.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,142 @@
package com.yunhai.notice.service;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.model.BasePageResult;
import com.yunhai.notice.model.notice.*;
import org.springframework.data.domain.Pageable;
import javax.servlet.http.HttpServletResponse;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 通知公告业务接口。
*/
public interface INoticeService {
/**
* 发通知:以当前登录用户为发布人,状态置为「已发布」入库。
* 若 request.timingSendTime 不为空且晚于当前时间,则作为定时发送,
* 未到点前用户端收件箱不可见(到点后由查询过滤自然可见)。
*
* @return 新建通知 ID(用于后续回填附件 TopicID 等)
*/
Integer publish(NoticeRequest request);
/**
* 编辑通知:按 id 更新标题/内容/类型/部门/置顶,发布人与状态保持不变。
*/
void edit(Integer id, NoticeRequest request);
/**
* 批量删除通知:删除主记录并级联清理阅读记录、接收人、附件关联。
*/
void deleteBatch(List<Integer> ids);
/**
* 撤回通知:仅已发布且未撤回的通知可撤回,置 isPulled=1。
*/
void pull(Integer id);
/**
* 置顶/取消置顶。
*/
void setTopmost(Integer id, Integer topmost);
/**
* 转发通知:基于源通知创建新通知(已发布),parentId 关联源通知。
*/
Integer forward(Integer sourceId, NoticeRequest request);
/**
* 用户端列表:仅展示已发布且已到点的通知,并标记当前用户是否已读,附未读总数。
* 返回 NoticePageResult(扩展 unreadCount)。
*/
NoticePageResult<NoticeVo> userList(NoticeQuery query, Pageable pageable);
/**
* 管理端列表:按条件查询全部通知(管理员查看全部)。
*/
BasePageResult<NoticeVo> adminList(NoticeQuery query, Pageable pageable);
/**
* 通知详情:返回视图对象,并顺带为当前用户标记已读(打开即视为已读)。
*/
NoticeVo detail(Integer id);
/**
* 编辑回显详情(不标记已读)。
*/
NoticeVo getDetail(Integer id);
/**
* 标记已读:为当前用户写入(幂等)该通知的阅读记录。
*/
void markRead(Integer id);
/**
* 一键全部已读(批量化):当前用户所有未读的已发布通知批量插入阅读记录。
* @return 已读条数
*/
int initializeRed();
/**
* 追加接收人(事务化):并入接收人列表并重算 allowcount。
*/
void receiveUsers(Integer noticeId, String users);
/**
* 上一篇/下一篇通知ID(SQL 相邻查询)。
* @param serid 当前通知ID
* @param type 0 已发(当前用户发布的序列)/ 1 已收(阅读记录序列)
* @return {prevId, nextId}
*/
Map<String, Object> getNextPrev(Integer serid, String type);
/**
* 获取某通知的阅读明细列表。
*/
List<NoticeVo.ReadDetail> getReadDetail(Integer noticeId);
/**
* 阅读情况分组(已读/未读)。
*/
List<Map<String, Object>> getReadDetailGrouped(Integer serid);
/**
* 催收未读:枚举未读接收人,后续接入消息推送。
*/
void expedite(Integer serid);
/**
* 通知统计:总数/已发布、累计阅读人次、当前用户未读、按发布人、按月。
*/
NoticeStatisticsVo statistics(NoticeQuery query, LocalDateTime start, LocalDateTime end);
/**
* 通知详情视图(含附件、接收人信息),供 Controller 返回。
*/
NoticeDetailVo getDetailMap(Integer serid);
/**
* 已发列表附件批量查询(消除 N+1)。
*/
Map<Integer, List<Integer>> batchFileIds(List<Integer> noticeIds);
/**
* 查看通知详情视图(含附件、接收人)。
* type=1 时先标记已读再返回,type=0 时直接返回不标记。
*/
NoticeDetailVo detailMap(Integer serid, Integer type);
/**
* 一键全部已读并返回结果(count = 已读条数)。
*/
InitializeRedVo initializeRedResult();
/**
* 批量下载通知全部附件(打包 zip):查通知标题后调文件域打包下载。
*/
void downloadAllFiles(Integer dataid, HttpServletResponse response);
}
@@ -0,0 +1,77 @@
package com.yunhai.notice.service;
import java.util.List;
import java.util.Map;
/**
* 短信设置业务接口。
* 对应前端 setSms 组接口:getmodule/getgroup/getschsms/getsch/setschsms/setschallsms/setschmodulesms。
*/
public interface ISmsService {
/**
* 获取短信模块列表。
* C# 从 AuthConfig.AppKeyMap 读取。Java版从 sms_module 表读取。
*
* @return List&lt;Map&gt; 每项 {ID, Name}
*/
List<Map<String, Object>> getModuleList();
/**
* 获取学校父类组列表。
* C# 从外部BLL获取学校组。Java版预留。
*
* @return List&lt;Map&gt; 每项 {ID, Name}
*/
List<Map<String, Object>> getSchParentGroup();
/**
* 分页获取学校短信开关列表。
*
* @param pageSize 每页条数
* @param pageIndex 页码
* @param schName 学校名称(模糊搜索)
* @return Map: { list, total }
*/
Map<String, Object> getSchIsSmsList(Integer pageSize, Integer pageIndex, String schName);
/**
* 获取学校列表。
* C# 从外部BLL获取学校列表。Java版预留。
*
* @return List&lt;Map&gt; 每项 {ID, Name}
*/
List<Map<String, Object>> getSchList();
/**
* 设置单校单模块短信开关。
*
* @param schSerID 学校ID
* @param moduleId 模块ID
* @param isSms 是否开通
*/
void setSchSms(Integer schSerID, Integer moduleId, Boolean isSms);
/**
* 设置某校全部模块短信开关。
*
* @param schSerID 学校ID
* @param isSms 是否开通
*/
void setSchSmsAll(Integer schSerID, Boolean isSms);
/**
* 批量设置多校多模块短信开关。
*
* @param schSerIDs 逗号分隔的学校ID
* @param modules 逗号分隔的模块ID
* @param isSms 是否开通
*/
void setSchModuleSms(String schSerIDs, String modules, Boolean isSms);
/**
* 从 JSON 字符串解析并设置单校单模块短信开关。
* 解析 SchSerID/ModuID/IsSms 三个字段。
*/
void setSchSmsFromJson(String itemsModuJson);
}
@@ -0,0 +1,69 @@
package com.yunhai.notice.service;
import java.util.List;
import java.util.Map;
/**
* 通知模板业务接口。
* 对应前端 Temp 组接口:GetTypelist/EditType/DelType/GetTemplatelist/EditTemplate/DelTemplate/GetTemplateFist。
*/
public interface ITemplateService {
/**
* 获取模板类型列表(首项固定为"全部")。
*
* @return List&lt;Map&gt; 每项 {TypeID, TypeName}
*/
List<Map<String, Object>> getTypelist();
/**
* 新增/编辑模板类型。
*
* @param typeid 类型ID(0=新增)
* @param typeName 类型名称
*/
void editType(Integer typeid, String typeName);
/**
* 删除模板类型(校验是否有模板引用此类型)。
*
* @param typeid 类型ID
*/
void delType(Integer typeid);
/**
* 分页获取模板列表。
*
* @param pageSize 每页条数
* @param pageIndex 页码
* @param typeid 类型ID(0=全部)
* @return Map: { list, total }
*/
Map<String, Object> getTemplatelist(Integer pageSize, Integer pageIndex, Integer typeid);
/**
* 新增/编辑模板。
*
* @param serID 模板ID(null=新增)
* @param title 标题
* @param content 内容
* @param typeId 类型ID
* @param cover 封面文件ID
*/
void editTemplate(Integer serID, String title, String content, Integer typeId, Integer cover);
/**
* 删除模板。
*
* @param serID 模板ID
*/
void delTemplate(Integer serID);
/**
* 获取单个模板详情。
*
* @param serID 模板ID
* @return Map 模板详情
*/
Map<String, Object> getTemplateFist(Integer serID);
}
@@ -0,0 +1,51 @@
package com.yunhai.notice.service;
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>userclass(班级管理)获取该校全部教师(/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>从基础平台拉取当前用户模块树(含学校上下文);<b>层级由用户中心 {@code BoaModule.children} 下发</b>,
* 本侧仅映射为前端 layout 期望的字段形状({@code modId/page/children}),对应前端 GetUserMod 接口。</p>
*
* @param userId 用户ID
* @param schId 学校ID(可为 null)
* @return 模块树(顶层 List,含 children 嵌套)
*/
List<Map<String, Object>> getUserModuleTree(Integer userId, Integer schId);
}
@@ -0,0 +1,249 @@
package com.yunhai.notice.service.flow;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.notice.model.flow.FlowModels;
import com.yunhai.platform.appauth.AppSignInterceptor;
import com.yunhai.platform.appauth.LocalAppIdentityProvider;
import com.yunhai.platform.openapi.core.TargetSpec;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* FlowClient 默认实现:主路径走 SDK 类型化门面 {@link YschFlowOpenApi}(服务目录 key=
*{@code flow-center}:yh.call.targets &gt; yh.open.services &gt; 枚举 base-host 兜底,
* 《子应用配置优化方案》§4.2 零专属地址配置);目录未显式配置时回落本应用 legacy 键
* {@code notice.flow.base-url}(ad-hoc TargetSpec,迁移期兼容)。签名/信封解析/错误映射
* 全部由 SDK 统一承载(401/403→DException、code!=0→DException、传输错→OpenApiException)。
* {@code notice.flow.enabled=false} 时全部方法抛业务异常(文案含"流程中心未接入")供前端降级。
*/
@Slf4j
@Service
public class FlowCenterClient implements FlowClient {
/** flow-center 业务类型常量(契约冻结:bizType="notice"、bizId=notice.id) */
public static final String BIZ_TYPE = "notice";
private static final String NOT_CONNECTED = "流程中心未接入,该功能不可用";
private final NoticeFlowProperties properties;
private final YschOpenCall openCall;
private final boolean directoryResolved;
public FlowCenterClient(NoticeFlowProperties properties,
ObjectProvider<YschOpenCall> openCallProvider,
ObjectProvider<LocalAppIdentityProvider> identityProvider,
ObjectMapper objectMapper) {
this.properties = properties;
YschOpenCall call = openCallProvider.getIfAvailable();
if (call == null) {
// 兜底自建(正常路径由 yh-openapi-starter 装配 YschOpenCall bean);identity 缺失时
// AppSignInterceptor 自动跳过签名(请求裸发,enforce 端会 401——配置问题尽早暴露)
call = new YschOpenCall(new com.yunhai.platform.openapi.core.YschCallProperties(), objectMapper,
(host, basePath) -> java.util.Collections.singletonList(
(org.springframework.http.client.ClientHttpRequestInterceptor)
new AppSignInterceptor(identityProvider.getIfAvailable(), host, basePath)));
}
this.openCall = call;
// 服务目录显式配置(targets/services)则走门面命名目标;否则回落 legacy base-url ad-hoc
this.directoryResolved = call.hasTarget(com.yunhai.platform.openapi.core.PlatformService.FLOW_CENTER.key());
if (directoryResolved) {
com.yunhai.platform.openapi.flow.YschFlowOpenApi.setCall(call);
}
String legacy = properties.getBaseUrl() == null ? "" : properties.getBaseUrl().trim();
if (properties.isEnabled() && !directoryResolved && legacy.isEmpty()) {
// 目录未配且 legacy 键也空:构造期 fail-fast(本键已无默认值),
// 避免静默打 localhost 把"地址没配"伪装成"流程中心不可达"。
// enabled=false(显式未接入)时不校验——地址本就不需要。
throw new IllegalStateException("流程中心调用地址未配置:请配置服务目录 yh.open.services."
+ com.yunhai.platform.openapi.core.PlatformService.FLOW_CENTER.key()
+ "(推荐,容器直连完整 URL)或回落键 notice.flow.base-url");
}
}
@Override
public List<FlowModels.FlowSummaryVo> listFlows(Integer schId) {
requireEnabled();
if (directoryResolved) {
return com.yunhai.platform.openapi.flow.YschFlowOpenApi.listFlows(
schId, FlowModels.FlowSummaryVo.class);
}
return openCall.getList(target(), "/oapi/flow/list", params("schId", schId),
FlowModels.FlowSummaryVo.class);
}
@Override
public FlowModels.ApplyResultVo apply(Integer schId, FlowModels.ApplyBody body) {
requireEnabled();
if (directoryResolved) {
return com.yunhai.platform.openapi.flow.YschFlowOpenApi.apply(
schId, body, FlowModels.ApplyResultVo.class);
}
return openCall.post(target(), "/oapi/flow/apply", params("schId", schId), body,
FlowModels.ApplyResultVo.class);
}
@Override
public FlowModels.InstanceVo getInstanceByBiz(Integer schId, String bizType, String bizId) {
requireEnabled();
if (directoryResolved) {
return com.yunhai.platform.openapi.flow.YschFlowOpenApi.instance(
bizType, bizId, schId, FlowModels.InstanceVo.class);
}
Map<String, String> query = new HashMap<>();
query.put("schId", String.valueOf(schId));
query.put("bizType", bizType);
query.put("bizId", bizId);
return openCall.get(target(), "/oapi/flow/instance", query, FlowModels.InstanceVo.class);
}
@Override
public FlowModels.CallbackRegResultVo registerCallback(Integer schId, FlowModels.CallbackRegBody body) {
requireEnabled();
if (directoryResolved) {
return com.yunhai.platform.openapi.flow.YschFlowOpenApi.registerCallback(
schId, body, FlowModels.CallbackRegResultVo.class);
}
return openCall.post(target(), "/oapi/flow/callback/reg", params("schId", schId), body,
FlowModels.CallbackRegResultVo.class);
}
@Override
public void withdraw(Integer schId, Long instanceId, Integer submitterId) {
requireEnabled();
if (directoryResolved) {
com.yunhai.platform.openapi.flow.YschFlowOpenApi.withdraw(schId, instanceId, submitterId);
return;
}
Map<String, String> query = new HashMap<>();
query.put("schId", String.valueOf(schId));
query.put("submitterId", String.valueOf(submitterId));
// 无请求体:对端 POST 无 @RequestBody,query 传参即可(body 传 null 即零长度请求体)
openCall.post(target(), "/oapi/flow/instance/" + instanceId + "/withdraw", query, null, Void.class);
}
@Override
public FlowModels.ProgressVo progress(Integer schId, Long instanceId) {
requireEnabled();
if (directoryResolved) {
return com.yunhai.platform.openapi.flow.YschFlowOpenApi.progress(
instanceId, BIZ_TYPE, null, schId, FlowModels.ProgressVo.class);
}
Map<String, String> query = new HashMap<>();
query.put("instanceId", String.valueOf(instanceId));
query.put("schId", String.valueOf(schId));
return openCall.get(target(), "/oapi/flow/progress", query, FlowModels.ProgressVo.class);
}
/** 免签出图端点(flow-center OpenDiagramImageController,相对其 context-path) */
private static final String DIAGRAM_IMG_PATH = "/open/flow/diagram/img";
@Override
public byte[] diagramImage(Integer schId, Long instanceId) {
requireEnabled();
String imgUrl;
if (directoryResolved) {
FlowModels.DiagramUrlVo dto = com.yunhai.platform.openapi.flow.YschFlowOpenApi.diagramUrl(
instanceId, null, null, schId, FlowModels.DiagramUrlVo.class);
imgUrl = dto == null ? null : dto.getImgUrl();
} else {
Map<String, String> query = new HashMap<>();
query.put("instanceId", String.valueOf(instanceId));
query.put("schId", String.valueOf(schId));
FlowModels.DiagramUrlVo dto =
openCall.get(target(), "/oapi/flow/diagram/url", query, FlowModels.DiagramUrlVo.class);
imgUrl = dto == null ? null : dto.getImgUrl();
}
String token = tokenOf(imgUrl);
if (token == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程中心未返回有效的示意图地址");
}
Map<String, String> imgQuery = new HashMap<>();
imgQuery.put("token", token);
byte[] bytes = directoryResolved
? com.yunhai.platform.openapi.flow.YschFlowOpenApi.raw().download(DIAGRAM_IMG_PATH, imgQuery)
: openCall.download(target(), DIAGRAM_IMG_PATH, imgQuery);
if (bytes == null || bytes.length == 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程示意图为空(流程可能未发布节点)");
}
return bytes;
}
/**
* 从换图地址中取 token(形如 {@code /open/flow/diagram/img?token=xxx})。
* 包可见:契约测试冻结"token 只在后端流转、前端拿不到"的取用形态。
*/
static String tokenOf(String imgUrl) {
if (imgUrl == null || imgUrl.isEmpty()) {
return null;
}
int q = imgUrl.indexOf("?token=");
if (q < 0) {
return null;
}
String token = imgUrl.substring(q + "?token=".length());
int amp = token.indexOf('&');
if (amp >= 0) {
token = token.substring(0, amp);
}
return token.isEmpty() ? null : token;
}
@Override
public byte[] diagramImageByFlowGuid(Integer schId, String flowGuid) {
requireEnabled();
String imgUrl;
if (directoryResolved) {
FlowModels.DiagramUrlVo dto = com.yunhai.platform.openapi.flow.YschFlowOpenApi.diagramUrl(
null, flowGuid, null, schId, FlowModels.DiagramUrlVo.class);
imgUrl = dto == null ? null : dto.getImgUrl();
} else {
Map<String, String> query = new HashMap<>();
query.put("flowGuid", flowGuid);
query.put("schId", String.valueOf(schId));
FlowModels.DiagramUrlVo dto =
openCall.get(target(), "/oapi/flow/diagram/url", query, FlowModels.DiagramUrlVo.class);
imgUrl = dto == null ? null : dto.getImgUrl();
}
String token = tokenOf(imgUrl);
if (token == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程中心未返回有效的示意图地址");
}
Map<String, String> imgQuery = new HashMap<>();
imgQuery.put("token", token);
byte[] bytes = directoryResolved
? com.yunhai.platform.openapi.flow.YschFlowOpenApi.raw().download(DIAGRAM_IMG_PATH, imgQuery)
: openCall.download(target(), DIAGRAM_IMG_PATH, imgQuery);
if (bytes == null || bytes.length == 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程示意图为空(流程可能未发布节点)");
}
return bytes;
}
private TargetSpec target() {
// base-path 空:本地/容器裸地址直连(两级模型 §2.2 对齐不变式的现行约定)
return TargetSpec.of(properties.getBaseUrl(), "");
}
private void requireEnabled() {
if (!properties.isEnabled()) {
throw new BizException(ErrorCode.FORBIDDEN, NOT_CONNECTED);
}
}
private static Map<String, String> params(String k, Object v) {
Map<String, String> m = new HashMap<>();
if (k != null && v != null) {
m.put(k, String.valueOf(v));
}
return m;
}
}
@@ -0,0 +1,43 @@
package com.yunhai.notice.service.flow;
import com.yunhai.notice.model.flow.FlowModels;
import java.util.List;
/**
* 流程中心出站接缝(Notice 首接方案 §5.1,方法面与 svc-hall FlowCenterClient 一致)。
* 接口保留三重意义:防依赖(业务层不感知 YschOpenCall)、可测试(fake 注入)、可降级(enabled 开关)。
*/
public interface FlowClient {
/** 已发布流程列表(编辑页"审核流程"选择器数据源) */
List<FlowModels.FlowSummaryVo> listFlows(Integer schId);
/** 代发起(biz uk 幂等:重复 apply 同办件;30/40 态 R4 重启推进) */
FlowModels.ApplyResultVo apply(Integer schId, FlowModels.ApplyBody body);
/** 按业务键查办件(进度卡片/放行判定/对账兜底;不存在返回 null) */
FlowModels.InstanceVo getInstanceByBiz(Integer schId, String bizType, String bizId);
/** 回调注册(下发 secret;重复注册会轮换 secret——仅在 reg 缺失时调用) */
FlowModels.CallbackRegResultVo registerCallback(Integer schId, FlowModels.CallbackRegBody body);
/**
* 撤回(托管件,2026-09-17 接入):对端按 submitterId 与办件提交人逐字复核,
* 应用签名只证明"哪个应用在调"不证明"谁在撤"——防业务应用越权代撤他人办件。
*/
void withdraw(Integer schId, Long instanceId, Integer submitterId);
/** 办件进度(结构化 steps,instanceId 定位;oapi 只校验 schId,调用方先做可见性守卫) */
FlowModels.ProgressVo progress(Integer schId, Long instanceId);
/** 流程示意图字节(token 换图两步都在本方法内,浏览器只拿同源 PNG,token 不落前端) */
byte[] diagramImage(Integer schId, Long instanceId);
/**
* 流程定义示意图字节(def 口径,绑定页/发通知页 Drawer):按 flowGuid 取最高已发布版出图。
* 与办件图同一免签 token 通道;本校限定由 flow-center 按 schId+flowGuid 收敛(绑定已解除/
* 无已发布版 → 业务异常文案,不留 500)。
*/
byte[] diagramImageByFlowGuid(Integer schId, String flowGuid);
}
@@ -0,0 +1,117 @@
package com.yunhai.notice.service.flow;
import com.yunhai.platform.openapi.core.PlatformService;
import com.yunhai.platform.openapi.core.YschOpenCall;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 应用内审批出站客户端(第三步 T-C4):待办/办件查询 + 通过/驳回,经网关打 flow-center
* {@code /oapi/flow/tasks|task/**}(2026-09-23 已上线)。
* <p>
* <b>刻意用 {@link YschOpenCall} 泛化调用而非 YschFlowOpenApi 新门面方法</b>——四方法
* (todoTasks/instances/approveTask/rejectTask)在 SDK main 已编码但**未发版**,notice 运行时
* 依赖的是 nexus 已发布快照;泛化调用与 FlowCenterClient legacy 分支同一条
* resolveTarget 解析链(targets &gt; services &gt; 枚举 base-host 兜底=网关),零发版依赖。
* SDK 发版后可平替为门面方法(行为等价,测试锁 URL 不变)。
* <p>
* 身份口径:operatorId/operatorName 由调用方(controller)从 CAS 登录态取,本类不猜身份;
* 对端按任务办理人逐字复核(非办理人→业务码拒绝),notice 不重复校验。
*/
@Slf4j
@Service
public class NoticeApprovalClient {
private static final String TARGET = PlatformService.FLOW_CENTER.key();
private static final String NOT_CONNECTED = "流程中心未接入,审批功能不可用";
private final NoticeFlowProperties properties;
private final YschOpenCall openCall;
public NoticeApprovalClient(NoticeFlowProperties properties,
ObjectProvider<YschOpenCall> openCallProvider) {
this.properties = properties;
this.openCall = openCallProvider.getIfAvailable();
}
/** 我的待办(bizType 固定 notice):Map 形态即 flow-center BasePageResult JSON(total/content/…) */
public Map<String, Object> todoTasks(Integer schId, Integer assigneeId, Integer page, Integer size) {
requireEnabled();
Map<String, String> query = new LinkedHashMap<>();
query.put("schId", String.valueOf(schId));
query.put("assigneeId", String.valueOf(assigneeId));
query.put("bizType", FlowCenterClient.BIZ_TYPE);
if (page != null) {
query.put("page", String.valueOf(page));
}
if (size != null) {
query.put("size", String.valueOf(size));
}
return openCall.get(TARGET, "/oapi/flow/tasks/todo", query, Map.class);
}
/** 办件分页(bizType 固定 notice;status 可空) */
public Map<String, Object> instances(Integer schId, Integer status, Integer page, Integer size) {
requireEnabled();
Map<String, String> query = new LinkedHashMap<>();
query.put("schId", String.valueOf(schId));
query.put("bizType", FlowCenterClient.BIZ_TYPE);
if (status != null) {
query.put("status", String.valueOf(status));
}
if (page != null) {
query.put("page", String.valueOf(page));
}
if (size != null) {
query.put("size", String.valueOf(size));
}
return openCall.get(TARGET, "/oapi/flow/instances", query, Map.class);
}
/** 同意(opinion 可空;多出口 edgeName 透传) */
public void approve(Long taskId, Integer schId, Integer operatorId, String operatorName,
String opinion, String edgeName) {
requireEnabled();
Map<String, Object> body = new LinkedHashMap<>();
if (opinion != null && !opinion.trim().isEmpty()) {
body.put("opinion", opinion.trim());
}
if (edgeName != null && !edgeName.trim().isEmpty()) {
body.put("edgeName", edgeName.trim());
}
openCall.post(TARGET, "/oapi/flow/task/" + taskId + "/approve",
operatorQuery(schId, operatorId, operatorName), body, Void.class);
}
/** 拒绝(opinion 必填由对端强制;此处前置校验给前端更快的反馈) */
public void reject(Long taskId, Integer schId, Integer operatorId, String operatorName, String opinion) {
requireEnabled();
if (opinion == null || opinion.trim().isEmpty()) {
throw new com.yunhai.platform.apistd.BizException(
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "驳回必须填写意见");
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("opinion", opinion.trim());
openCall.post(TARGET, "/oapi/flow/task/" + taskId + "/reject",
operatorQuery(schId, operatorId, operatorName), body, Void.class);
}
private static Map<String, String> operatorQuery(Integer schId, Integer operatorId, String operatorName) {
Map<String, String> query = new LinkedHashMap<>();
query.put("schId", String.valueOf(schId));
query.put("operatorId", String.valueOf(operatorId));
query.put("operatorName", operatorName);
return query;
}
private void requireEnabled() {
if (!properties.isEnabled() || openCall == null) {
throw new com.yunhai.platform.apistd.BizException(
com.yunhai.platform.utils.exception.ErrorCode.FORBIDDEN, NOT_CONNECTED);
}
}
}
@@ -0,0 +1,187 @@
package com.yunhai.notice.service.flow;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.enums.FlowAuditStatusEnum;
import com.yunhai.notice.enums.NoticeStatusEnum;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.platform.flowcallback.FlowCallbackHandler;
import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 流程中心回调业务映射(2026-09-20 迁 yh-flow-callback-spring-boot-starter):
* 验签(平台 X-App-*,/oapi/* 强制)、ping、必填校验、bizType 过滤、eventId 幂等、
* instanceVersion 乱序门由 starter 接收管线承担;本类只做通知侧状态机推进——
* <br>事件映射:submitted/task.created/task.handled→10;completed→20(置 status=1 发布
* + 短信后置);rejected→30;withdrawn→40;cc.sent→只落 notice_cc 抄送行(不动状态机,
* 2026-09-24)。行锁串行化并发推送,终态不回退守卫,终态 data 快照落 flow_result_json(详情页进度卡片)。
* <br>应答语义不变:本方法抛任何异常 → starter 回 5xx(flow-center 持续重试追平,免对账);
* 正常返回(含通知不存在/未绑流程/未知事件等不可重试场景)→ 200。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class NoticeFlowCallbackHandler implements FlowCallbackHandler {
private final NoticeRepository noticeRepository;
private final com.yunhai.notice.service.sms.SmsSendService smsSendService;
private final ObjectMapper objectMapper;
private final com.yunhai.notice.repository.NoticeCcRepository noticeCcRepository;
@Override
public void onEvent(FlowCallbackEvent event) {
Integer noticeId;
try {
noticeId = Integer.valueOf(event.getBizId().trim());
} catch (NumberFormatException e) {
log.warn("[notice-flow] 回调 bizId 非法(丢弃):{}", event.getBizId());
return;
}
// 行锁串行化:并发推送(flow-center 推送池)对同一通知的处理排队,防丢更新
Notice notice = noticeRepository.findByIdForUpdate(noticeId).orElse(null);
if (notice == null || !StringUtils.hasText(notice.getFlowGuid())) {
log.warn("[notice-flow] 回调通知不存在或未绑流程(丢弃):notice={}", event.getBizId());
return;
}
// 单据级乱序闸门(starter 表级闸门之外的第二道:以行锁后读到的 notice 版本为准)
if (event.getInstanceVersion() != null && notice.getLastInstanceVersion() != null
&& event.getInstanceVersion() < notice.getLastInstanceVersion()) {
log.info("[notice-flow] 乱序事件丢弃:notice={} eventVersion={} last={}",
noticeId, event.getInstanceVersion(), notice.getLastInstanceVersion());
return;
}
advance(notice, event);
}
/** 状态推进(幂等:同一事件重放由 eventId 去重拦截,此处只需正确覆盖目标态) */
private void advance(Notice notice, FlowCallbackEvent event) {
String code = event.getEvent();
Integer instanceVersion = event.getInstanceVersion();
Integer target;
switch (code) {
case "flow.cc.sent":
recordCc(notice, event);
return;
case "flow.completed":
target = FlowAuditStatusEnum.APPROVED.getCode();
break;
case "flow.rejected":
target = FlowAuditStatusEnum.REJECTED.getCode();
break;
case "flow.withdrawn":
target = FlowAuditStatusEnum.WITHDRAWN.getCode();
break;
case "flow.submitted":
case "flow.task.created":
case "flow.task.handled":
target = FlowAuditStatusEnum.APPROVING.getCode();
break;
default:
log.warn("[notice-flow] 未知事件(丢弃):{}", code);
return;
}
// P1-3 终态不回退守卫:终态事件与最后一个 task.handled 同 instanceVersion,若 handled
// 在 completed/rejected 之后落库(推送池并发不保序),无守卫会把 20/30 打回 10 并覆盖结果。
// R4 重提不冲突:编辑保存先本地置 audit=0,新一轮事件到达时行已非终态。
if (isTerminal(notice.getAuditStatus())) {
if (!isTerminal(target)) {
log.warn("[notice-flow] 终态不回退:notice={} audit={} 拒绝非终态事件 {}(v{})",
notice.getId(), notice.getAuditStatus(), code, instanceVersion);
return;
}
if (StringUtils.hasText(notice.getFlowResultJson())) {
log.warn("[notice-flow] 终态结果已落定:notice={} audit={} 拒绝覆盖 flow_result_json(事件 {})",
notice.getId(), notice.getAuditStatus(), code);
return;
}
}
notice.setAuditStatus(target);
if (instanceVersion != null) {
notice.setLastInstanceVersion(instanceVersion);
}
// 终态明细快照(result/opinion/rejectNode,详情页进度卡片展示)
if (isTerminal(target)) {
notice.setFlowResultJson(snapshot(event));
}
boolean justPublished = false;
if (target == FlowAuditStatusEnum.APPROVED.getCode()) {
// 审核通过 = 正式发布:置 status=1(用户端自此可见)+ 发布后置(短信后移到此刻)
if (notice.getStatus() == null || notice.getStatus() != NoticeStatusEnum.PUBLISHED.getCode()) {
notice.setStatus(NoticeStatusEnum.PUBLISHED.getCode());
justPublished = true;
}
}
noticeRepository.saveAndFlush(notice);
if (justPublished) {
sendSmsAfterPublish(notice);
}
}
/**
* 抄送留痕(2026-09-24,flow.cc.sent):逐人一条事件逐条落 notice_cc,供应用内
* 「抄送给我的」列表渲染、点击跳通知详情。不动审核状态机(抄送是知会不是审批,
* taskId 恒 null 也不落任务);已通过行锁与乱序闸门,重放由 eventId 幂等拦截。
* data 缺 ccUserId 属上游脏数据:记日志跳过(回 200),落库异常照旧外抛回 5xx 重试。
*/
private void recordCc(Notice notice, FlowCallbackEvent event) {
Map<String, Object> data = event.getData();
Object ccUserId = data == null ? null : data.get("ccUserId");
if (ccUserId == null || String.valueOf(ccUserId).isEmpty()) {
log.warn("[notice-flow] cc.sent 缺 ccUserId(丢弃):notice={} eventId={}",
notice.getId(), event.getEventId());
return;
}
com.yunhai.notice.entity.NoticeCc cc = new com.yunhai.notice.entity.NoticeCc();
cc.setSchId(notice.getSchId());
cc.setNoticeId(notice.getId());
cc.setFlowInstanceId(event.getInstanceId());
cc.setNodeId(data.get("nodeId") == null ? null : String.valueOf(data.get("nodeId")));
cc.setNodeName(data.get("nodeName") == null ? null : String.valueOf(data.get("nodeName")));
cc.setCcUserId(Integer.valueOf(String.valueOf(ccUserId)));
cc.setCcUserName(data.get("ccUserName") == null ? null : String.valueOf(data.get("ccUserName")));
cc.setSentTime(LocalDateTime.now());
noticeCcRepository.save(cc);
log.info("[notice-flow] 抄送留痕:notice={} instance={} ccUser={} node={}",
notice.getId(), event.getInstanceId(), cc.getCcUserId(), cc.getNodeName());
}
private String snapshot(FlowCallbackEvent event) { if (event.getData() == null) {
return null;
}
try {
return objectMapper.writeValueAsString(event.getData());
} catch (Exception e) {
log.warn("[notice-flow] 终态快照序列化失败(置 null 不阻断推进):{}", e.toString());
return null;
}
}
/** 审核通过后的短信后置(绑流程通知 create 时不发,方案 §5.2 短信时机后移) */
private void sendSmsAfterPublish(Notice notice) {
if (notice.getSmsFlag() == null || notice.getSmsFlag() != 1) {
return;
}
// 与直发链路 trySendSms 的 isImmediatePublish 语义对齐:定时未到点的通知此刻不发
// (可见性已生效),到点由 SmsCatchupJob 补发(§3.2 修 BP-R5-2 短信永久丢失缺口)
if (notice.getTimingSendTime() != null
&& notice.getTimingSendTime().isAfter(LocalDateTime.now())) {
return;
}
smsSendService.sendOnce(notice);
}
/** 审批终态(20 通过 / 30 驳回 / 40 撤回)——终态行的状态与结果明细只允许被新一轮(本地先置 0)改写 */
private static boolean isTerminal(Integer auditStatus) {
return auditStatus != null
&& (auditStatus == FlowAuditStatusEnum.APPROVED.getCode()
|| auditStatus == FlowAuditStatusEnum.REJECTED.getCode()
|| auditStatus == FlowAuditStatusEnum.WITHDRAWN.getCode());
}
}
@@ -0,0 +1,52 @@
package com.yunhai.notice.service.flow;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.flow.YschFlowOpenApi;
import com.yunhai.platform.openapi.flow.model.FlowEmbedUrl;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.Collections;
import java.util.Map;
/**
* 办件进度嵌入页换址(2026-10-03 嵌入页方案):子应用 Drawer 内 iframe 直嵌 flow-center
* 统一进度页,零样式复制。与 Diagram 同口径的用户级守卫(未发布仅提交人/管理员/办理人
* /被抄送人可见);oapi 层只认 schId——"谁能看"必须落在本端点,应用签名不替代身份。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class NoticeFlowEmbedService {
private final NoticeRepository noticeRepository;
private final NoticeFlowGuard noticeFlowGuard;
/**
* 取嵌入页地址(pageUrl 含 10 分钟短时 token):现取现发、不缓存、不落库、
* 不打进应用日志(token 泄露窗口即有效期,纪律见方案 §6.1/§7)。
*/
public Map<String, String> embedUrl(Integer noticeId, BoaUser user) {
Notice notice = noticeRepository.findById(noticeId)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId));
if (notice.getStatus() == null || notice.getStatus() != 1) {
noticeFlowGuard.requireUnpublishedVisible(notice, user);
}
if (notice.getFlowInstanceId() == null || notice.getSchId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "该通知尚未进入流程");
}
FlowEmbedUrl embed = YschFlowOpenApi.embedUrl(
null, FlowCenterClient.BIZ_TYPE, String.valueOf(notice.getId()),
notice.getSchId(), FlowEmbedUrl.class);
if (embed == null || !StringUtils.hasText(embed.getPageUrl())) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "嵌入页地址获取失败,请稍后重试");
}
return Collections.singletonMap("pageUrl", embed.getPageUrl());
}
}
@@ -0,0 +1,133 @@
package com.yunhai.notice.service.flow;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.model.flow.FlowModels;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 未发布通知(status=0,审核载体)的详情放行守卫(方案 §5.5,V2.0 B.4#8 同构)。
* 四类人可见:提交人本人(publisherAccount)/ 本校管理员(type=1 或 roleId∈{2,5,7})/
* 该办件流程办理人(instance.assigneeIds 含当前用户,60s 短缓存——审批中途换人不实时,可接受)/
* 该办件被抄送人(notice_cc 有行,2026-09-24——抄送是知会,应用内「抄送给我的」点开必须可见)。
* 其余用户查询即"通知不存在或未发布"(查不到语义,不泄露存在性)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class NoticeFlowGuard {
/** 管理员角色白名单(校级管理员/分管领导等,与既有菜单权限体系对齐) */
private static final List<Integer> ADMIN_ROLE_IDS = Arrays.asList(2, 5, 7);
private static final long ASSIGNEE_CACHE_MS = 60_000L;
private final FlowClient flowClient;
private final NoticeFlowProperties properties;
private final com.yunhai.notice.repository.NoticeCcRepository noticeCcRepository;
private final Map<Long, CachedAssignees> assigneeCache = new ConcurrentHashMap<>();
private static class CachedAssignees {
final List<Integer> userIds;
final long expireAt;
CachedAssignees(List<Integer> userIds) {
this.userIds = userIds;
this.expireAt = System.currentTimeMillis() + ASSIGNEE_CACHE_MS;
}
}
/**
* 校验当前用户可查看未发布通知;不可见抛 FORBIDDEN(文案=查不到语义)。
* status=1 已发布通知不走本守卫(既有行为零变化)。
*/
public void requireUnpublishedVisible(Notice notice, BoaUser user) {
if (notice == null || user == null) {
throw notFound();
}
// 1) 提交人本人
if (StringUtils.hasText(notice.getPublisherAccount())
&& notice.getPublisherAccount().equals(user.getAccount())) {
return;
}
// 2) 本校管理员(type=1 校管 或 roleId 命中白名单)
if (isAdmin(user)) {
return;
}
// 3) 该办件办理人(assigneeIds 含当前用户 id;flowInstanceId 缺失=尚未提交成功,仅前两类可见)
if (notice.getFlowInstanceId() != null && properties.isEnabled()
&& currentAssignees(notice).contains(user.getId())) {
return;
}
// 4) 该办件被抄送人(notice_cc 本地表有行即放行——抄送发生即知会,无需查流程中心)
if (noticeCcRepository.existsByNoticeIdAndCcUserId(notice.getId(), user.getId())) {
return;
}
throw notFound();
}
/**
* 管理员校验(应用设置 &gt; 流程绑定保存用):绑定决定全校"发全部用户是否需要审核",
* 与"谁能看未发布通知"同一口径({@link #ADMIN_ROLE_IDS})。
*/
public void requireAdmin(BoaUser user) {
if (user == null || !isAdmin(user)) {
throw new BizException(ErrorCode.FORBIDDEN, "仅本校管理员可修改审核流程绑定");
}
}
/** 是否本校管理员(口径同下方守卫;绑定页据此决定是否可编辑) */
public boolean isAdmin(BoaUser user) {
if (user == null) {
return false;
}
if (user.getType() != null && user.getType() == 1) {
return true;
}
if (user.getRoleIds() != null) {
for (Integer roleId : user.getRoleIds()) {
if (roleId != null && ADMIN_ROLE_IDS.contains(roleId)) {
return true;
}
}
}
return false;
}
private List<Integer> currentAssignees(Notice notice) {
long now = System.currentTimeMillis();
CachedAssignees cached = assigneeCache.get(notice.getFlowInstanceId());
if (cached != null && cached.expireAt > now) {
return cached.userIds == null ? java.util.Collections.emptyList() : cached.userIds;
}
List<Integer> assignees = null;
try {
FlowModels.InstanceVo instance = flowClient.getInstanceByBiz(
notice.getSchId(), FlowCenterClient.BIZ_TYPE, String.valueOf(notice.getId()));
if (instance != null) {
assignees = instance.getAssigneeIds();
}
} catch (Exception e) {
// 流程中心不可达:fail-closed(不放行),日志留痕
log.warn("[notice-flow] 办理人查询失败(fail-closed):notice={} {}", notice.getId(), e.toString());
}
assigneeCache.put(notice.getFlowInstanceId(),
new CachedAssignees(assignees == null ? java.util.Collections.emptyList() : assignees));
return assignees == null ? java.util.Collections.emptyList() : assignees;
}
private BizException notFound() {
return new BizException(ErrorCode.FORBIDDEN, "通知不存在或未发布");
}
}
@@ -0,0 +1,26 @@
package com.yunhai.notice.service.flow;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 通知-流程中心接缝配置(notice.flow.*)。front-base-url 已删(2026-09-26):
* bizUrl 改传裸相对路径,绝对化由接收方按页面归属承担(自指地址优化 Phase 2)。
*/
@Data
@Component
@ConfigurationProperties(prefix = "notice.flow")
public class NoticeFlowProperties {
/** 是否接入流程中心(false=编辑页选择器隐藏、FlowClient 全部抛业务异常降级) */
private boolean enabled = true;
/**
* flow-center 基址的 <b>legacy 回落</b>(Tier 0 ad-hoc TargetSpec 路径)。
* 主路径是服务目录 {@code yh.open.services.flowcenter} → {@code YschFlowOpenApi} 门面;
* 本键仅迁移期兼容,<b>不再给默认值</b>——两者都空时 {@code FlowCenterClient} 构造期
* fail-fast,避免静默打 localhost 把"地址没配"伪装成"流程中心不可达"。
*/
private String baseUrl = "";
}
@@ -0,0 +1,441 @@
package com.yunhai.notice.service.flow;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.entity.NoticeFlowBinding;
import com.yunhai.notice.entity.UpFile;
import com.yunhai.notice.enums.FlowAuditStatusEnum;
import com.yunhai.notice.model.flow.FlowModels;
import com.yunhai.notice.repository.NoticeFlowBindingRepository;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.notice.repository.UpFileRepository;
import com.yunhai.notice.utils.ReceiveParser;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.auth.spring.CurrentBoaUser;
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
import com.yunhai.platform.openapi.core.JumpTarget;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationAdapter;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 通知-流程中心接合服务:提交/重提(D9 AFTER_COMMIT 出站)、回调注册管理、进度组装。
* 提交语义(方案 §5.2):apply 幂等(bizId=notice.id),失败保持 0 待提交可重推;
* 10 审批中禁止重复提交/编辑/删除(删除需先撤回)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class NoticeFlowService {
private final NoticeRepository noticeRepository;
private final NoticeFlowBindingRepository bindingRepository;
private final UpFileRepository upFileRepository;
private final FlowClient flowClient;
private final NoticeFlowProperties properties;
private final ObjectMapper objectMapper;
private final com.yunhai.platform.flowcallback.FlowCallbackRegistrar callbackRegistrar;
private final CurrentBoaUser currentBoaUser;
private final NoticeFlowGuard noticeFlowGuard;
/**
* 注册后事务提交了再提交审核(D9:apply 出站不进 DB 事务,防 HTTP 长调用拖住连接);
* 无活动事务时立即执行(测试/兜底)。
* <p>E2E 实测教训:afterCommit 回调若留在原请求线程执行,OSIV 绑定的 EntityManager
* 已随提交关闭,后续 repository 写会话静默丢失(有成功日志但库无行)——故提交动作
* 一律切到独立线程(干净事务 + 无 ThreadLocal 残留),提交人身份在请求线程先捕获透传。
*/
public void submitAfterCommit(Integer noticeId) {
final BoaUser user = currentUser();
Runnable task = () -> trySubmitQuietly(noticeId, user);
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
SUBMIT_EXECUTOR.submit(task);
}
});
} else {
task.run();
}
}
/** 提交动作专用单线程池(串行化避免并发双 apply;uk 幂等兜底) */
private static final java.util.concurrent.ExecutorService SUBMIT_EXECUTOR =
java.util.concurrent.Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "notice-flow-submit");
t.setDaemon(true);
return t;
});
/** 提交失败静默保持 0 待提交(行已落库不回滚;列表"待提交"态即提示,编辑页可重推) */
private void trySubmitQuietly(Integer noticeId, BoaUser user) {
try {
trySubmitFlow(noticeId, user);
} catch (Exception e) {
log.warn("[notice-flow] 提交审核失败(保持待提交,可重推):notice={} {}", noticeId, e.toString());
}
}
/**
* 提交/重提审核(幂等入口:编辑页重提按钮 / create 后置共用)。
* audit=0/30/40 可提交;10 审批中拒绝;apply 成功回填 flow_instance_id + audit=10。
* apply 失败抛业务异常,notice 行保持 0 待提交(不回滚已落库的编辑内容)。
*/
public void trySubmitFlow(Integer noticeId) {
trySubmitFlow(noticeId, currentUser());
}
/** 提交/重提(带访问者——控制器直传登录人,BP-R5-3 守卫入口) */
public void trySubmitFlow(Integer noticeId, BoaUser user) {
Notice notice = requireNotice(noticeId);
// BP-R5-3:Trace/TrySubmit 原先仅验存在性,任意登录用户可对他人通知触发重提——补
// NoticeFlowGuard 三分支(提交人/管理员/办理人),与 Detail 的守卫口径对齐
noticeFlowGuard.requireUnpublishedVisible(notice, user);
if (!StringUtils.hasText(notice.getFlowGuid())) {
throw new BizException(ErrorCode.PARAM_ERROR, "该通知未绑定审核流程");
}
Integer audit = notice.getAuditStatus();
if (audit != null && audit == FlowAuditStatusEnum.APPROVING.getCode()) {
throw new BizException(ErrorCode.FORBIDDEN, "审批中,不可重复提交,请先撤回");
}
ensureRegistered(notice);
FlowModels.ApplyBody body = buildApplyBody(notice, user);
FlowModels.ApplyResultVo result = flowClient.apply(notice.getSchId(), body);
// E2E 实测教训:此处曾整实体 save——apply 出站期间回调事件可能已并发推进
// (submitted 先行置 10 / completed 置 20),旧快照盲写会把状态打回;改为窄列原子更新,
// 且 audit=20(已通过)不回退(apply 返回晚于 completed 送达的合法场景)
noticeRepository.markApproving(noticeId, result.getInstanceId());
log.info("[notice-flow] 提交审核成功:notice={} instance={} duplicated={}",
noticeId, result.getInstanceId(), result.isDuplicated());
}
/**
* 回调注册(2026-09-20 迁 starter 的 {@link com.yunhai.platform.flowcallback.FlowCallbackRegistrar}):
* 仅本地镜像行(flow_callback_reg)缺失/失效时注册并落 secret。<b>绝不重复调用</b>——
* flow-center 重复注册会轮换 secret;失配恢复手段=删除镜像行后重新保存绑定。
*/
void ensureRegistered(Notice notice) {
callbackRegistrar.ensureRegistered(notice.getSchId(), notice.getFlowGuid());
}
/** apply 出参组装(契约冻结:bizId=notice.id、bizUrl=裸相对路径、快照只收摘要不收富文本正文;
* formData 带发文部门(2026-09-24)——条件路由判断端点优先读它免查库,缺字段回落查表同口径。
* bizUrl 自 2026-09-26 起为裸相对路径(不含域名/ctx/#,接收方按页面归属拼绝对地址),
* Notice 不再需要配置自己的前端基址(自指地址优化 Phase 2) */
FlowModels.ApplyBody buildApplyBody(Notice notice, BoaUser user) {
FlowModels.ApplyBody body = new FlowModels.ApplyBody();
body.setFlowGuid(notice.getFlowGuid());
body.setBizType(FlowCenterClient.BIZ_TYPE);
body.setBizId(String.valueOf(notice.getId()));
body.setBizTitle(notice.getTitle());
body.setBizJump(JumpTarget.page("list/detail").param("serid", notice.getId()));
body.setBizSnapshot(buildSnapshot(notice));
Map<String, Object> formData = new LinkedHashMap<>();
formData.put("deptId", notice.getDeptId());
formData.put("deptName", notice.getDeptName());
body.setFormData(formData);
if (user != null) {
body.setSubmitterId(user.getId());
body.setSubmitterName(StringUtils.hasText(user.getName()) ? user.getName() : user.getAccount());
}
return body;
}
/** 业务摘要:原键照旧(流程中心透传、嵌入页不展示)+ 约定键 summaryFields(嵌入页「业务信息」唯一渲染源) */
private Map<String, Object> buildSnapshot(Notice notice) {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("title", notice.getTitle());
snapshot.put("summary", plainSummary(notice.getContent()));
List<String> names = new ArrayList<>();
for (UpFile f : upFileRepository.findByTopicTypeAndTopicId("notice", notice.getId())) {
if (StringUtils.hasText(f.getFileTrueName())) {
names.add(f.getFileTrueName());
}
}
snapshot.put("attachmentNames", names);
snapshot.put("summaryFields", buildSummaryFields(notice));
return snapshot;
}
/** 嵌入页「业务信息」三行:通知标题 / 来源部门 / 通知对象 */
private List<Map<String, Object>> buildSummaryFields(Notice notice) {
List<Map<String, Object>> fields = new ArrayList<>();
fields.add(summaryField("通知标题", notice.getTitle()));
fields.add(summaryField("来源部门", notice.getDeptName()));
fields.add(summaryField("通知对象", buildReceiveSummary(notice)));
return fields;
}
private Map<String, Object> summaryField(String label, Object value) {
Map<String, Object> field = new LinkedHashMap<>();
field.put("label", label);
field.put("value", value == null ? "" : value);
return field;
}
/**
* 通知对象展示串:全员(receive_list 空 / ALL / UserAll)⇒ "全部";指定 ⇒ 前 3 个姓名以「、」连接,
* 多于 3 人再追 " 等N人"(如 "张三、李四、王五 等11人")。
* 姓名经用户中心解析(手法同 NoticeService.getReadDetailGrouped),失败回落原 id/账号;
* 只解析前 3 个——本方法在 apply 提交时执行,避免 N 次远程调用。
*/
private String buildReceiveSummary(Notice notice) {
List<String> accounts = ReceiveParser.parse(notice.getReceiveList());
if (accounts.isEmpty()) {
return "全部";
}
int total = accounts.size();
List<String> shown = new ArrayList<>();
for (String account : accounts.subList(0, Math.min(3, total))) {
shown.add(resolveReceiverName(account));
}
String joined = String.join("、", shown);
return total > 3 ? joined + " 等" + total + "人" : joined;
}
/** 接收明细可能存登录名,也可能是历史数字 userId:先按登录名查,查不到且纯数字再按 id 查 */
private String resolveReceiverName(String account) {
try {
BoaUser user = YschBaseOpenApi.getUserInfo(account);
if (user == null && StringUtils.hasText(account) && account.chars().allMatch(Character::isDigit)) {
user = YschBaseOpenApi.getUserInfo(Integer.parseInt(account));
}
return user != null && StringUtils.hasText(user.getName()) ? user.getName() : account;
} catch (Exception e) {
log.warn("[notice-flow] 通知对象姓名解析失败 account={}", account, e);
return account;
}
}
/** 富文本剥标签取纯文本摘要(截断 300 字) */
private String plainSummary(String content) {
if (!StringUtils.hasText(content)) {
return "";
}
String text = content.replaceAll("<[^>]+>", "").replaceAll("&nbsp;", " ").trim();
return text.length() > 300 ? text.substring(0, 300) : text;
}
/** 进度组装(trace 接口):auditStatus/status/version/trace[]/currentNodes/nodeSummary;未绑流程返回未接入语义 */
public Map<String, Object> buildTrace(Integer noticeId) {
return buildTrace(noticeId, currentUser());
}
/**
* 进度组装(带访问者,BP-R5-3):未发布通知(status=0,审核载体)仅提交人/管理员/办理人
* 可见轨迹与驳回意见(NoticeFlowGuard 三分支);已发布(status=1)内容本就公开,登录即可看。
*/
public Map<String, Object> buildTrace(Integer noticeId, BoaUser user) {
Notice notice = requireNotice(noticeId);
if (notice.getStatus() == null || notice.getStatus() != 1) {
noticeFlowGuard.requireUnpublishedVisible(notice, user);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("bound", StringUtils.hasText(notice.getFlowGuid()));
result.put("flowGuid", notice.getFlowGuid());
result.put("flowInstanceId", notice.getFlowInstanceId());
result.put("auditStatus", notice.getAuditStatus());
result.put("auditStatusDesc", FlowAuditStatusEnum.descOf(notice.getAuditStatus()));
result.put("status", notice.getStatus());
if (!StringUtils.hasText(notice.getFlowGuid()) || notice.getAuditStatus() == null) {
return result; // 未接入流程中心(编辑页/详情页据此隐藏进度卡片)
}
result.put("flowResult", parseJson(notice.getFlowResultJson()));
if (properties.isEnabled()) {
try {
FlowModels.InstanceVo instance = flowClient.getInstanceByBiz(
notice.getSchId(), FlowCenterClient.BIZ_TYPE, String.valueOf(notice.getId()));
if (instance != null) {
result.put("instanceStatus", instance.getStatus());
result.put("version", instance.getVersion());
result.put("nodeSummary", instance.getNodeSummary());
result.put("currentNodes", instance.getCurrentNodes());
result.put("assigneeIds", instance.getAssigneeIds());
result.put("submitTime", instance.getSubmitTime());
result.put("finishTime", instance.getFinishTime());
result.put("trace", parseJson(instance.getTraceJson()));
}
if (notice.getFlowInstanceId() != null) {
// 结构化步骤(steps[0] 固定"发起申请",同节点多任务每人一行)——
// 2026-09-17 接入 SDK progress,详情页时间线数据源;失败降级不阻断
FlowModels.ProgressVo progress =
flowClient.progress(notice.getSchId(), notice.getFlowInstanceId());
if (progress != null) {
result.put("steps", progress.getSteps());
}
}
} catch (Exception e) {
// 进度查询失败不阻断详情:展示本地镜像态即可(流程中心停机时详情仍可用)
log.warn("[notice-flow] 进度查询失败:notice={} {}", noticeId, e.toString());
}
}
return result;
}
/**
* 撤回审核(2026-09-17 治理:原先前端直调 flow-center 用户面——跨应用 CAS 票据 + CORS
* 双重外溢,且"谁能撤"落在前端)。现经 SDK 托管件后端中转:①本人(publisherAccount)
* = 权威授权;②审批中(10) 且已有实例;③对端再按 submitterId 复核一次。
* 撤回后镜像状态由 flow.withdrawn 回调推进(40),本方法不改本地状态——单一写入口。
*/
public void withdrawNotice(Integer noticeId, BoaUser user) {
Notice notice = requireNotice(noticeId);
if (user == null || !StringUtils.hasText(notice.getPublisherAccount())
|| !notice.getPublisherAccount().equals(user.getAccount())) {
throw new BizException(ErrorCode.FORBIDDEN, "仅通知创建人可撤回审核");
}
Integer audit = notice.getAuditStatus();
if (audit == null || audit != FlowAuditStatusEnum.APPROVING.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "仅审批中的通知可撤回");
}
if (notice.getFlowInstanceId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "该通知尚未进入流程,无需撤回");
}
flowClient.withdraw(notice.getSchId(), notice.getFlowInstanceId(), user.getId());
log.info("[notice-flow] 撤回审核:notice={} instance={} user={}",
noticeId, notice.getFlowInstanceId(), user.getId());
}
/**
* 流程示意图(PNG 流,同源直嵌 {@code <img>}):可见性与 Trace 同口径(未发布仅三类人)。
* token 与字节都在服务端流转,前端不再需要知道 flow-center 地址。
*/
public byte[] diagramImage(Integer noticeId, BoaUser user) {
Notice notice = requireNotice(noticeId);
if (notice.getStatus() == null || notice.getStatus() != 1) {
noticeFlowGuard.requireUnpublishedVisible(notice, user);
}
if (!StringUtils.hasText(notice.getFlowGuid()) || notice.getAuditStatus() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "该通知未接入审核流程");
}
if (notice.getFlowInstanceId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "该通知尚未进入流程,无流程图");
}
return flowClient.diagramImage(notice.getSchId(), notice.getFlowInstanceId());
}
/** 编辑页"审核流程"选择器数据源(未启用返回空列表由前端隐藏) */
public List<FlowModels.FlowSummaryVo> options(Integer schId) {
if (!properties.isEnabled()) {
return new ArrayList<>();
}
try {
return flowClient.listFlows(schId);
} catch (BizException e) {
throw e;
} catch (Exception e) {
log.warn("[notice-flow] 流程列表查询失败:{}", e.toString());
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "审核流程列表获取失败,请稍后重试");
}
}
private BoaUser currentUser() {
return currentBoaUser == null ? null : currentBoaUser.get();
}
// ==================== 应用级流程绑定(应用设置 > 流程绑定) ====================
/**
* 本校绑定视图(设置页展示 / 发通知页按钮显隐):bound / flowGuid / flowName / flowEnabled / canEdit。
* 无学校身份或未绑定 → bound=false。读取不需要管理员(发通知页人人都要据此判断是否走审核)。
*/
public Map<String, Object> bindingView(Integer schId, BoaUser user) {
Map<String, Object> view = new LinkedHashMap<>();
NoticeFlowBinding binding = schId == null ? null : bindingRepository.findBySchId(schId).orElse(null);
boolean bound = binding != null && StringUtils.hasText(binding.getFlowGuid());
view.put("bound", bound);
view.put("flowGuid", bound ? binding.getFlowGuid() : null);
view.put("flowName", bound ? binding.getFlowName() : null);
view.put("flowEnabled", properties.isEnabled());
view.put("canEdit", noticeFlowGuard.isAdmin(user));
return view;
}
/**
* 发通知分流唯一判定入口:本校已绑定且流程中心可用时返回绑定 GUID;否则 null(走直发)。
* 流程中心未接入(properties.enabled=false)时不进审核轨道——否则通知会永远停在未发布态。
*/
public String boundFlowGuid(Integer schId) {
if (schId == null || !properties.isEnabled()) {
return null;
}
NoticeFlowBinding binding = bindingRepository.findBySchId(schId).orElse(null);
if (binding == null || !StringUtils.hasText(binding.getFlowGuid())) {
return null;
}
return binding.getFlowGuid();
}
/**
* 保存绑定(空 flowGuid=解绑,直接删行)。绑定的流程必须是流程中心"已发布"流程——
* 值由 options() 同一数据源校验,避免手改请求把不存在的 GUID 落库(发通知时才发现)。
*/
@Transactional
public Map<String, Object> saveBinding(BoaUser user, String flowGuid) {
noticeFlowGuard.requireAdmin(user);
Integer schId = currentSchId(user);
NoticeFlowBinding binding = bindingRepository.findBySchId(schId).orElse(null);
if (!StringUtils.hasText(flowGuid)) {
if (binding != null) {
bindingRepository.delete(binding);
log.info("[notice-flow] 解除流程绑定:schId={} 原流程={}", schId, binding.getFlowGuid());
}
return bindingView(schId, user);
}
String name = requirePublishedFlowName(schId, flowGuid.trim());
if (binding == null) {
binding = new NoticeFlowBinding();
binding.setSchId(schId);
}
binding.setFlowGuid(flowGuid.trim());
binding.setFlowName(name);
bindingRepository.save(binding);
log.info("[notice-flow] 保存流程绑定:schId={} flowGuid={} name={}", schId, binding.getFlowGuid(), name);
return bindingView(schId, user);
}
/** 校验流程 GUID 属于本校已发布流程并返回流程名(流程中心不可达时抛业务异常,不落脏绑定) */
private String requirePublishedFlowName(Integer schId, String flowGuid) {
for (FlowModels.FlowSummaryVo flow : options(schId)) {
if (flowGuid.equals(flow.getFlowGuid())) {
return flow.getName();
}
}
throw new BizException(ErrorCode.PARAM_ERROR, "流程不存在或未发布:" + flowGuid);
}
private Integer currentSchId(BoaUser user) {
if (user == null || user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "无法确定当前学校身份,请重新登录后再试");
}
return user.getCurrentRole().getSchId();
}
private Notice requireNotice(Integer noticeId) {
return noticeRepository.findById(noticeId)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId));
}
private JsonNode parseJson(String json) { if (!StringUtils.hasText(json)) {
return null;
}
try {
return objectMapper.readTree(json);
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,53 @@
package com.yunhai.notice.service.impl;
import com.yunhai.notice.model.SchTeaList;
import com.yunhai.notice.service.IClassService;
import com.yunhai.platform.openapi.clazz.YschClassOpenApi;
import com.yunhai.platform.openapi.clazz.model.SchTea;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* 班级管理(userclass)开放接口实现。<br>
* 2026-09-16 SDK 重构起改为委托类型化门面 {@link YschClassOpenApi}(服务目录 key=userclass:
* services/枚举兜底统一解析,签名/信封/错误语义由 SDK 承载),替代原手搓 RestTemplate +
* legacy 键 {@code yh.open.userclass} 直连(无签名旁路)。Notice 侧模型 {@link SchTeaList}
* 保留(消费方签名零改动),此处仅做字段映射。
*/
@Service
public class ClassService implements IClassService {
private static final Logger log = LoggerFactory.getLogger(ClassService.class);
@Override
public List<SchTeaList> getSchTeaList(int schId) {
List<SchTea> teachers = YschClassOpenApi.getSchTeaList(schId);
log.info("[getSchTeaList] schId={}, 获取教师 {} 人", schId, teachers == null ? 0 : teachers.size());
if (teachers == null || teachers.isEmpty()) {
return Collections.emptyList();
}
List<SchTeaList> result = new ArrayList<>(teachers.size());
for (SchTea tea : teachers) {
result.add(map(tea));
}
return result;
}
private static SchTeaList map(SchTea tea) {
SchTeaList t = new SchTeaList();
t.setSchSerID(tea.getSchSerId());
t.setTeaTel(tea.getTeaTel());
t.setTeaName(tea.getTeaName());
t.setUserTeaSerID(tea.getUserTeaSerId());
t.setTeaIDCard(tea.getTeaIdCard());
t.setTeaNo(tea.getTeaNo());
t.setGroupIds(tea.getGroupIds());
t.setGroupId(tea.getGroupId());
return t;
}
}
@@ -0,0 +1,413 @@
package com.yunhai.notice.service.impl;
import com.yunhai.notice.entity.ContactsGroup;
import com.yunhai.notice.entity.ContactsGroupUser;
import com.yunhai.notice.repository.ContactsGroupRepository;
import com.yunhai.notice.repository.ContactsGroupUserRepository;
import com.yunhai.notice.service.IContactsService;
import com.yunhai.platform.auth.LoginUser;
import com.yunhai.platform.auth.UserContextHolder;
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;
}
String account = currentAccount();
// 查重:同创建人下同名同类型组不重复
List<ContactsGroup> existing = groupRepository.findByCreatorAccount(account);
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.setCreatorAccount(account);
groupRepository.save(group);
}
// ============================ GetGroup ============================
@Override
public Map<String, Object> getGroup(Integer page, Integer count, Integer type) {
if (type == null) {
type = 1;
}
String account = currentAccount();
// 查询当前用户创建的全部联系组
List<ContactsGroup> allGroups = groupRepository.findByCreatorAccount(account);
// 统计每组人数
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, "参数错误!");
}
String account = currentAccount();
// 查重(排除自身)
List<ContactsGroup> existing = groupRepository.findByCreatorAccount(account);
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.setUserAccount(user.getAccount());
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 反查。
* 对应 C# 保存成员时同步写入 UserName/UserAccount 的行为。
* 查到的值会回写落库自愈,避免历史空数据每次读取都重复远程调用。
*/
private void fillUserInfo(Map<String, Object> item, ContactsGroupUser m) {
String name = m.getUserName();
String account = m.getUserAccount();
if (m.getUserId() != null && (!StringUtils.hasText(name) || !StringUtils.hasText(account))) {
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(account)) {
account = user.getAccount();
}
if (!StringUtils.hasText(name)) {
name = user.getName();
}
// 回写落库:下次读取直接命中快照
if ((!StringUtils.hasText(m.getUserAccount()) && StringUtils.hasText(account))
|| (!StringUtils.hasText(m.getUserName()) && StringUtils.hasText(name))) {
if (StringUtils.hasText(account)) {
m.setUserAccount(account);
}
if (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 : "");
item.put("account", account != null ? account : "");
}
// ============================ GetGroupTree ============================
@Override
public List<Map<String, Object>> getGroupTree(Integer type) {
if (type == null) {
type = 1;
}
String account = currentAccount();
// 查询当前用户的联系组
List<ContactsGroup> groups = groupRepository.findByCreatorAccount(account);
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;
}
/**
* 当前登录人账号(来自平台 UserContextHolder,无登录态时为 null)。
*/
private String currentAccount() {
LoginUser user = UserContextHolder.get();
return user == null ? null : user.getLoginName();
}
}
@@ -0,0 +1,253 @@
package com.yunhai.notice.service.impl;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.entity.SysDict;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.notice.repository.SysDictRepository;
import com.yunhai.notice.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 中的字典管理逻辑。
*/
@Service
public class DictService implements IDictService {
private static final Logger log = LoggerFactory.getLogger(DictService.class);
/** 字典类型常量:发文部门 */
private static final String DICT_TYPE_DEPT = "dept";
@Autowired
private SysDictRepository sysDictRepository;
@Autowired
private NoticeRepository noticeRepository;
// ============================ GetSysDic ============================
@Override
public List<Map<String, Object>> getSysDic(Integer type) {
// C# 中 Type=1 为部门类别(DEPT),其余类型(文印、请假等)本系统暂不涉及
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, "尚未添加发文部门,请联系管理员添加!");
}
// 按 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) {
// type="dept" 表示部门类别(DEPT)
String dictType = "dept".equalsIgnoreCase(type) ? DICT_TYPE_DEPT : null;
if (dictType == null) {
// 非 DEPT 类型需超管权限,本系统暂只支持 DEPT
throw new BizException(ErrorCode.FORBIDDEN, "非超管不得操作!");
}
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 addDeptDic(String name) {
if (!StringUtils.hasText(name)) {
throw new BizException(ErrorCode.PARAM_ERROR, "输入字符串格式不正确!");
}
// 校验重名
List<SysDict> existing = sysDictRepository.findByDictTypeOrderBySortOrderAsc(DICT_TYPE_DEPT);
boolean duplicate = existing.stream().anyMatch(d ->
DICT_TYPE_DEPT.equals(d.getDictType()) && name.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(DICT_TYPE_DEPT);
dict.setDictValue(name);
dict.setSortOrder(maxSort + 1);
dict.setDictKey(DICT_TYPE_DEPT + (maxSort + 1));
dict.setEnableFlag(1);
sysDictRepository.save(dict);
}
// ============================ UpdateDeptDic ============================
@Override
@Transactional
public void updateDeptDic(Integer serID, String name, Integer enable) {
if (serID == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误!");
}
// 校验重名(排除自身)
List<SysDict> existing = sysDictRepository.findByDictTypeOrderBySortOrderAsc(DICT_TYPE_DEPT);
boolean duplicate = existing.stream().anyMatch(d ->
!d.getId().equals(serID) && name.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);
}
if (enable != null) {
dict.setEnableFlag(enable);
}
sysDictRepository.save(dict);
}
// ============================ DelDept ============================
@Override
@Transactional
public void delDept(Integer serID) {
SysDict dict = sysDictRepository.findById(serID)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "字典记录不存在"));
// 校验是否被通知引用(按 deptId = dictKey 匹配)
// C# 中检查 Notify_Message.DeptID == dict.CodeID
// Java 中 Notice.deptId 存的是 dictKey
List<Notice> referenced = noticeRepository.findAll((root, query, cb) ->
cb.equal(root.get("deptId"), dict.getDictKey()));
if (!referenced.isEmpty()) {
throw new BizException(ErrorCode.FORBIDDEN, "已引用该部门,无法删除!");
}
sysDictRepository.delete(dict);
}
// ============================ OrdbyDicIds ============================
@Override
@Transactional
public void ordbyDicIds(String ids) {
if (!StringUtils.hasText(ids)) {
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误");
}
List<SysDict> list = sysDictRepository.findByDictTypeOrderBySortOrderAsc(DICT_TYPE_DEPT);
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# 中使用枚举,Java 版仅支持 DEPT(1)。
*/
private String mapDictType(Integer type) {
if (type == null) return null;
switch (type) {
case 1: return DICT_TYPE_DEPT;
// 其余类型(文印/请假/物品等)本系统不涉及
default: return null;
}
}
/**
* 当前登录人账号(来自平台 UserContextHolder,无登录态时为 null)。
*/
private String currentAccount() {
LoginUser user = UserContextHolder.get();
return user == null ? null : user.getLoginName();
}
}
@@ -0,0 +1,344 @@
package com.yunhai.notice.service.impl;
import com.yunhai.notice.entity.UpFile;
import com.yunhai.notice.model.notice.UpFileVo;
import com.yunhai.notice.model.notice.UpFileResultVo;
import com.yunhai.notice.repository.UpFileRepository;
import com.yunhai.notice.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;
/**
* 附件存储根目录(生产容器内 /data/upload,对应 projman 卷 /data/uploads/notice)。
* 平台标准(与 userhome/userbase/flow-center 同款):FILE_PATH 各子应用值相同
* (/data/upload),统一收敛在共享配置 application-platform.yml 一份,子应用不再各自维护;
* notice 曾走过渡键 FILE_UPLOAD_PATH(prod 配置 2026-10-10 已加,保留兼容);
* file.upload-path(application.yml 的 D:/data/upload)仅本地 Windows 开发用。
* 教训:生产曾因键名不匹配回落到 D:/data/upload——Linux 锚定为 /app/D:/data/upload
* 写进容器可写层,每次重新部署文件即丢(2026-10 事故)。
*/
@Value("${FILE_PATH:${FILE_UPLOAD_PATH:${file.upload-path:/data/upload}}}")
private String uploadPath;
private static final String DEFAULT_APP_TYPE = "notice";
/**
* 解析存储绝对路径。uploadPath 需以绝对路径配置(生产容器内 /data/upload);
* 若误配为相对路径,此处统一锚定到进程工作目录——否则 MultipartFile.transferTo
* 会把相对目标解析到 Tomcat multipart 临时目录,与后续读取(按工作目录解析)分裂。
*/
private Path resolvePath(String relative) {
return Paths.get(uploadPath, relative).toAbsolutePath().normalize();
}
// ============================ 上传 ============================
@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 = resolvePath(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 = resolvePath(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 = resolvePath(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 = resolvePath(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;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,284 @@
package com.yunhai.notice.service.impl;
import com.yunhai.notice.entity.SmsModule;
import com.yunhai.notice.entity.SmsSchoolConfig;
import com.yunhai.notice.repository.SmsModuleRepository;
import com.yunhai.notice.repository.SmsSchoolConfigRepository;
import com.yunhai.notice.service.ISmsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
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# SchModuleSmsController + SchModuleSms.cs。
* 注意:C# 原版依赖外部DLL(ISchIsSmsBLL/ISchoolBLL/ISchGroupBLL)获取学校和组数据,
* Java版改为从本地 sms_module + sms_school_config 表管理。
*/
@Service
public class SmsService implements ISmsService {
private static final Logger log = LoggerFactory.getLogger(SmsService.class);
@Autowired
private SmsModuleRepository smsModuleRepository;
@Autowired
private SmsSchoolConfigRepository smsSchoolConfigRepository;
// ============================ getmodule ============================
@Override
public List<Map<String, Object>> getModuleList() {
List<SmsModule> modules = smsModuleRepository.findAllByOrderByIdAsc();
return modules.stream().map(m -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("ID", m.getId());
item.put("Name", m.getModuleName());
return item;
}).collect(Collectors.toList());
}
// ============================ getgroup ============================
@Override
public List<Map<String, Object>> getSchParentGroup() {
// C# 从外部BLL获取学校组数据。Java版暂不接入用户中心API。
// 返回空列表,前端可按需处理。
log.info("[getSchParentGroup] 暂未接入用户中心API,返回空列表");
return Collections.emptyList();
}
// ============================ getschsms ============================
@Override
public Map<String, Object> getSchIsSmsList(Integer pageSize, Integer pageIndex, String schName) {
if (pageSize == null || pageSize <= 0) pageSize = 10;
// 前端 PageIndex 从0开始(page-1),后端按0基处理
if (pageIndex == null || pageIndex < 0) pageIndex = 0;
// 查询学校配置(按学校名模糊搜索或全部)
Page<SmsSchoolConfig> page;
int offset = pageIndex; // 0基
Sort sort = Sort.by(Sort.Direction.DESC, "schoolName");
if (StringUtils.hasText(schName)) {
page = smsSchoolConfigRepository.findBySchoolNameContaining(schName,
PageRequest.of(offset, pageSize, sort));
} else {
page = smsSchoolConfigRepository.findAll(PageRequest.of(offset, pageSize, sort));
}
// 加载全部模块列表
List<SmsModule> modules = smsModuleRepository.findAllByOrderByIdAsc();
// 按 schoolId 分组配置
Map<Integer, List<SmsSchoolConfig>> configBySchool = page.getContent().stream()
.collect(Collectors.groupingBy(SmsSchoolConfig::getSchoolId));
// 构建返回数据:每个学校一行,包含各模块的开关状态
List<Map<String, Object>> listData = page.getContent().stream()
.collect(Collectors.groupingBy(SmsSchoolConfig::getSchoolId))
.entrySet().stream()
.map(entry -> {
Integer schoolId = entry.getKey();
List<SmsSchoolConfig> configs = entry.getValue();
String schoolName = configs.get(0).getSchoolName();
// 构建模块开关矩阵
List<Map<String, Object>> moduleList = new ArrayList<>();
boolean isAll = true;
for (SmsModule module : modules) {
Map<String, Object> modu = new LinkedHashMap<>();
SmsSchoolConfig config = configs.stream()
.filter(c -> module.getId().equals(c.getModuleId()))
.findFirst().orElse(null);
modu.put("SerID", config != null ? config.getId() : -1);
modu.put("ModuID", module.getId());
modu.put("IsSms", config != null && Boolean.TRUE.equals(config.getIsSms()));
modu.put("SchSerID", schoolId);
moduleList.add(modu);
if (config == null || !Boolean.TRUE.equals(config.getIsSms())) {
isAll = false;
}
}
Map<String, Object> schoolItem = new LinkedHashMap<>();
schoolItem.put("SchSerID", schoolId);
schoolItem.put("SchName", schoolName);
schoolItem.put("IsAll", isAll);
schoolItem.put("itemsModu", moduleList);
return schoolItem;
})
.collect(Collectors.toList());
// 如果没有配置数据,返回空列表(但保留total)
if (listData.isEmpty() && page.hasContent()) {
// 这种情况不应发生,但防御性处理
listData = Collections.emptyList();
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("list", listData);
result.put("total", page.getTotalElements());
return result;
}
// ============================ getsch ============================
@Override
public List<Map<String, Object>> getSchList() {
// C# 从外部BLL获取学校列表。Java版从 sms_school_config 表提取已配置学校。
// 获取所有不重复的学校
List<SmsSchoolConfig> allConfigs = smsSchoolConfigRepository.findAll();
Map<Integer, String> schoolMap = new LinkedHashMap<>();
for (SmsSchoolConfig config : allConfigs) {
schoolMap.putIfAbsent(config.getSchoolId(), config.getSchoolName());
}
return schoolMap.entrySet().stream().map(entry -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("ID", entry.getKey());
item.put("Name", entry.getValue());
return item;
}).collect(Collectors.toList());
}
// ============================ setschsms ============================
@Override
@Transactional
public void setSchSms(Integer schSerID, Integer moduleId, Boolean isSms) {
if (schSerID == null || moduleId == null) {
return;
}
SmsSchoolConfig config = smsSchoolConfigRepository.findBySchoolIdAndModuleId(schSerID, moduleId);
if (config == null) {
config = new SmsSchoolConfig();
config.setSchoolId(schSerID);
config.setModuleId(moduleId);
// 学校名称从已有配置中获取
List<SmsSchoolConfig> existing = smsSchoolConfigRepository.findBySchoolId(schSerID);
if (!existing.isEmpty()) {
config.setSchoolName(existing.get(0).getSchoolName());
}
}
config.setIsSms(isSms != null ? isSms : false);
smsSchoolConfigRepository.save(config);
}
// ============================ setschallsms ============================
@Override
@Transactional
public void setSchSmsAll(Integer schSerID, Boolean isSms) {
if (schSerID == null) {
return;
}
List<SmsModule> modules = smsModuleRepository.findAllByOrderByIdAsc();
boolean sms = Boolean.TRUE.equals(isSms);
for (SmsModule module : modules) {
SmsSchoolConfig config = smsSchoolConfigRepository.findBySchoolIdAndModuleId(schSerID, module.getId());
if (config == null) {
config = new SmsSchoolConfig();
config.setSchoolId(schSerID);
config.setModuleId(module.getId());
List<SmsSchoolConfig> existing = smsSchoolConfigRepository.findBySchoolId(schSerID);
if (!existing.isEmpty()) {
config.setSchoolName(existing.get(0).getSchoolName());
}
}
config.setIsSms(sms);
smsSchoolConfigRepository.save(config);
}
}
// ============================ setschmodulesms ============================
@Override
@Transactional
public void setSchModuleSms(String schSerIDs, String modules, Boolean isSms) {
if (!StringUtils.hasText(schSerIDs) || !StringUtils.hasText(modules)) {
return;
}
List<Integer> schoolIds = Arrays.stream(schSerIDs.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(Integer::parseInt)
.collect(Collectors.toList());
List<Integer> moduleIds = Arrays.stream(modules.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(Integer::parseInt)
.collect(Collectors.toList());
boolean sms = Boolean.TRUE.equals(isSms);
for (Integer schoolId : schoolIds) {
for (Integer moduleId : moduleIds) {
SmsSchoolConfig config = smsSchoolConfigRepository.findBySchoolIdAndModuleId(schoolId, moduleId);
if (config == null) {
config = new SmsSchoolConfig();
config.setSchoolId(schoolId);
config.setModuleId(moduleId);
List<SmsSchoolConfig> existing = smsSchoolConfigRepository.findBySchoolId(schoolId);
if (!existing.isEmpty()) {
config.setSchoolName(existing.get(0).getSchoolName());
}
}
config.setIsSms(sms);
smsSchoolConfigRepository.save(config);
}
}
}
// ============================ setschsms (JSON 解析) ============================
@Override
@Transactional
public void setSchSmsFromJson(String itemsModuJson) {
Integer schSerID = extractJsonField(itemsModuJson, "SchSerID");
Integer moduID = extractJsonField(itemsModuJson, "ModuID");
Boolean isSms = extractJsonBool(itemsModuJson, "IsSms");
setSchSms(schSerID, moduID, isSms);
}
// ============================ 辅助方法 ============================
/**
* 从 JSON 字符串中提取整数字段。
*/
private Integer extractJsonField(String json, String field) {
if (json == null) return null;
String pattern = "\"" + field + "\"\\s*:\\s*\"?(-?\\d+)\"?";
java.util.regex.Pattern p = java.util.regex.Pattern.compile(pattern);
java.util.regex.Matcher m = p.matcher(json);
if (m.find()) {
return Integer.parseInt(m.group(1));
}
return null;
}
/**
* 从 JSON 字符串中提取布尔字段。
*/
private Boolean extractJsonBool(String json, String field) {
if (json == null) return null;
String pattern = "\"" + field + "\"\\s*:\\s*(true|false)";
java.util.regex.Pattern p = java.util.regex.Pattern.compile(pattern);
java.util.regex.Matcher m = p.matcher(json);
if (m.find()) {
return Boolean.parseBoolean(m.group(1));
}
return null;
}
}
@@ -0,0 +1,264 @@
package com.yunhai.notice.service.impl;
import com.yunhai.notice.entity.NoticeTemplate;
import com.yunhai.notice.entity.TemplateType;
import com.yunhai.notice.entity.UpFile;
import com.yunhai.notice.repository.NoticeTemplateRepository;
import com.yunhai.notice.repository.TemplateTypeRepository;
import com.yunhai.notice.repository.UpFileRepository;
import com.yunhai.notice.service.ITemplateService;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
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# TemplateController + Template.cs。
*/
@Service
public class TemplateService implements ITemplateService {
@Autowired
private TemplateTypeRepository templateTypeRepository;
@Autowired
private NoticeTemplateRepository noticeTemplateRepository;
@Autowired
private UpFileRepository upFileRepository;
// ============================ GetTypelist ============================
@Override
public List<Map<String, Object>> getTypelist() {
List<TemplateType> types = templateTypeRepository.findAllByOrderBySortOrderAsc();
List<Map<String, Object>> result = new ArrayList<>();
// 首项固定为"全部"
Map<String, Object> allItem = new LinkedHashMap<>();
allItem.put("TypeID", 0);
allItem.put("TypeName", "全部");
result.add(allItem);
for (TemplateType t : types) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("TypeID", t.getId());
item.put("TypeName", t.getTypeName());
result.add(item);
}
return result;
}
// ============================ EditType ============================
@Override
@Transactional
public void editType(Integer typeid, String typeName) {
if (!StringUtils.hasText(typeName)) {
throw new BizException(ErrorCode.PARAM_ERROR, "类型名称不能为空!");
}
// 重名校验
boolean exists;
if (typeid == null || typeid == 0) {
exists = templateTypeRepository.existsByTypeName(typeName);
} else {
exists = templateTypeRepository.existsByTypeNameAndIdNot(typeName, typeid);
}
if (exists) {
throw new BizException(ErrorCode.FORBIDDEN, "类型名称已存在!");
}
if (typeid == null || typeid == 0) {
// 新增
TemplateType type = new TemplateType();
type.setTypeName(typeName);
// sortOrder = max + 1
List<TemplateType> all = templateTypeRepository.findAllByOrderBySortOrderAsc();
int maxSort = all.stream()
.mapToInt(t -> t.getSortOrder() != null ? t.getSortOrder() : 0)
.max().orElse(0);
type.setSortOrder(maxSort + 1);
templateTypeRepository.save(type);
} else {
// 编辑
TemplateType type = templateTypeRepository.findById(typeid)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "类型不存在!"));
type.setTypeName(typeName);
templateTypeRepository.save(type);
}
}
// ============================ DelType ============================
@Override
@Transactional
public void delType(Integer typeid) {
if (typeid == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "类型不存在!");
}
TemplateType type = templateTypeRepository.findById(typeid)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "类型不存在!"));
// 校验是否有模板引用此类型
long refCount = noticeTemplateRepository.countByTypeId(typeid);
if (refCount > 0) {
throw new BizException(ErrorCode.FORBIDDEN, "类型存在模板!");
}
templateTypeRepository.delete(type);
}
// ============================ GetTemplatelist ============================
@Override
public Map<String, Object> getTemplatelist(Integer pageSize, Integer pageIndex, Integer typeid) {
if (pageSize == null || pageSize <= 0) pageSize = 10;
if (pageIndex == null || pageIndex <= 0) pageIndex = 1;
if (typeid == null) typeid = 0;
Page<NoticeTemplate> page;
int offset = pageIndex - 1;
if (typeid > 0) {
page = noticeTemplateRepository.findByTypeId(typeid,
PageRequest.of(offset, pageSize, Sort.by(Sort.Direction.DESC, "refCount")));
} else {
page = noticeTemplateRepository.findAllByOrderByRefCountDesc(PageRequest.of(offset, pageSize));
}
// 加载类型名映射
List<TemplateType> allTypes = templateTypeRepository.findAllByOrderBySortOrderAsc();
Map<Integer, String> typeNameMap = allTypes.stream()
.collect(Collectors.toMap(TemplateType::getId, TemplateType::getTypeName, (a, b) -> a));
// 加载封面图
List<Integer> coverIds = page.getContent().stream()
.map(NoticeTemplate::getCover)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
Map<Integer, String> coverUrlMap = new HashMap<>();
if (!coverIds.isEmpty()) {
for (Integer fid : coverIds) {
upFileRepository.findById(fid).ifPresent(f -> coverUrlMap.put(fid, f.getFileName()));
}
}
List<Map<String, Object>> listData = page.getContent().stream().map(t -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("SerID", t.getId());
item.put("Title", t.getTitle());
item.put("TypeName", typeNameMap.getOrDefault(t.getTypeId(), ""));
item.put("Contents", t.getContent());
item.put("ContentsFilter", filterHtml(t.getContent()));
item.put("Num", t.getRefCount() != null ? t.getRefCount() : 0);
item.put("imgurl", t.getCover() != null ? coverUrlMap.get(t.getCover()) : null);
return item;
}).collect(Collectors.toList());
Map<String, Object> result = new LinkedHashMap<>();
result.put("list", listData);
result.put("total", page.getTotalElements());
return result;
}
// ============================ EditTemplate ============================
@Override
@Transactional
public void editTemplate(Integer serID, String title, String content, Integer typeId, Integer cover) {
if (!StringUtils.hasText(title)) {
throw new BizException(ErrorCode.PARAM_ERROR, "模板标题不能为空!");
}
if (!StringUtils.hasText(content)) {
throw new BizException(ErrorCode.PARAM_ERROR, "模板内容不能为空!");
}
if (serID == null || serID == 0) {
// 新增
NoticeTemplate template = new NoticeTemplate();
template.setTitle(title);
template.setContent(content);
template.setTypeId(typeId);
template.setCover(cover);
template.setRefCount(0);
noticeTemplateRepository.save(template);
} else {
// 编辑
NoticeTemplate template = noticeTemplateRepository.findById(serID)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "模板不存在!"));
template.setTitle(title);
template.setContent(content);
template.setTypeId(typeId);
template.setCover(cover);
noticeTemplateRepository.save(template);
}
}
// ============================ DelTemplate ============================
@Override
@Transactional
public void delTemplate(Integer serID) {
if (serID == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误");
}
noticeTemplateRepository.findById(serID).ifPresent(noticeTemplateRepository::delete);
}
// ============================ GetTemplateFist ============================
@Override
public Map<String, Object> getTemplateFist(Integer serID) {
if (serID == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误");
}
NoticeTemplate t = noticeTemplateRepository.findById(serID)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "模板不存在!"));
// 加载类型名
String typeName = "";
if (t.getTypeId() != null) {
Optional<TemplateType> type = templateTypeRepository.findById(t.getTypeId());
if (type.isPresent()) {
typeName = type.get().getTypeName();
}
}
// 加载封面图
String imgurl = null;
if (t.getCover() != null) {
Optional<UpFile> file = upFileRepository.findById(t.getCover());
if (file.isPresent()) {
imgurl = file.get().getFileName();
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("SerID", t.getId());
result.put("Title", t.getTitle());
result.put("Type", t.getTypeId());
result.put("TypeName", typeName);
result.put("Contents", t.getContent());
result.put("Num", t.getRefCount() != null ? t.getRefCount() : 0);
result.put("imgurl", imgurl);
return result;
}
// ============================ 辅助方法 ============================
/**
* 过滤HTML标签,返回纯文本(对应 C# WebHelper.FilterHtml)。
*/
private String filterHtml(String html) {
if (html == null) return "";
return html.replaceAll("<[^>]+>", "").trim();
}
}

Some files were not shown because too many files have changed in this diff Show More