Compare commits
11
Commits
ab49ae2155
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dc7f38af0 | ||
|
|
cf77b6719c | ||
|
|
bb2bb93293 | ||
|
|
33398a7f8a | ||
|
|
07c409c83c | ||
|
|
14b4e4c24f | ||
|
|
7edf4323dd | ||
|
|
3eae05d77b | ||
|
|
58d0d6d973 | ||
|
|
6ac8229474 | ||
|
|
d8d11804db |
@@ -25,7 +25,10 @@ jobs:
|
|||||||
options: --network infra-infra-net
|
options: --network infra-infra-net
|
||||||
steps:
|
steps:
|
||||||
- name: 安装 git / curl(alpine)
|
- name: 安装 git / curl(alpine)
|
||||||
run: apk add --no-cache git curl
|
run: |
|
||||||
|
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
|
||||||
|
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
||||||
|
apk add --no-cache git curl
|
||||||
|
|
||||||
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||||
@@ -55,18 +58,24 @@ jobs:
|
|||||||
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
|
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
docker rm -f mavenbuild 2>/dev/null || true
|
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||||
docker run -d --name mavenbuild \
|
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||||
|
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||||
|
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
|
||||||
|
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||||
|
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||||
|
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
|
||||||
|
docker run -d --name "$BUILD_CONTAINER" \
|
||||||
--network infra-infra-net \
|
--network infra-infra-net \
|
||||||
-v /opt/docker/projman-m2:/root/.m2 \
|
-v /opt/docker/projman-m2:/root/.m2 \
|
||||||
-w /build maven:3.6.3-jdk-8 sleep 3600
|
-w /build maven:3.6.3-jdk-8 sleep 3600
|
||||||
docker cp "${PWD}/." mavenbuild:/build/
|
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||||
docker exec mavenbuild sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
|
docker exec "$BUILD_CONTAINER" sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
|
||||||
docker exec mavenbuild sh -c 'cd /build && mvn -B -DskipTests clean package'
|
docker exec "$BUILD_CONTAINER" sh -c 'cd /build && mvn -B -DskipTests clean package'
|
||||||
docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar'
|
docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
|
||||||
mkdir -p "${PWD}/target"
|
mkdir -p "${PWD}/target"
|
||||||
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar"
|
docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
|
||||||
docker rm -f mavenbuild
|
docker rm -f "$BUILD_CONTAINER"
|
||||||
|
|
||||||
- name: 构建并推送镜像
|
- name: 构建并推送镜像
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+26
-7
@@ -63,7 +63,7 @@ com.yunhai.familyactivity
|
|||||||
├── entity/
|
├── entity/
|
||||||
│ ├── BaseEntity / SysDict / UpFile / ContactsGroup / ContactsGroupUser # 保留
|
│ ├── BaseEntity / SysDict / UpFile / ContactsGroup / ContactsGroupUser # 保留
|
||||||
│ ├── Activity # 新建:活动主表
|
│ ├── Activity # 新建:活动主表
|
||||||
│ ├── ActivityRead # 新建:已读记录(班级粒度)
|
│ ├── ActivityRead # 新建:已读记录(学生粒度)
|
||||||
│ ├── ActivitySignup # 新建:回执/报名记录
|
│ ├── ActivitySignup # 新建:回执/报名记录
|
||||||
│ └── OperationLogRecord # 新建:操作日志表(参考 UserclassService)
|
│ └── OperationLogRecord # 新建:操作日志表(参考 UserclassService)
|
||||||
├── enums/ # 新建:ActivityStatusEnum、ReceiptModeEnum、SignupStatusEnum
|
├── enums/ # 新建:ActivityStatusEnum、ReceiptModeEnum、SignupStatusEnum
|
||||||
@@ -175,11 +175,14 @@ com.yunhai.familyactivity
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| id | int 自增 | |
|
| id | int 自增 | |
|
||||||
| activityId | int | 活动 id |
|
| activityId | int | 活动 id |
|
||||||
| classId | varchar(50) | **班级粒度已读**(前端关键约定 4:`classRead`) |
|
| classId | varchar(50) | 班级 id |
|
||||||
| account | varchar(50) | 操作账号(家长=学生账号) |
|
| studentId | int | 阅读学生ID(家长端以学生身份打开;学生档案不可得时为 null) |
|
||||||
| readTime | datetime | 阅读时间 |
|
| readTime | datetime | 阅读时间 |
|
||||||
|
|
||||||
唯一约束 `(activity_id, class_id)`;应收/已读人数按班级 `studentCount` 累加(班级与学生数取自 `ClassService`/基础平台接口)。
|
唯一约束 `(activity_id, class_id, student_id)`(`uk_activity_class_student`;2026-09-23 由班级粒度 `(activity_id, class_id)`
|
||||||
|
迁移而来,SQL 见 `docs/sql/20260923_activity_read_per_student.sql`,`ddl-auto=update` 不会自动改唯一键,各环境需手动执行)。
|
||||||
|
**已读人数 = 接收班级内的已读记录条数**(即实际打开过活动的家长数,每个家长一条,重复打开幂等);
|
||||||
|
班级「读/未读」= 该班是否存在任一已读记录;应读人数(allowCount)仍为接收班级学生数之和(取自 `ClassService`/基础平台接口)。
|
||||||
|
|
||||||
#### `ActivitySignup`(回执/报名记录,表 `fa_activity_signup`)
|
#### `ActivitySignup`(回执/报名记录,表 `fa_activity_signup`)
|
||||||
|
|
||||||
@@ -210,8 +213,8 @@ com.yunhai.familyactivity
|
|||||||
|
|
||||||
1. **发布**(Create):draft=true 存草稿;否则置已发;`targetClassIds()` 统一解析 `receiveList`(`"ALL"`=全校班级);发布时按班级×学生**预生成回执记录**(receiptMode≠none 时,status=none),保证「应收人数」「未回执名单」可查。
|
1. **发布**(Create):draft=true 存草稿;否则置已发;`targetClassIds()` 统一解析 `receiveList`(`"ALL"`=全校班级);发布时按班级×学生**预生成回执记录**(receiptMode≠none 时,status=none),保证「应收人数」「未回执名单」可查。
|
||||||
2. **状态机**:`actStatus` 推导规则 —— status=草稿→`draft`;now < signupDeadline→`signup`;signupDeadline ≤ now < endTime→`closed`;now ≥ endTime→`finished`(receiptMode=none 时无报名态语义,仅展示)。
|
2. **状态机**:`actStatus` 推导规则 —— status=草稿→`draft`;now < signupDeadline→`signup`;signupDeadline ≤ now < endTime→`closed`;now ≥ endTime→`finished`(receiptMode=none 时无报名态语义,仅展示)。
|
||||||
3. **进度统计**(`ActivityProgress`):`should`=应回执人数;`signed/confirmed/canceled` 按 signup 表 status 计数;`unsigned=should-signed-confirmed-canceled`;`usedQuota`=ΣjoinCount(canceled 不计);`full = quota>0 && usedQuota≥quota`;`signupRate`。`receiptMode=none` 或 `draft` 时 `progress=null`。
|
3. **进度统计**(`ActivityProgress`):`should`=应回执人数;`signed/confirmed/canceled` 按 signup 表 status 计数;`unsigned=should-signed-confirmed-canceled`;`usedQuota`=ΣjoinCount(canceled 不计);`full = quota>0 && usedQuota≥quota`;`signupRate`;`readCount`=已读人数(学生粒度:接收班级内已读记录条数=实际阅读家长数,仅 `/Signups` 汇总填充,口径同列表 `readCount` 与详情 `readnumcount`)、`readRate`=阅读率%(readCount/should 取整)。`receiptMode=none` 或 `draft` 时 `progress=null`。
|
||||||
4. **已读**:`Detail(type=1)` / `MySignup` 标读(家长端打开即确认模式:`receiptMode=confirm` 且状态为 none 时同步置 `confirmed`);`InitializeRed` 一键全部已读;`ReadDetail` 按年级→班级分组返回读/未读。
|
4. **已读**:学生粒度——`Detail(type=1)` / `MySignup` 标读(每个家长账号各记一条,重复打开幂等;打开即确认模式 `receiptMode=confirm` 且状态为 none 时同步置 `confirmed`);`InitializeRed` 一键全部已读(为当前学生逐活动补记录);家长端列表/收件箱的已读标志按「本人学号已读」判定;`ReadDetail` 按年级→班级分组返回读/未读(该班任一学生已读即记为读)。
|
||||||
5. **报名**(Signup/CancelSignup):校验活动状态(signup 中)、截止时间、名额(quota>0 时 usedQuota+joinCount≤quota)、随行(allowAccompany);成功/失败回执业务码。
|
5. **报名**(Signup/CancelSignup):校验活动状态(signup 中)、截止时间、名额(quota>0 时 usedQuota+joinCount≤quota)、随行(allowAccompany);成功/失败回执业务码。
|
||||||
6. **管理动作**:Edit(含 sourceId 复用)、Delete(批量 + 级联删 read/signup)、UserPull 撤回、SetTopmost、ReceiveClasses 补发(新班级追加并补回执记录)、Expedite/Remind 催办(本期日志占位)、ExportSignups 导出 CSV(easyexcel 已在 pom,CSV 直接拼文本返回)。
|
6. **管理动作**:Edit(含 sourceId 复用)、Delete(批量 + 级联删 read/signup)、UserPull 撤回、SetTopmost、ReceiveClasses 补发(新班级追加并补回执记录)、Expedite/Remind 催办(本期日志占位)、ExportSignups 导出 CSV(easyexcel 已在 pom,CSV 直接拼文本返回)。
|
||||||
7. **家长端**:`MyActivities` 分桶(todo 待回执/signed 已报名/finished 已结束/all)+ 计数;`MySignup` 活动详情+我的回执(同时标读/自动确认)。
|
7. **家长端**:`MyActivities` 分桶(todo 待回执/signed 已报名/finished 已结束/all)+ 计数;`MySignup` 活动详情+我的回执(同时标读/自动确认)。
|
||||||
@@ -238,7 +241,7 @@ com.yunhai.familyactivity
|
|||||||
| POST | `/SetTopmost` | serid, topmost | 置顶 |
|
| POST | `/SetTopmost` | serid, topmost | 置顶 |
|
||||||
| POST | `/Expedite` | serid | 催办未读(日志占位) |
|
| POST | `/Expedite` | serid | 催办未读(日志占位) |
|
||||||
| POST | `/ReceiveClasses` | serid, classes | 追加接收班级(补回执) |
|
| POST | `/ReceiveClasses` | serid, classes | 追加接收班级(补回执) |
|
||||||
| GET | `/Signups` | serid, signupStatus | 回执名单(按班级分组+汇总) |
|
| GET | `/Signups` | serid, signupStatus | 回执名单(按班级分组+汇总;summary 含 `readCount`/`readRate`,名单项含 `read` 已读/未读) |
|
||||||
| GET | `/SignupDetail` | signupId | 单条回执详情 |
|
| GET | `/SignupDetail` | signupId | 单条回执详情 |
|
||||||
| POST | `/Review` | ReviewRequest | 处理回执 confirm/cancel/waitlist |
|
| POST | `/Review` | ReviewRequest | 处理回执 confirm/cancel/waitlist |
|
||||||
| POST | `/Remind` | serid | 催报名(日志占位,返回 {reminded}) |
|
| POST | `/Remind` | serid | 催报名(日志占位,返回 {reminded}) |
|
||||||
@@ -429,3 +432,19 @@ npm run dev # 首次需 npm install(Node≥20 建议 --ignore-scripts
|
|||||||
经逐项核对,§九的关键修复在该迭代中**全部保留**:数据隔离(`delete/review/allFileDown` 补 `requireActivity`)、家长可见性 `ensureParentVisible`、报名悲观锁 `requireActivityForUpdate`、LIKE 转义、CSV 公式注入防护、receiveClasses 本校校验、超管 schId=0 拒绝发布、编辑保留置顶、`FileController` topicType=activity、导出 `{csv}` 契约等。迭代后已重新 `mvn compile` BUILD SUCCESS,并在真实环境(lijing 登录)完成 §9.4 所列闭环验证。
|
经逐项核对,§九的关键修复在该迭代中**全部保留**:数据隔离(`delete/review/allFileDown` 补 `requireActivity`)、家长可见性 `ensureParentVisible`、报名悲观锁 `requireActivityForUpdate`、LIKE 转义、CSV 公式注入防护、receiveClasses 本校校验、超管 schId=0 拒绝发布、编辑保留置顶、`FileController` topicType=activity、导出 `{csv}` 契约等。迭代后已重新 `mvn compile` BUILD SUCCESS,并在真实环境(lijing 登录)完成 §9.4 所列闭环验证。
|
||||||
|
|
||||||
> 供核对:`fa_activity_signup` 实际唯一索引为 `uk_fa_signup_activity_student` 与 Hibernate 自动生成的同名约束(二者等价,冗余无害,可择机合并)。
|
> 供核对:`fa_activity_signup` 实际唯一索引为 `uk_fa_signup_activity_student` 与 Hibernate 自动生成的同名约束(二者等价,冗余无害,可择机合并)。
|
||||||
|
|
||||||
|
### 9.7 字典学校隔离 + 审计列改存用户ID(2026-09-20)
|
||||||
|
|
||||||
|
两处收口变更,与 §9.6「账号 → ID」体系一脉相承:
|
||||||
|
|
||||||
|
1. **`sys_dict` 增加 `sch_id`(学校隔离)**
|
||||||
|
- 实体 `SysDict` 增 `schId`(默认 0);`SysDictRepository` 增 `findByDictTypeAndSchIdIn...` 系列(读口径为 `[当前学校, 0]`)。
|
||||||
|
- `DictService` 读写全部限「本校 + 全局种子(`sch_id=0`)」:下拉/管理列表按范围查;新增归属当前学校;改/删/排序经 `findOwned` 校验,仅允许操作本校行或全局种子行(历史 `sch_id` 为空按全局处理),跨校操作抛 `无权操作其他学校的字典`。
|
||||||
|
- `resolveDeptName` 先在本校范围内解析(本校行优先),未命中退回不区分学校的旧口径,保证历史活动仍能展示部门名。
|
||||||
|
- 迁移脚本:`src/main/resources/sql/fa_sys_dict_sch_id_and_audit.sql`(加列 + 存量置 0 + 索引 + 全局种子 + 验证查询);建表脚本 `fa_activity_tables.sql` 补 `sys_dict` DDL。
|
||||||
|
2. **`create_by` / `update_by` 由登录账号改为平台用户ID(INT 列)**
|
||||||
|
- `BaseEntity` 两字段改为 `Integer`,`NoticeAuditListener` 取 `UserContext.getUserId()`(`BoaUser.id`)直接落 INT;`UserContextHolder` 兜底仅接受纯数字 `userId`(平台默认 Provider 会放入登录名,非数字则留空)。
|
||||||
|
- `ActivityVo.createBy/updateBy` 同步改 `Integer`,JSON 输出为数字;前端契约 `types/activity.ts` 改 `number | null`(mock 演示数据置 null)。
|
||||||
|
- 历史行为账号文本,无法在本库映射为用户ID:迁移脚本先将非纯数字文本置 NULL,再把列 `VARCHAR(50)` 改为 `INT`(涉及继承 BaseEntity 的全部 7 张表),纯数字历史值直接转换保留。
|
||||||
|
|
||||||
|
验收:`mvn compile` BUILD SUCCESS。注意:`createBy/updateBy` 改 INT 后前端契约同步为 `number | null`,`fa_sys_dict_sch_id_and_audit.sql` 需由 DBA 执行(含审计列 VARCHAR→INT 转换)。**本地库 `family_activity` 已于 2026-09-20 执行**(`sch_id` 列此前已由 ddl-auto 自动添加,脚本跳过加列直接归零回填;存量账号文本 `lijing` 置 NULL 后 14 个审计列转为 `int(11)`,并建 `idx_sys_dict_sch_type` 索引;因库中已有真实字典数据(发文部/运动,归入全局种子),未执行种子 INSERT 段)。
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.yunhai.familyactivity.common.listener;
|
package com.yunhai.familyactivity.common.listener;
|
||||||
|
|
||||||
import com.yunhai.familyactivity.entity.BaseEntity;
|
import com.yunhai.familyactivity.entity.BaseEntity;
|
||||||
|
import com.yunhai.familyactivity.utils.UserContext;
|
||||||
import com.yunhai.platform.auth.LoginUser;
|
import com.yunhai.platform.auth.LoginUser;
|
||||||
import com.yunhai.platform.auth.UserContextHolder;
|
import com.yunhai.platform.auth.UserContextHolder;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -12,7 +13,8 @@ import java.time.LocalDateTime;
|
|||||||
/**
|
/**
|
||||||
* JPA 审计监听器:在实体持久化/更新前自动填充创建人/更新人(平台当前登录人)与时间戳。
|
* JPA 审计监听器:在实体持久化/更新前自动填充创建人/更新人(平台当前登录人)与时间戳。
|
||||||
* 对应 {@link BaseEntity} 的 createBy / updateBy / createTime / updateTime 字段。
|
* 对应 {@link BaseEntity} 的 createBy / updateBy / createTime / updateTime 字段。
|
||||||
* 当前登录人取自基础平台 {@link UserContextHolder}。
|
* <p>当前登录人取平台用户ID({@code UserContext.getUserId()},由 {@code ControllerUserContextAspect} 从
|
||||||
|
* {@code BoaUser.id} 填充),审计列为 INT;不再落登录账号。无登录态(定时任务/脚本)时留空。</p>
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class NoticeAuditListener {
|
public class NoticeAuditListener {
|
||||||
@@ -22,26 +24,43 @@ public class NoticeAuditListener {
|
|||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
entity.setCreateTime(now);
|
entity.setCreateTime(now);
|
||||||
entity.setUpdateTime(now);
|
entity.setUpdateTime(now);
|
||||||
String account = currentAccount();
|
Integer userId = currentUserId();
|
||||||
if (account != null) {
|
if (userId != null) {
|
||||||
if (entity.getCreateBy() == null) {
|
if (entity.getCreateBy() == null) {
|
||||||
entity.setCreateBy(account);
|
entity.setCreateBy(userId);
|
||||||
}
|
}
|
||||||
entity.setUpdateBy(account);
|
entity.setUpdateBy(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
public void preUpdate(BaseEntity entity) {
|
public void preUpdate(BaseEntity entity) {
|
||||||
entity.setUpdateTime(LocalDateTime.now());
|
entity.setUpdateTime(LocalDateTime.now());
|
||||||
String account = currentAccount();
|
Integer userId = currentUserId();
|
||||||
if (account != null) {
|
if (userId != null) {
|
||||||
entity.setUpdateBy(account);
|
entity.setUpdateBy(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String currentAccount() {
|
/**
|
||||||
|
* 当前登录人平台用户ID。优先取 UserContext 中已解析的数字ID;
|
||||||
|
* 兜底 {@link LoginUser#getUserId()}(平台默认 Provider 会放入登录名,非纯数字则忽略)。
|
||||||
|
*/
|
||||||
|
private Integer currentUserId() {
|
||||||
|
Integer userId = UserContext.getUserId();
|
||||||
|
if (userId != null) {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
LoginUser user = UserContextHolder.get();
|
LoginUser user = UserContextHolder.get();
|
||||||
return user == null ? null : user.getLoginName();
|
String raw = user == null ? null : user.getUserId();
|
||||||
|
if (raw == null || raw.trim().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
raw = raw.trim();
|
||||||
|
try {
|
||||||
|
return Integer.valueOf(raw);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ import java.time.LocalDateTime;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 活动已读记录(fa_activity_read)。
|
* 活动已读记录(fa_activity_read)。
|
||||||
* 已读按「班级」粒度(前端 classRead 约定):应收/已读人数按班级 studentCount 累加;
|
* 已读按「学生(家庭)」粒度:每个家长账号打开一次记一条(activity+class+student 唯一),
|
||||||
* 同一活动同一班级只有一条记录,由唯一约束兜底。
|
* 已读人数 = 接收班级内的已读记录条数;班级的「读/未读」= 该班是否存在任一已读记录。
|
||||||
*/
|
*/
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "fa_activity_read",
|
@Table(name = "fa_activity_read",
|
||||||
uniqueConstraints = @UniqueConstraint(columnNames = {"activity_id", "class_id"}))
|
uniqueConstraints = @UniqueConstraint(name = "uk_activity_class_student",
|
||||||
|
columnNames = {"activity_id", "class_id", "student_id"}))
|
||||||
public class ActivityRead extends BaseEntity {
|
public class ActivityRead extends BaseEntity {
|
||||||
|
|
||||||
/** 活动ID */
|
/** 活动ID */
|
||||||
@@ -29,7 +30,7 @@ public class ActivityRead extends BaseEntity {
|
|||||||
@Column(name = "class_id", length = 50, nullable = false)
|
@Column(name = "class_id", length = 50, nullable = false)
|
||||||
private String classId;
|
private String classId;
|
||||||
|
|
||||||
/** 首读学生ID(家长端以学生身份打开,存学生ID) */
|
/** 阅读学生ID(家长端以学生身份打开;学生档案不可得时为 null,此时退化为班级粒度) */
|
||||||
@Column(name = "student_id")
|
@Column(name = "student_id")
|
||||||
private Integer studentId;
|
private Integer studentId;
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ public abstract class BaseEntity implements Serializable {
|
|||||||
private Integer id;
|
private Integer id;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建人登录账号
|
* 创建人ID(平台用户ID,不再存登录账号)
|
||||||
*/
|
*/
|
||||||
@Column(name = "create_by", updatable = false)
|
@Column(name = "create_by", updatable = false)
|
||||||
private String createBy;
|
private Integer createBy;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建时间
|
* 创建时间
|
||||||
@@ -36,10 +36,10 @@ public abstract class BaseEntity implements Serializable {
|
|||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新人登录账号
|
* 更新人ID(平台用户ID,不再存登录账号)
|
||||||
*/
|
*/
|
||||||
@Column(name = "update_by")
|
@Column(name = "update_by")
|
||||||
private String updateBy;
|
private Integer updateBy;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新时间
|
* 更新时间
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import javax.persistence.Table;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 系统字典(发文部门等)。对应原 .NET Common_DeptDic 表。
|
* 系统字典(发文部门等)。对应原 .NET Common_DeptDic 表。
|
||||||
|
* <p>按学校隔离(sch_id):读写取当前 {@code UserContext.getSchId()};
|
||||||
|
* {@code schId=0} 视为全局种子(学校无自有字典时兜底可用),历史 {@code sch_id} 为空的行同样按全局处理。</p>
|
||||||
*/
|
*/
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@Data
|
@Data
|
||||||
@@ -18,6 +20,10 @@ import javax.persistence.Table;
|
|||||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||||
public class SysDict extends BaseEntity {
|
public class SysDict extends BaseEntity {
|
||||||
|
|
||||||
|
/** 学校ID(多租户隔离;0=全局种子,所有学校可见) */
|
||||||
|
@Column(name = "sch_id")
|
||||||
|
private Integer schId = 0;
|
||||||
|
|
||||||
/** 字典类型(如 "dept"=发文部门) */
|
/** 字典类型(如 "dept"=发文部门) */
|
||||||
@Column(name = "dict_type", length = 50)
|
@Column(name = "dict_type", length = 50)
|
||||||
private String dictType;
|
private String dictType;
|
||||||
|
|||||||
@@ -43,8 +43,14 @@ public class ActivityProgress {
|
|||||||
/** 待处理回执(名额超额/待定),统计用,列表场景恒为 0 */
|
/** 待处理回执(名额超额/待定),统计用,列表场景恒为 0 */
|
||||||
private int pendingReview;
|
private int pendingReview;
|
||||||
|
|
||||||
|
/** 已读人数(学生粒度:接收班级内已读记录条数,即实际打开过的家长数,与列表 readCount / 详情 readnumcount 同口径) */
|
||||||
|
private int readCount;
|
||||||
|
|
||||||
|
/** 阅读率(readCount / should,四舍五入取整 %) */
|
||||||
|
private int readRate;
|
||||||
|
|
||||||
public ActivityProgress(int should, int signed, int confirmed, int canceled, int unsigned,
|
public ActivityProgress(int should, int signed, int confirmed, int canceled, int unsigned,
|
||||||
double signupRate, int quota, int usedQuota, boolean full) {
|
double signupRate, int quota, int usedQuota, boolean full) {
|
||||||
this(should, signed, confirmed, canceled, unsigned, signupRate, quota, usedQuota, full, 0);
|
this(should, signed, confirmed, canceled, unsigned, signupRate, quota, usedQuota, full, 0, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,11 +105,13 @@ public class ActivityVo {
|
|||||||
/** 回执/报名进度(草稿或仅通知为 null) */
|
/** 回执/报名进度(草稿或仅通知为 null) */
|
||||||
private ActivityProgress progress;
|
private ActivityProgress progress;
|
||||||
|
|
||||||
private String createBy;
|
/** 创建人ID(平台用户ID) */
|
||||||
|
private Integer createBy;
|
||||||
|
|
||||||
private String createTime;
|
private String createTime;
|
||||||
|
|
||||||
private String updateBy;
|
/** 更新人ID(平台用户ID) */
|
||||||
|
private Integer updateBy;
|
||||||
|
|
||||||
private String updateTime;
|
private String updateTime;
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ public class SignupBrief {
|
|||||||
/** 是否已回执/已报名 */
|
/** 是否已回执/已报名 */
|
||||||
private boolean hasReceipt;
|
private boolean hasReceipt;
|
||||||
|
|
||||||
|
/** 阅读状态:该生本人是否已打开过活动(fa_activity_read 学生粒度) */
|
||||||
|
private boolean read;
|
||||||
|
|
||||||
/** 备注(如过敏史、随行人数说明) */
|
/** 备注(如过敏史、随行人数说明) */
|
||||||
private String remark;
|
private String remark;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,17 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 活动已读记录仓库(班级粒度)。
|
* 活动已读记录仓库(学生粒度:activity + class + student 唯一)。
|
||||||
*/
|
*/
|
||||||
public interface ActivityReadRepository extends JpaRepository<ActivityRead, Integer> {
|
public interface ActivityReadRepository extends JpaRepository<ActivityRead, Integer> {
|
||||||
|
|
||||||
Optional<ActivityRead> findByActivityIdAndClassId(Integer activityId, String classId);
|
Optional<ActivityRead> findByActivityIdAndClassId(Integer activityId, String classId);
|
||||||
|
|
||||||
|
/** 学生粒度幂等判定(studentId 为 null 时由调用方退化为班级粒度判定) */
|
||||||
|
boolean existsByActivityIdAndClassIdAndStudentId(Integer activityId, String classId, Integer studentId);
|
||||||
|
|
||||||
|
Optional<ActivityRead> findByActivityIdAndClassIdAndStudentId(Integer activityId, String classId, Integer studentId);
|
||||||
|
|
||||||
List<ActivityRead> findByActivityId(Integer activityId);
|
List<ActivityRead> findByActivityId(Integer activityId);
|
||||||
|
|
||||||
List<ActivityRead> findByActivityIdIn(Collection<Integer> activityIds);
|
List<ActivityRead> findByActivityIdIn(Collection<Integer> activityIds);
|
||||||
|
|||||||
@@ -6,10 +6,22 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统字典仓储。
|
||||||
|
* <p>多租户口径:读走 {@code ...SchId...}(服务层先查本校,空则回退 sch_id=0 全局种子);
|
||||||
|
* 写走 {@code findById},由服务层做同校校验(必要时先 copy-on-write 复制种子)。</p>
|
||||||
|
*/
|
||||||
public interface SysDictRepository extends JpaRepository<SysDict, Integer> {
|
public interface SysDictRepository extends JpaRepository<SysDict, Integer> {
|
||||||
List<SysDict> findByDictTypeOrderBySortOrderAsc(String dictType);
|
|
||||||
List<SysDict> findByDictTypeAndEnableFlagOrderBySortOrderAsc(String dictType, Integer enableFlag);
|
|
||||||
|
|
||||||
/** 按类型+key 取单条字典(发文部门名称解析用) */
|
/** 某学校某类型的字典(管理列表),sortOrder 升序 */
|
||||||
|
List<SysDict> findBySchIdAndDictTypeOrderBySortOrderAsc(Integer schId, String dictType);
|
||||||
|
|
||||||
|
/** 某学校某类型且启用的字典(下拉),sortOrder 升序 */
|
||||||
|
List<SysDict> findBySchIdAndDictTypeAndEnableFlagOrderBySortOrderAsc(Integer schId, String dictType, Integer enableFlag);
|
||||||
|
|
||||||
|
/** 某学校某类型按 key 取单条(部门名称解析用) */
|
||||||
|
Optional<SysDict> findBySchIdAndDictTypeAndDictKey(Integer schId, String dictType, String dictKey);
|
||||||
|
|
||||||
|
/** 按类型+key 取单条字典(不区分学校,用于历史数据/兜底解析) */
|
||||||
Optional<SysDict> findByDictTypeAndDictKey(String dictType, String dictKey);
|
Optional<SysDict> findByDictTypeAndDictKey(String dictType, String dictKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.yunhai.familyactivity.service;
|
package com.yunhai.familyactivity.service;
|
||||||
|
|
||||||
import com.yunhai.familyactivity.entity.UpFile;
|
import com.yunhai.familyactivity.entity.UpFile;
|
||||||
import com.yunhai.familyactivity.model.UpFileVo;
|
|
||||||
import com.yunhai.familyactivity.model.UpFileResultVo;
|
import com.yunhai.familyactivity.model.UpFileResultVo;
|
||||||
|
import com.yunhai.familyactivity.model.UpFileVo;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletResponse;
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
|||||||
@@ -504,20 +504,33 @@ public class ActivityService implements IActivityService {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
Map<Integer, SchClassDto> classes = schoolClasses(schId);
|
Map<Integer, SchClassDto> classes = schoolClasses(schId);
|
||||||
|
List<Integer> targetIds = targetClassIds(activity, classes);
|
||||||
|
Set<Integer> targetSet = new LinkedHashSet<>(targetIds);
|
||||||
|
|
||||||
|
// 已读统计(学生粒度):readCount = 接收班级内的已读记录条数(即实际打开过的家长数),
|
||||||
|
// readRate = readCount/should 取整;readKeys 供名单行按「该生本人是否已读」填充 read(与 SignupDetail 判定一致)
|
||||||
|
List<ActivityRead> reads = activityReadRepository.findByActivityId(serid);
|
||||||
|
Set<String> readKeys = reads.stream()
|
||||||
|
.map(r -> readKey(r.getClassId(), r.getStudentId()))
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
Map<String, List<ActivitySignup>> byClass = filtered.stream()
|
Map<String, List<ActivitySignup>> byClass = filtered.stream()
|
||||||
.collect(Collectors.groupingBy(r -> r.getClassName() != null ? r.getClassName()
|
.collect(Collectors.groupingBy(r -> r.getClassName() != null ? r.getClassName()
|
||||||
: classNameOf(classes, parseSafe(r.getClassId())), LinkedHashMap::new, Collectors.toList()));
|
: classNameOf(classes, parseSafe(r.getClassId())), LinkedHashMap::new, Collectors.toList()));
|
||||||
|
|
||||||
List<SignupGroup> groups = byClass.entrySet().stream()
|
List<SignupGroup> groups = byClass.entrySet().stream()
|
||||||
.map(e -> new SignupGroup(e.getKey(), e.getValue().stream()
|
.map(e -> new SignupGroup(e.getKey(), e.getValue().stream()
|
||||||
.map(this::toBrief).collect(Collectors.toList())))
|
.map(r -> toBrief(r, readKeys)).collect(Collectors.toList())))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
Map<Integer, List<SchStudentDto>> byClassStudents = studentsByClass(schId, targetClassIds(activity, classes));
|
Map<Integer, List<SchStudentDto>> byClassStudents = studentsByClass(schId, targetIds);
|
||||||
int should = byClassStudents.values().stream().mapToInt(List::size).sum();
|
int should = byClassStudents.values().stream().mapToInt(List::size).sum();
|
||||||
if (should == 0 && !records.isEmpty()) {
|
if (should == 0 && !records.isEmpty()) {
|
||||||
should = records.size(); // 班级数据不可达时降级为回执表实有人数
|
should = records.size(); // 班级数据不可达时降级为回执表实有人数
|
||||||
}
|
}
|
||||||
|
int readCount = (int) reads.stream()
|
||||||
|
.filter(r -> targetSet.contains(parseSafe(r.getClassId())))
|
||||||
|
.count();
|
||||||
|
|
||||||
SignupsResultVo vo = new SignupsResultVo();
|
SignupsResultVo vo = new SignupsResultVo();
|
||||||
vo.setActivityId(activity.getId());
|
vo.setActivityId(activity.getId());
|
||||||
@@ -532,7 +545,10 @@ public class ActivityService implements IActivityService {
|
|||||||
ReceiptModeEnum mode = ReceiptModeEnum.of(activity.getReceiptMode());
|
ReceiptModeEnum mode = ReceiptModeEnum.of(activity.getReceiptMode());
|
||||||
vo.setReceiptMode(mode.getCode());
|
vo.setReceiptMode(mode.getCode());
|
||||||
vo.setReceiptModeDesc(mode.getDesc());
|
vo.setReceiptModeDesc(mode.getDesc());
|
||||||
vo.setSummary(computeProgress(activity, records, should));
|
ActivityProgress summary = computeProgress(activity, records, should);
|
||||||
|
summary.setReadCount(readCount);
|
||||||
|
summary.setReadRate(should > 0 ? (int) Math.round(readCount * 100.0 / should) : 0);
|
||||||
|
vo.setSummary(summary);
|
||||||
vo.setGroups(groups);
|
vo.setGroups(groups);
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
@@ -542,7 +558,11 @@ public class ActivityService implements IActivityService {
|
|||||||
ActivitySignup record = activitySignupRepository.findById(signupId)
|
ActivitySignup record = activitySignupRepository.findById(signupId)
|
||||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "回执记录不存在"));
|
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "回执记录不存在"));
|
||||||
Activity activity = requireActivity(record.getActivityId());
|
Activity activity = requireActivity(record.getActivityId());
|
||||||
ActivityRead read = findRead(record.getActivityId(), record.getClassId());
|
// 已读按学生粒度:该生本人是否打开过(历史班级粒度数据不误标同班其他学生)
|
||||||
|
ActivityRead read = record.getStudentId() == null ? null
|
||||||
|
: activityReadRepository
|
||||||
|
.findByActivityIdAndClassIdAndStudentId(record.getActivityId(), record.getClassId(), record.getStudentId())
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
SignupDetailVo vo = new SignupDetailVo();
|
SignupDetailVo vo = new SignupDetailVo();
|
||||||
vo.setSignupId(record.getId());
|
vo.setSignupId(record.getId());
|
||||||
@@ -894,9 +914,10 @@ public class ActivityService implements IActivityService {
|
|||||||
List<Integer> targetIds = targetClassIds(a, classes);
|
List<Integer> targetIds = targetClassIds(a, classes);
|
||||||
int allowCount = targetIds.stream().mapToInt(t -> classSize.getOrDefault(t, 0)).sum();
|
int allowCount = targetIds.stream().mapToInt(t -> classSize.getOrDefault(t, 0)).sum();
|
||||||
Set<Integer> targetSet = new LinkedHashSet<>(targetIds);
|
Set<Integer> targetSet = new LinkedHashSet<>(targetIds);
|
||||||
int readCount = readMap.getOrDefault(a.getId(), Collections.emptyList()).stream()
|
// 已读人数 = 接收班级内的已读记录条数(学生粒度),与列表/详情同口径
|
||||||
.mapToInt(r -> targetSet.contains(parseSafe(r.getClassId()))
|
int readCount = (int) readMap.getOrDefault(a.getId(), Collections.emptyList()).stream()
|
||||||
? classSize.getOrDefault(parseSafe(r.getClassId()), 0) : 0).sum();
|
.filter(r -> targetSet.contains(parseSafe(r.getClassId())))
|
||||||
|
.count();
|
||||||
fillProgressItem(item, computeProgress(a, records, allowCount), readCount, allowCount);
|
fillProgressItem(item, computeProgress(a, records, allowCount), readCount, allowCount);
|
||||||
myItems.add(item);
|
myItems.add(item);
|
||||||
}
|
}
|
||||||
@@ -1151,19 +1172,24 @@ public class ActivityService implements IActivityService {
|
|||||||
if (pc.myClassId == null || candidates.isEmpty()) {
|
if (pc.myClassId == null || candidates.isEmpty()) {
|
||||||
return Collections.emptySet();
|
return Collections.emptySet();
|
||||||
}
|
}
|
||||||
|
Integer myStudentId = studentIdOf(pc);
|
||||||
List<Integer> ids = candidates.stream().map(Activity::getId).collect(Collectors.toList());
|
List<Integer> ids = candidates.stream().map(Activity::getId).collect(Collectors.toList());
|
||||||
// 已读是班级粒度(fa_activity_read 唯一键为 activity+class,studentId 只是首读学生),
|
// 已读按学生粒度:本人(学生身份)打开过才算已读;studentId 缺失的存量数据退化为班级粒度
|
||||||
// 不能按学生过滤,否则同班其他学生永远「未读」
|
|
||||||
return activityReadRepository
|
return activityReadRepository
|
||||||
.findByActivityIdIn(ids).stream()
|
.findByActivityIdIn(ids).stream()
|
||||||
.filter(r -> pc.myClassId.equals(r.getClassId()))
|
.filter(r -> pc.myClassId.equals(r.getClassId()))
|
||||||
|
.filter(r -> myStudentId == null || myStudentId.equals(r.getStudentId()))
|
||||||
.map(ActivityRead::getActivityId)
|
.map(ActivityRead::getActivityId)
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 标读(班级粒度,幂等) */
|
/** 标读(学生粒度,幂等:同一家长重复打开只记一条;学生档案不可得时退化为班级粒度) */
|
||||||
private void markRead(Integer activityId, String classId, Integer studentId) {
|
private void markRead(Integer activityId, String classId, Integer studentId) {
|
||||||
if (activityReadRepository.findByActivityIdAndClassId(activityId, classId).isPresent()) {
|
if (studentId == null) {
|
||||||
|
if (activityReadRepository.findByActivityIdAndClassId(activityId, classId).isPresent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (activityReadRepository.existsByActivityIdAndClassIdAndStudentId(activityId, classId, studentId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ActivityRead read = new ActivityRead();
|
ActivityRead read = new ActivityRead();
|
||||||
@@ -1175,18 +1201,15 @@ public class ActivityService implements IActivityService {
|
|||||||
activityReadRepository.save(read);
|
activityReadRepository.save(read);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ActivityRead findRead(Integer activityId, String classId) {
|
/** confirm 模式:家长打开活动即自动确认(未回执 → 已确认)。
|
||||||
if (classId == null) {
|
* 仅报名截止时间内生效(活动处于「报名中」态):超过截止时间(转「已截止/已结束」)后打开只标已读,不再自动确认。 */
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return activityReadRepository.findByActivityIdAndClassId(activityId, classId).orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** confirm 模式:家长打开活动即自动确认(未回执 → 已确认) */
|
|
||||||
private void autoConfirmIfNeeded(Activity activity, Integer studentId) {
|
private void autoConfirmIfNeeded(Activity activity, Integer studentId) {
|
||||||
if (!ReceiptModeEnum.CONFIRM.getCode().equalsIgnoreCase(activity.getReceiptMode())) {
|
if (!ReceiptModeEnum.CONFIRM.getCode().equalsIgnoreCase(activity.getReceiptMode())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (deriveStatus(activity, LocalDateTime.now()) != ActivityStatusEnum.SIGNUP) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
activitySignupRepository.findByActivityIdAndStudentId(activity.getId(), studentId)
|
activitySignupRepository.findByActivityIdAndStudentId(activity.getId(), studentId)
|
||||||
.ifPresent(record -> {
|
.ifPresent(record -> {
|
||||||
if (SignupStatusEnum.NONE.getCode().equalsIgnoreCase(record.getStatus())) {
|
if (SignupStatusEnum.NONE.getCode().equalsIgnoreCase(record.getStatus())) {
|
||||||
@@ -1272,7 +1295,8 @@ public class ActivityService implements IActivityService {
|
|||||||
boolean full = quota > 0 && usedQuota >= quota;
|
boolean full = quota > 0 && usedQuota >= quota;
|
||||||
double rate = should > 0 ? Math.round((signed + confirmed) * 1000.0 / should) / 10.0 : 0;
|
double rate = should > 0 ? Math.round((signed + confirmed) * 1000.0 / should) / 10.0 : 0;
|
||||||
int pending = quota > 0 ? Math.max(0, usedQuota - quota) : 0;
|
int pending = quota > 0 ? Math.max(0, usedQuota - quota) : 0;
|
||||||
return new ActivityProgress(should, signed, confirmed, canceled, unsigned, rate, quota, usedQuota, full, pending);
|
// readCount/readRate 由调用方按 fa_activity_read 投影填充(如 Signups 汇总),此处缺省 0
|
||||||
|
return new ActivityProgress(should, signed, confirmed, canceled, unsigned, rate, quota, usedQuota, full, pending, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private int usedQuota(List<ActivitySignup> records) {
|
private int usedQuota(List<ActivitySignup> records) {
|
||||||
@@ -1466,10 +1490,10 @@ public class ActivityService implements IActivityService {
|
|||||||
List<Integer> targetIds = targetClassIds(a, classes);
|
List<Integer> targetIds = targetClassIds(a, classes);
|
||||||
Set<Integer> targetSet = new LinkedHashSet<>(targetIds);
|
Set<Integer> targetSet = new LinkedHashSet<>(targetIds);
|
||||||
int allowCount = targetIds.stream().mapToInt(t -> classSize.getOrDefault(t, 0)).sum();
|
int allowCount = targetIds.stream().mapToInt(t -> classSize.getOrDefault(t, 0)).sum();
|
||||||
// 已读只按目标班级累计(收窄接收班级后的残留已读行不计入)
|
// 已读人数 = 接收班级内的已读记录条数(学生粒度,一人一条;收窄接收班级后的出界记录不计入)
|
||||||
int readCount = readMap.getOrDefault(a.getId(), Collections.emptyList()).stream()
|
int readCount = (int) readMap.getOrDefault(a.getId(), Collections.emptyList()).stream()
|
||||||
.mapToInt(r -> targetSet.contains(parseSafe(r.getClassId()))
|
.filter(r -> targetSet.contains(parseSafe(r.getClassId())))
|
||||||
? classSize.getOrDefault(parseSafe(r.getClassId()), 0) : 0).sum();
|
.count();
|
||||||
vo.setAllowCount(allowCount);
|
vo.setAllowCount(allowCount);
|
||||||
vo.setReadCount(readCount);
|
vo.setReadCount(readCount);
|
||||||
// 管理端列表不填已读标志:fa_activity_read 是班级粒度且 studentId 只记首读学生,
|
// 管理端列表不填已读标志:fa_activity_read 是班级粒度且 studentId 只记首读学生,
|
||||||
@@ -1578,9 +1602,10 @@ public class ActivityService implements IActivityService {
|
|||||||
List<Integer> targetIds = targetClassIds(a, classes);
|
List<Integer> targetIds = targetClassIds(a, classes);
|
||||||
Map<Integer, List<SchStudentDto>> byClass = studentsByClass(schId, targetIds);
|
Map<Integer, List<SchStudentDto>> byClass = studentsByClass(schId, targetIds);
|
||||||
int allowCount = byClass.values().stream().mapToInt(List::size).sum();
|
int allowCount = byClass.values().stream().mapToInt(List::size).sum();
|
||||||
int readCount = activityReadRepository.findByActivityId(a.getId()).stream()
|
// 已读人数 = 接收班级内的已读记录条数(学生粒度),与列表 readCount 同口径
|
||||||
.mapToInt(r -> byClass.getOrDefault(parseSafe(r.getClassId()), Collections.emptyList()).size())
|
int readCount = (int) activityReadRepository.findByActivityId(a.getId()).stream()
|
||||||
.sum();
|
.filter(r -> byClass.containsKey(parseSafe(r.getClassId())))
|
||||||
|
.count();
|
||||||
vo.setNumcount(allowCount);
|
vo.setNumcount(allowCount);
|
||||||
vo.setReadnumcount(readCount);
|
vo.setReadnumcount(readCount);
|
||||||
if (a.isPublished() && !ReceiptModeEnum.NONE.getCode().equalsIgnoreCase(a.getReceiptMode())) {
|
if (a.isPublished() && !ReceiptModeEnum.NONE.getCode().equalsIgnoreCase(a.getReceiptMode())) {
|
||||||
@@ -1591,7 +1616,7 @@ public class ActivityService implements IActivityService {
|
|||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
private SignupBrief toBrief(ActivitySignup r) {
|
private SignupBrief toBrief(ActivitySignup r, Set<String> readKeys) {
|
||||||
SignupBrief brief = new SignupBrief();
|
SignupBrief brief = new SignupBrief();
|
||||||
brief.setSignupId(r.getId());
|
brief.setSignupId(r.getId());
|
||||||
brief.setStudentId(r.getStudentId());
|
brief.setStudentId(r.getStudentId());
|
||||||
@@ -1605,10 +1630,16 @@ public class ActivityService implements IActivityService {
|
|||||||
brief.setSignedAt(fmt(r.getSignedAt()));
|
brief.setSignedAt(fmt(r.getSignedAt()));
|
||||||
boolean receipt = !SignupStatusEnum.NONE.getCode().equalsIgnoreCase(r.getStatus());
|
boolean receipt = !SignupStatusEnum.NONE.getCode().equalsIgnoreCase(r.getStatus());
|
||||||
brief.setHasReceipt(receipt);
|
brief.setHasReceipt(receipt);
|
||||||
|
brief.setRead(readKeys.contains(readKey(r.getClassId(), r.getStudentId())));
|
||||||
brief.setRemark(r.getRemark());
|
brief.setRemark(r.getRemark());
|
||||||
return brief;
|
return brief;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 学生粒度已读键:classId:studentId(studentId 为 null 的存量数据按 classId:null 匹配,互不误标) */
|
||||||
|
private String readKey(String classId, Integer studentId) {
|
||||||
|
return classId + ":" + studentId;
|
||||||
|
}
|
||||||
|
|
||||||
/** 是否占用名额(已报名/已确认) */
|
/** 是否占用名额(已报名/已确认) */
|
||||||
private boolean isOccupying(ActivitySignup r) {
|
private boolean isOccupying(ActivitySignup r) {
|
||||||
return SignupStatusEnum.SIGNED.getCode().equalsIgnoreCase(r.getStatus())
|
return SignupStatusEnum.SIGNED.getCode().equalsIgnoreCase(r.getStatus())
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.yunhai.familyactivity.entity.SysDict;
|
|||||||
import com.yunhai.familyactivity.repository.ActivityRepository;
|
import com.yunhai.familyactivity.repository.ActivityRepository;
|
||||||
import com.yunhai.familyactivity.repository.SysDictRepository;
|
import com.yunhai.familyactivity.repository.SysDictRepository;
|
||||||
import com.yunhai.familyactivity.service.IDictService;
|
import com.yunhai.familyactivity.service.IDictService;
|
||||||
|
import com.yunhai.familyactivity.utils.UserContext;
|
||||||
import com.yunhai.platform.auth.LoginUser;
|
import com.yunhai.platform.auth.LoginUser;
|
||||||
import com.yunhai.platform.auth.UserContextHolder;
|
import com.yunhai.platform.auth.UserContextHolder;
|
||||||
import com.yunhai.platform.apistd.BizException;
|
import com.yunhai.platform.apistd.BizException;
|
||||||
@@ -22,6 +23,17 @@ import java.util.stream.Collectors;
|
|||||||
* 系统字典服务实现。
|
* 系统字典服务实现。
|
||||||
* 对应 C# CommonController + GetSysDict.cs 中的字典管理逻辑。
|
* 对应 C# CommonController + GetSysDict.cs 中的字典管理逻辑。
|
||||||
* 支持两类字典:dept=主办部门、category=活动分类,CRUD 逻辑同构。
|
* 支持两类字典:dept=主办部门、category=活动分类,CRUD 逻辑同构。
|
||||||
|
*
|
||||||
|
* <p><b>多租户(按学校隔离,sys_dict.sch_id):</b></p>
|
||||||
|
* <ol>
|
||||||
|
* <li><b>读</b>:本校自有字典(sch_id=当前学校);本校该类型尚无数据时回退全局种子(sch_id=0),
|
||||||
|
* 保证新学校开箱可用、历史(.NET 迁移)字典不丢失。</li>
|
||||||
|
* <li><b>写</b>:改/删/排序前先把全局种子整体复制为本校自有行(copy-on-write,仅首次写入触发一次),
|
||||||
|
* 此后该校完全独立维护,互不影响;新增字典直接归属当前学校。</li>
|
||||||
|
* <li><b>超管</b>(schId=0)不复制,直接维护全局种子集合。</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>这样既避免「A 校改名影响 B 校」的跨租户串改,也让前端拿到的 SerID 在首次写入后仍可稳定回写
|
||||||
|
* (种子 id → 同 dict_key 的本校副本)。</p>
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class DictService implements IDictService {
|
public class DictService implements IDictService {
|
||||||
@@ -33,6 +45,11 @@ public class DictService implements IDictService {
|
|||||||
/** 字典类型常量:活动分类 */
|
/** 字典类型常量:活动分类 */
|
||||||
private static final String DICT_TYPE_CATEGORY = "category";
|
private static final String DICT_TYPE_CATEGORY = "category";
|
||||||
|
|
||||||
|
/** 全局种子学校ID */
|
||||||
|
private static final int GLOBAL_SCH_ID = 0;
|
||||||
|
/** 前端约定的“已删除”哨兵值(沿用原逻辑,重名校验时跳过) */
|
||||||
|
private static final int DISABLED_SENTINEL = 3;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private SysDictRepository sysDictRepository;
|
private SysDictRepository sysDictRepository;
|
||||||
|
|
||||||
@@ -47,13 +64,13 @@ public class DictService implements IDictService {
|
|||||||
if (dictType == null) {
|
if (dictType == null) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误");
|
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误");
|
||||||
}
|
}
|
||||||
List<SysDict> list = sysDictRepository.findByDictTypeAndEnableFlagOrderBySortOrderAsc(dictType, 1);
|
List<SysDict> list = readVisible(dictType, true);
|
||||||
if (list.isEmpty()) {
|
if (list.isEmpty()) {
|
||||||
throw new BizException(ErrorCode.FORBIDDEN, emptyTip(dictType));
|
throw new BizException(ErrorCode.FORBIDDEN, emptyTip(dictType));
|
||||||
}
|
}
|
||||||
// 按 sortOrder 升序返回 {k, v}
|
// 按 sortOrder 升序返回 {k, v}
|
||||||
return list.stream()
|
return list.stream()
|
||||||
.sorted(Comparator.comparingInt(d -> d.getSortOrder() == null ? Integer.MAX_VALUE : d.getSortOrder()))
|
.sorted(Comparator.comparingInt(DictService::sortOf))
|
||||||
.map(d -> {
|
.map(d -> {
|
||||||
Map<String, Object> item = new LinkedHashMap<>();
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
item.put("k", d.getDictKey());
|
item.put("k", d.getDictKey());
|
||||||
@@ -71,9 +88,9 @@ public class DictService implements IDictService {
|
|||||||
if (dictType == null) {
|
if (dictType == null) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误");
|
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误");
|
||||||
}
|
}
|
||||||
List<SysDict> list = sysDictRepository.findByDictTypeOrderBySortOrderAsc(dictType);
|
List<SysDict> list = readVisible(dictType, false);
|
||||||
return list.stream()
|
return list.stream()
|
||||||
.sorted(Comparator.comparingInt(d -> d.getSortOrder() == null ? 999 : d.getSortOrder()))
|
.sorted(Comparator.comparingInt(DictService::sortOf))
|
||||||
.map(d -> {
|
.map(d -> {
|
||||||
Map<String, Object> item = new LinkedHashMap<>();
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
item.put("SerID", d.getId());
|
item.put("SerID", d.getId());
|
||||||
@@ -95,21 +112,25 @@ public class DictService implements IDictService {
|
|||||||
if (type == null || !StringUtils.hasText(name)) {
|
if (type == null || !StringUtils.hasText(name)) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR, "输入字符串格式不正确!");
|
throw new BizException(ErrorCode.PARAM_ERROR, "输入字符串格式不正确!");
|
||||||
}
|
}
|
||||||
// 校验重名
|
// 先把全局种子落为本校自有行,保证新增后本校字典完整且可独立维护
|
||||||
List<SysDict> existing = sysDictRepository.findByDictTypeOrderBySortOrderAsc(type);
|
materialize(type);
|
||||||
|
|
||||||
|
// 校验重名(本校范围内,含刚复制的种子)
|
||||||
|
List<SysDict> existing = rowsOf(currentSchId(), type, false);
|
||||||
boolean duplicate = existing.stream().anyMatch(d ->
|
boolean duplicate = existing.stream().anyMatch(d ->
|
||||||
type.equals(d.getDictType()) && name.trim().equals(d.getDictValue()) && d.getEnableFlag() != 3);
|
type.equals(d.getDictType()) && name.trim().equals(d.getDictValue()) && !isRemoved(d));
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
throw new BizException(ErrorCode.FORBIDDEN, "已存在该分类,请重新填写!");
|
throw new BizException(ErrorCode.FORBIDDEN, "已存在该分类,请重新填写!");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算 sortOrder = max + 1
|
// 计算 sortOrder = max + 1
|
||||||
int maxSort = existing.stream()
|
int maxSort = existing.stream()
|
||||||
.filter(d -> d.getEnableFlag() != 3)
|
.filter(d -> !isRemoved(d))
|
||||||
.mapToInt(d -> d.getSortOrder() != null ? d.getSortOrder() : 0)
|
.mapToInt(DictService::sortValue)
|
||||||
.max().orElse(0);
|
.max().orElse(0);
|
||||||
|
|
||||||
SysDict dict = new SysDict();
|
SysDict dict = new SysDict();
|
||||||
|
dict.setSchId(currentSchId());
|
||||||
dict.setDictType(type);
|
dict.setDictType(type);
|
||||||
dict.setDictValue(name.trim());
|
dict.setDictValue(name.trim());
|
||||||
dict.setSortOrder(maxSort + 1);
|
dict.setSortOrder(maxSort + 1);
|
||||||
@@ -127,16 +148,17 @@ public class DictService implements IDictService {
|
|||||||
if (type == null || serID == null) {
|
if (type == null || serID == null) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误!");
|
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误!");
|
||||||
}
|
}
|
||||||
// 校验重名(排除自身)
|
materialize(type);
|
||||||
List<SysDict> existing = sysDictRepository.findByDictTypeOrderBySortOrderAsc(type);
|
|
||||||
|
// 校验重名(排除自身,本校范围内)
|
||||||
|
List<SysDict> existing = rowsOf(currentSchId(), type, false);
|
||||||
boolean duplicate = existing.stream().anyMatch(d ->
|
boolean duplicate = existing.stream().anyMatch(d ->
|
||||||
!d.getId().equals(serID) && name != null && name.trim().equals(d.getDictValue()) && d.getEnableFlag() != 3);
|
!d.getId().equals(serID) && name != null && name.trim().equals(d.getDictValue()) && !isRemoved(d));
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
throw new BizException(ErrorCode.FORBIDDEN, "已存在该分类,请重新填写!");
|
throw new BizException(ErrorCode.FORBIDDEN, "已存在该分类,请重新填写!");
|
||||||
}
|
}
|
||||||
|
|
||||||
SysDict dict = sysDictRepository.findById(serID)
|
SysDict dict = findOwned(type, serID);
|
||||||
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "字典记录不存在"));
|
|
||||||
if (StringUtils.hasText(name)) {
|
if (StringUtils.hasText(name)) {
|
||||||
dict.setDictValue(name.trim());
|
dict.setDictValue(name.trim());
|
||||||
}
|
}
|
||||||
@@ -155,8 +177,9 @@ public class DictService implements IDictService {
|
|||||||
if (type == null) {
|
if (type == null) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误!");
|
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误!");
|
||||||
}
|
}
|
||||||
SysDict dict = sysDictRepository.findById(serID)
|
materialize(type);
|
||||||
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "字典记录不存在"));
|
|
||||||
|
SysDict dict = findOwned(type, serID);
|
||||||
if (DICT_TYPE_CATEGORY.equals(type)) {
|
if (DICT_TYPE_CATEGORY.equals(type)) {
|
||||||
// 活动分类:fa_activity.category 冗余存分类名称,按名称判断引用
|
// 活动分类:fa_activity.category 冗余存分类名称,按名称判断引用
|
||||||
if (activityRepository.existsByCategory(dict.getDictValue())) {
|
if (activityRepository.existsByCategory(dict.getDictValue())) {
|
||||||
@@ -180,7 +203,9 @@ public class DictService implements IDictService {
|
|||||||
if (type == null || !StringUtils.hasText(ids)) {
|
if (type == null || !StringUtils.hasText(ids)) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误");
|
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误");
|
||||||
}
|
}
|
||||||
List<SysDict> list = sysDictRepository.findByDictTypeOrderBySortOrderAsc(type);
|
materialize(type);
|
||||||
|
|
||||||
|
List<SysDict> list = rowsOf(currentSchId(), type, false);
|
||||||
if (list.isEmpty()) {
|
if (list.isEmpty()) {
|
||||||
throw new BizException(ErrorCode.FORBIDDEN, "字典类型错误,未找到该字典类型");
|
throw new BizException(ErrorCode.FORBIDDEN, "字典类型错误,未找到该字典类型");
|
||||||
}
|
}
|
||||||
@@ -192,21 +217,20 @@ public class DictService implements IDictService {
|
|||||||
.mapToInt(Integer::parseInt)
|
.mapToInt(Integer::parseInt)
|
||||||
.toArray();
|
.toArray();
|
||||||
|
|
||||||
int maxSort = list.stream()
|
int maxSort = list.stream().mapToInt(DictService::sortValue).max().orElse(idArr.length);
|
||||||
.mapToInt(d -> d.getSortOrder() != null ? d.getSortOrder() : idArr.length)
|
|
||||||
.max().orElse(idArr.length);
|
|
||||||
|
|
||||||
// lambda 中不能修改局部变量,用数组包装
|
// lambda 中不能修改局部变量,用数组包装
|
||||||
final int[] indexOrd = { maxSort };
|
final int[] indexOrd = { maxSort };
|
||||||
|
|
||||||
// 逆序遍历,从最大序号递减
|
// 逆序遍历,从最大序号递减;前端传来的 id 可能是种子 id,按 dict_key 归位到本校行
|
||||||
for (int i = idArr.length - 1; i >= 0; i--) {
|
for (int i = idArr.length - 1; i >= 0; i--) {
|
||||||
int id = idArr[i];
|
SysDict dict = ownedOrNull(type, idArr[i]);
|
||||||
sysDictRepository.findById(id).ifPresent(dict -> {
|
if (dict == null) {
|
||||||
dict.setSortOrder(indexOrd[0]);
|
continue;
|
||||||
sysDictRepository.save(dict);
|
}
|
||||||
indexOrd[0]--;
|
dict.setSortOrder(indexOrd[0]);
|
||||||
});
|
sysDictRepository.save(dict);
|
||||||
|
indexOrd[0]--;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,15 +252,129 @@ public class DictService implements IDictService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 按主办部门字典 key 解析部门名称(对应 C# GetDeptName)。
|
* 按主办部门字典 key 解析部门名称(对应 C# GetDeptName)。
|
||||||
|
* 先按本校字典解析,未命中回退全局种子,最后退回不区分学校的旧口径,
|
||||||
|
* 保证历史活动(发布时未带学校上下文的字典)仍能展示部门名。
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public String resolveDeptName(String deptId) {
|
public String resolveDeptName(String deptId) {
|
||||||
if (!StringUtils.hasText(deptId)) {
|
if (!StringUtils.hasText(deptId)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return sysDictRepository.findByDictTypeAndDictKey(DICT_TYPE_DEPT, deptId.trim())
|
String key = deptId.trim();
|
||||||
|
String name = sysDictRepository.findBySchIdAndDictTypeAndDictKey(currentSchId(), DICT_TYPE_DEPT, key)
|
||||||
.map(SysDict::getDictValue)
|
.map(SysDict::getDictValue)
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
if (name != null) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return sysDictRepository.findByDictTypeAndDictKey(DICT_TYPE_DEPT, key)
|
||||||
|
.map(SysDict::getDictValue)
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ 多租户辅助 ============================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取当前学校可见字典:本校自有行优先;本校该类型为空时回退全局种子。
|
||||||
|
*
|
||||||
|
* @param onlyEnabled true=仅启用项(下拉),false=全部(管理列表)
|
||||||
|
*/
|
||||||
|
private List<SysDict> readVisible(String dictType, boolean onlyEnabled) {
|
||||||
|
Integer schId = currentSchId();
|
||||||
|
// 用「全量是否存在」判断本校是否已建字典:本校全部停用时也不应回退全局种子(否则会“复活”已禁用的项)
|
||||||
|
boolean hasOwn = schId == GLOBAL_SCH_ID
|
||||||
|
|| !sysDictRepository.findBySchIdAndDictTypeOrderBySortOrderAsc(schId, dictType).isEmpty();
|
||||||
|
return hasOwn ? rowsOf(schId, dictType, onlyEnabled) : rowsOf(GLOBAL_SCH_ID, dictType, onlyEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把全局种子(sch_id=0)整体复制为当前学校的自有字典(copy-on-write)。
|
||||||
|
* <p>仅在本校该类型尚无任何数据时执行,故每个学校每种类型最多触发一次;
|
||||||
|
* 超管(schId=0)跳过,直接维护全局种子。</p>
|
||||||
|
*/
|
||||||
|
private void materialize(String dictType) {
|
||||||
|
Integer schId = currentSchId();
|
||||||
|
if (schId == GLOBAL_SCH_ID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sysDictRepository.findBySchIdAndDictTypeOrderBySortOrderAsc(schId, dictType).isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<SysDict> seeds = sysDictRepository.findBySchIdAndDictTypeOrderBySortOrderAsc(GLOBAL_SCH_ID, dictType);
|
||||||
|
for (SysDict seed : seeds) {
|
||||||
|
SysDict copy = new SysDict();
|
||||||
|
copy.setSchId(schId);
|
||||||
|
copy.setDictType(seed.getDictType());
|
||||||
|
copy.setDictKey(seed.getDictKey());
|
||||||
|
copy.setDictValue(seed.getDictValue());
|
||||||
|
copy.setSortOrder(seed.getSortOrder());
|
||||||
|
copy.setEnableFlag(seed.getEnableFlag());
|
||||||
|
sysDictRepository.save(copy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 某学校某类型的字典行(可只取启用项) */
|
||||||
|
private List<SysDict> rowsOf(Integer schId, String dictType, boolean onlyEnabled) {
|
||||||
|
return onlyEnabled
|
||||||
|
? sysDictRepository.findBySchIdAndDictTypeAndEnableFlagOrderBySortOrderAsc(schId, dictType, 1)
|
||||||
|
: sysDictRepository.findBySchIdAndDictTypeOrderBySortOrderAsc(schId, dictType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前学校ID:超管/未绑定学校统一为 0(与全局种子同口径) */
|
||||||
|
private Integer currentSchId() {
|
||||||
|
Integer schId = UserContext.getSchId();
|
||||||
|
return schId == null ? GLOBAL_SCH_ID : schId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取「本校可维护行」:命中本校行直接返回;前端仍引用全局种子 id 时按 dict_key 归位到本校副本
|
||||||
|
* (材料化后必存在对应副本);越权/不存在返回 null。
|
||||||
|
*/
|
||||||
|
private SysDict ownedOrNull(String dictType, Integer serID) {
|
||||||
|
if (serID == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
SysDict dict = sysDictRepository.findById(serID).orElse(null);
|
||||||
|
if (dict == null || !dictType.equals(dict.getDictType())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Integer schId = currentSchId();
|
||||||
|
if (schId.equals(dict.getSchId())) {
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
if (dict.getSchId() == null || dict.getSchId() == GLOBAL_SCH_ID) {
|
||||||
|
return sysDictRepository.findBySchIdAndDictTypeAndDictKey(schId, dictType, dict.getDictKey())
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本校可维护行,取不到按权限/存在性报错 */
|
||||||
|
private SysDict findOwned(String dictType, Integer serID) {
|
||||||
|
SysDict dict = sysDictRepository.findById(serID)
|
||||||
|
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "字典记录不存在"));
|
||||||
|
if (!dictType.equals(dict.getDictType())) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "字典类型不匹配");
|
||||||
|
}
|
||||||
|
SysDict owned = ownedOrNull(dictType, serID);
|
||||||
|
if (owned == null) {
|
||||||
|
throw new BizException(ErrorCode.FORBIDDEN, "无权操作其他学校的字典");
|
||||||
|
}
|
||||||
|
return owned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已删除哨兵(沿用原逻辑的软删标记) */
|
||||||
|
private static boolean isRemoved(SysDict d) {
|
||||||
|
return d.getEnableFlag() != null && d.getEnableFlag() == DISABLED_SENTINEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int sortOf(SysDict d) {
|
||||||
|
return d.getSortOrder() == null ? Integer.MAX_VALUE : d.getSortOrder();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排序号取值(缺失按 0,用于取 max 计算下一个序号,避免 Integer.MAX_VALUE 溢出) */
|
||||||
|
private static int sortValue(SysDict d) {
|
||||||
|
return d.getSortOrder() == null ? 0 : d.getSortOrder();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.yunhai.familyactivity.service.impl;
|
package com.yunhai.familyactivity.service.impl;
|
||||||
|
|
||||||
import com.yunhai.familyactivity.entity.UpFile;
|
import com.yunhai.familyactivity.entity.UpFile;
|
||||||
import com.yunhai.familyactivity.model.UpFileVo;
|
|
||||||
import com.yunhai.familyactivity.model.UpFileResultVo;
|
import com.yunhai.familyactivity.model.UpFileResultVo;
|
||||||
|
import com.yunhai.familyactivity.model.UpFileVo;
|
||||||
import com.yunhai.familyactivity.repository.UpFileRepository;
|
import com.yunhai.familyactivity.repository.UpFileRepository;
|
||||||
import com.yunhai.familyactivity.service.IFileService;
|
import com.yunhai.familyactivity.service.IFileService;
|
||||||
import com.yunhai.platform.apistd.BizException;
|
import com.yunhai.platform.apistd.BizException;
|
||||||
@@ -18,7 +18,6 @@ import org.springframework.util.StringUtils;
|
|||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletResponse;
|
import javax.servlet.http.HttpServletResponse;
|
||||||
import java.io.File;
|
|
||||||
import java.io.FileInputStream;
|
import java.io.FileInputStream;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.net.URLConnection;
|
import java.net.URLConnection;
|
||||||
@@ -26,15 +25,7 @@ import java.net.URLEncoder;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.time.LocalDate;
|
import java.util.*;
|
||||||
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.stream.Collectors;
|
||||||
import java.util.zip.ZipEntry;
|
import java.util.zip.ZipEntry;
|
||||||
import java.util.zip.ZipOutputStream;
|
import java.util.zip.ZipOutputStream;
|
||||||
@@ -53,11 +44,9 @@ public class FileService implements IFileService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private UpFileRepository upFileRepository;
|
private UpFileRepository upFileRepository;
|
||||||
|
|
||||||
@Value("${file.upload-path}")
|
@Value("${FILE_PATH}")
|
||||||
private String uploadPath;
|
private String uploadPath;
|
||||||
|
|
||||||
private static final String DEFAULT_APP_TYPE = "activity";
|
|
||||||
|
|
||||||
// ============================ 上传 ============================
|
// ============================ 上传 ============================
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -77,13 +66,10 @@ public class FileService implements IFileService {
|
|||||||
ext = originalName.substring(dotIdx + 1).toLowerCase();
|
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 savedName = UUID.randomUUID().toString().replace("-", "") + (ext.isEmpty() ? "" : "." + ext);
|
||||||
String relativePath = (appType == null ? DEFAULT_APP_TYPE : appType) + "/" + dateDir + "/" + savedName;
|
|
||||||
|
|
||||||
// 物理写入
|
// 物理写入
|
||||||
Path fullPath = Paths.get(uploadPath, relativePath);
|
Path fullPath = Paths.get(uploadPath, savedName);
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(fullPath.getParent());
|
Files.createDirectories(fullPath.getParent());
|
||||||
file.transferTo(fullPath.toFile());
|
file.transferTo(fullPath.toFile());
|
||||||
@@ -94,10 +80,10 @@ public class FileService implements IFileService {
|
|||||||
|
|
||||||
// 入库
|
// 入库
|
||||||
UpFile entity = new UpFile();
|
UpFile entity = new UpFile();
|
||||||
entity.setTopicType(appType == null ? DEFAULT_APP_TYPE : appType);
|
entity.setTopicType(appType);
|
||||||
entity.setTopicId(topicId);
|
entity.setTopicId(topicId);
|
||||||
entity.setFileTrueName(originalName);
|
entity.setFileTrueName(originalName);
|
||||||
entity.setFileName(relativePath);
|
entity.setFileName(savedName);
|
||||||
entity.setFileSize((int) file.getSize());
|
entity.setFileSize((int) file.getSize());
|
||||||
entity.setFileType(ext);
|
entity.setFileType(ext);
|
||||||
upFileRepository.save(entity);
|
upFileRepository.save(entity);
|
||||||
@@ -125,8 +111,7 @@ public class FileService implements IFileService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<UpFileVo> listByTopic(String appType, Integer topicId) {
|
public List<UpFileVo> listByTopic(String appType, Integer topicId) {
|
||||||
String type = appType == null ? DEFAULT_APP_TYPE : appType;
|
return upFileRepository.findByTopicTypeAndTopicId(appType, topicId).stream()
|
||||||
return upFileRepository.findByTopicTypeAndTopicId(type, topicId).stream()
|
|
||||||
.map(this::toVo)
|
.map(this::toVo)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
@@ -279,8 +264,7 @@ public class FileService implements IFileService {
|
|||||||
@Override
|
@Override
|
||||||
public void downloadZipByTopic(String topicType, Integer topicId, String zipName,
|
public void downloadZipByTopic(String topicType, Integer topicId, String zipName,
|
||||||
HttpServletResponse response) {
|
HttpServletResponse response) {
|
||||||
String type = topicType == null ? DEFAULT_APP_TYPE : topicType;
|
List<UpFile> files = upFileRepository.findByTopicTypeAndTopicId(topicType, topicId);
|
||||||
List<UpFile> files = upFileRepository.findByTopicTypeAndTopicId(type, topicId);
|
|
||||||
downloadZip(files, zipName, response);
|
downloadZip(files, zipName, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,9 +277,8 @@ public class FileService implements IFileService {
|
|||||||
if (files == null || files.length == 0) {
|
if (files == null || files.length == 0) {
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
String type = appType != null ? appType : DEFAULT_APP_TYPE;
|
|
||||||
for (MultipartFile file : files) {
|
for (MultipartFile file : files) {
|
||||||
results.add(upload(file, type, null));
|
results.add(upload(file, appType, null));
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import com.yunhai.familyactivity.model.SchTeaList;
|
|||||||
import com.yunhai.familyactivity.service.IClassService;
|
import com.yunhai.familyactivity.service.IClassService;
|
||||||
import com.yunhai.familyactivity.service.IUserCenterService;
|
import com.yunhai.familyactivity.service.IUserCenterService;
|
||||||
import com.yunhai.platform.apistd.BizException;
|
import com.yunhai.platform.apistd.BizException;
|
||||||
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
|
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
|
||||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||||
import com.yunhai.platform.utils.model.BoaGroup;
|
import com.yunhai.platform.utils.model.BoaGroup;
|
||||||
import com.yunhai.platform.utils.model.BoaModule;
|
import com.yunhai.platform.utils.model.BoaModule;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import lombok.Value;
|
|||||||
/**
|
/**
|
||||||
* 当前登录用户上下文(ThreadLocal)。
|
* 当前登录用户上下文(ThreadLocal)。
|
||||||
* <p>由 {@link com.yunhai.familyactivity.aspect.ControllerUserContextAspect} 在请求进入控制器前填充、
|
* <p>由 {@link com.yunhai.familyactivity.aspect.ControllerUserContextAspect} 在请求进入控制器前填充、
|
||||||
* 请求结束后清理;Service 层统一通过本类获取当前用户(发布人账号、schId 数据隔离、操作日志)。
|
* 请求结束后清理;Service 层统一通过本类获取当前用户(发布人ID/姓名、schId 数据隔离、操作日志、审计字段)。
|
||||||
* 参考实现:UserclassService {@code com.yunhai.bschclassmanage.utils.UserContext}。</p>
|
* 参考实现:UserclassService {@code com.yunhai.bschclassmanage.utils.UserContext}。</p>
|
||||||
*/
|
*/
|
||||||
public class UserContext {
|
public class UserContext {
|
||||||
@@ -35,7 +35,10 @@ public class UserContext {
|
|||||||
public static void setAccount(String account) {
|
public static void setAccount(String account) {
|
||||||
ACCOUNT.set(account);
|
ACCOUNT.set(account);
|
||||||
}
|
}
|
||||||
/** 当前登录账号(家长端=学生账号;createBy/updateBy、publisherAccount 取自此) */
|
/**
|
||||||
|
* 当前登录账号。仅用于需要以「账号」为键的外呼场景(如班级服务按账号反查家长名下学生);
|
||||||
|
* 业务落库一律用 {@link #getUserId()}(发布人ID、createBy/updateBy 审计列),不再存账号。
|
||||||
|
*/
|
||||||
public static String getAccount() {
|
public static String getAccount() {
|
||||||
return ACCOUNT.get();
|
return ACCOUNT.get();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- notice 表补 sch_id 列(多租户隔离)
|
||||||
|
-- 决策 D3:发布时从 BoaUser.currentRole.schId 填充,查询时按校过滤
|
||||||
|
--
|
||||||
|
-- 对应实体:Notice.schId / NoticeRepository 多租户统计方法
|
||||||
|
--
|
||||||
|
-- 执行方式:由 DBA 手动在对应环境执行,AI 不代跑
|
||||||
|
-- MySQL 5.7+ 兼容
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 1) 加列
|
||||||
|
ALTER TABLE `notice`
|
||||||
|
ADD COLUMN `sch_id` INT NULL COMMENT '学校ID(多租户隔离:发布时从当前用户角色填充)';
|
||||||
|
|
||||||
|
-- 2) 加索引(用于按学校过滤查询)
|
||||||
|
CREATE INDEX `idx_notice_sch_id` ON `notice` (`sch_id`);
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 存量数据回填脚本
|
||||||
|
-- 1. notice.sch_id:按发布人所属学校回填(从 sys_user_role_rel 取 main=1 的 sch_id)
|
||||||
|
-- 2. notice_receiver:从 notice.receive_list JSON 字符串展开为关联表行
|
||||||
|
--
|
||||||
|
-- 幂等设计:可重复执行,已存在的数据不会重复插入
|
||||||
|
-- 执行方式:由 DBA 手动在对应环境执行,AI 不代跑
|
||||||
|
-- MySQL 5.7+ 兼容
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Part 1:回填 notice.sch_id
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 方式一:如果 base_user / sys_user 表有 sch_id 字段,直接关联回填
|
||||||
|
-- 注意:需根据实际平台库表结构调整关联条件
|
||||||
|
UPDATE `notice` n
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT DISTINCT u.login_name, u.sch_id
|
||||||
|
FROM base_user u
|
||||||
|
WHERE u.sch_id IS NOT NULL
|
||||||
|
) t ON n.publisher_account = t.login_name
|
||||||
|
SET n.sch_id = t.sch_id
|
||||||
|
WHERE n.sch_id IS NULL
|
||||||
|
AND n.publisher_account IS NOT NULL
|
||||||
|
AND n.publisher_account != '';
|
||||||
|
|
||||||
|
-- 方式二(备用):如果用户学校关系在 sys_user_role_rel 表
|
||||||
|
-- 取每个用户的主身份学校(is_main=1),回填 sch_id
|
||||||
|
-- 取消注释后执行(视实际库表结构选用)
|
||||||
|
--
|
||||||
|
-- UPDATE `notice` n
|
||||||
|
-- INNER JOIN (
|
||||||
|
-- SELECT r.account, r.sch_id
|
||||||
|
-- FROM sys_user_role_rel r
|
||||||
|
-- WHERE r.is_main = 1
|
||||||
|
-- AND r.sch_id IS NOT NULL
|
||||||
|
-- ) t ON n.publisher_account = t.account
|
||||||
|
-- SET n.sch_id = t.sch_id
|
||||||
|
-- WHERE n.sch_id IS NULL
|
||||||
|
-- AND n.publisher_account IS NOT NULL
|
||||||
|
-- AND n.publisher_account != '';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Part 2:从 notice.receive_list 展开到 notice_receiver 关联表
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 说明:
|
||||||
|
-- - notice.receive_list 存储格式有两种:
|
||||||
|
-- 1) "ALL"(全员通知)—— 不写明细行,跳过
|
||||||
|
-- 2) JSON 数组 ["account1","account2",...] 或逗号分隔串
|
||||||
|
-- - 本脚本使用 MySQL JSON 函数解析(MySQL 5.7+ 支持 JSON_EXTRACT)
|
||||||
|
-- - 对于非标准 JSON 格式(逗号分隔串),使用循环逐条插入
|
||||||
|
-- - 幂等:每次插入前先删除该通知的旧接收人记录
|
||||||
|
|
||||||
|
-- 注意:MySQL 5.7 的 JSON_TABLE 不可用,这里用存储过程逐条解析
|
||||||
|
-- 如果 receive_list 是标准 JSON 数组格式,可以直接用 JSON_EXTRACT
|
||||||
|
|
||||||
|
DELIMITER $$
|
||||||
|
|
||||||
|
-- 存储过程:展开单个通知的接收人列表
|
||||||
|
DROP PROCEDURE IF EXISTS `expand_notice_receivers`$$
|
||||||
|
CREATE PROCEDURE `expand_notice_receivers`()
|
||||||
|
BEGIN
|
||||||
|
DECLARE v_done INT DEFAULT 0;
|
||||||
|
DECLARE v_notice_id INT;
|
||||||
|
DECLARE v_receive_list TEXT;
|
||||||
|
DECLARE cur CURSOR FOR
|
||||||
|
SELECT id, receive_list
|
||||||
|
FROM notice
|
||||||
|
WHERE receive_list IS NOT NULL
|
||||||
|
AND TRIM(receive_list) != ''
|
||||||
|
AND UPPER(TRIM(receive_list)) != 'ALL'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM notice_receiver WHERE notice_id = notice.id LIMIT 1
|
||||||
|
);
|
||||||
|
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
|
||||||
|
|
||||||
|
OPEN cur;
|
||||||
|
read_loop: LOOP
|
||||||
|
FETCH cur INTO v_notice_id, v_receive_list;
|
||||||
|
IF v_done = 1 THEN
|
||||||
|
LEAVE read_loop;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 清理旧数据(幂等)
|
||||||
|
DELETE FROM notice_receiver WHERE notice_id = v_notice_id;
|
||||||
|
|
||||||
|
-- 尝试 JSON 数组解析
|
||||||
|
-- receive_list 格式: ["account1","account2"] 或 account1,account2
|
||||||
|
SET v_receive_list = TRIM(v_receive_list);
|
||||||
|
|
||||||
|
-- 去除首尾方括号(如有)
|
||||||
|
IF LEFT(v_receive_list, 1) = '[' AND RIGHT(v_receive_list, 1) = ']' THEN
|
||||||
|
SET v_receive_list = SUBSTRING(v_receive_list, 2, CHAR_LENGTH(v_receive_list) - 2);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 替换引号为空,按逗号分隔
|
||||||
|
SET v_receive_list = REPLACE(REPLACE(v_receive_list, '"', ''), '''', '');
|
||||||
|
|
||||||
|
-- 逐条插入
|
||||||
|
-- 简单实现:用 FIND_IN_SET 不适用于变量,改用临时表
|
||||||
|
BEGIN
|
||||||
|
DECLARE v_pos INT DEFAULT 1;
|
||||||
|
DECLARE v_account VARCHAR(64);
|
||||||
|
DECLARE v_remaining TEXT;
|
||||||
|
SET v_remaining = v_receive_list;
|
||||||
|
|
||||||
|
WHILE v_remaining IS NOT NULL AND TRIM(v_remaining) != '' DO
|
||||||
|
IF LOCATE(',', v_remaining) > 0 THEN
|
||||||
|
SET v_account = TRIM(SUBSTRING_INDEX(v_remaining, ',', 1));
|
||||||
|
SET v_remaining = SUBSTRING(v_remaining, LOCATE(',', v_remaining) + 1);
|
||||||
|
ELSE
|
||||||
|
SET v_account = TRIM(v_remaining);
|
||||||
|
SET v_remaining = NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_account != '' AND v_account IS NOT NULL THEN
|
||||||
|
INSERT INTO notice_receiver (notice_id, account, receive_type, create_time)
|
||||||
|
VALUES (v_notice_id, v_account, 1, NOW());
|
||||||
|
END IF;
|
||||||
|
END WHILE;
|
||||||
|
END;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
CLOSE cur;
|
||||||
|
END$$
|
||||||
|
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
-- 执行存储过程
|
||||||
|
CALL expand_notice_receivers();
|
||||||
|
|
||||||
|
-- 清理临时存储过程
|
||||||
|
DROP PROCEDURE IF EXISTS `expand_notice_receivers`;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 回填结果验证查询(执行后手动检查)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 检查1:sch_id 回填覆盖率
|
||||||
|
-- SELECT
|
||||||
|
-- COUNT(*) AS total,
|
||||||
|
-- SUM(CASE WHEN sch_id IS NOT NULL THEN 1 ELSE 0 END) AS sch_id_filled,
|
||||||
|
-- SUM(CASE WHEN sch_id IS NULL THEN 1 ELSE 0 END) AS sch_id_null
|
||||||
|
-- FROM notice;
|
||||||
|
|
||||||
|
-- 检查2:notice_receiver 展开行数
|
||||||
|
-- SELECT COUNT(*) FROM notice_receiver;
|
||||||
|
|
||||||
|
-- 检查3:未展开的通知(receive_list 非空非 ALL 但 receiver 表无记录)
|
||||||
|
-- SELECT n.id, n.receive_list
|
||||||
|
-- FROM notice n
|
||||||
|
-- LEFT JOIN notice_receiver r ON r.notice_id = n.id
|
||||||
|
-- WHERE n.receive_list IS NOT NULL
|
||||||
|
-- AND TRIM(n.receive_list) != ''
|
||||||
|
-- AND UPPER(TRIM(n.receive_list)) != 'ALL'
|
||||||
|
-- AND r.id IS NULL;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- notice_receiver 建表(替代 notice.receive_list JSON 字符串存储)
|
||||||
|
-- 决策 D2-B:拆关联表,支撑阅读情况/催收/统计 SQL 化
|
||||||
|
--
|
||||||
|
-- 对应实体:com.yunhai.notice.entity.NoticeReceiver
|
||||||
|
-- 索引:idx_notice_id(notice_id) + idx_account(account)
|
||||||
|
--
|
||||||
|
-- 执行方式:由 DBA 手动在对应环境执行,AI 不代跑
|
||||||
|
-- MySQL 5.7+ 兼容
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `notice_receiver` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
|
||||||
|
`notice_id` INT NOT NULL COMMENT '通知ID(关联 notice.id)',
|
||||||
|
`account` VARCHAR(64) NOT NULL COMMENT '接收人登录账号',
|
||||||
|
`receive_type` INT NOT NULL DEFAULT 1 COMMENT '接收类型:1 接收 / 2 抄送',
|
||||||
|
`create_by` VARCHAR(64) NULL COMMENT '创建人登录账号(审计填充)',
|
||||||
|
`create_time` DATETIME NULL COMMENT '创建时间(审计填充)',
|
||||||
|
`update_by` VARCHAR(64) NULL COMMENT '更新人登录账号(审计填充)',
|
||||||
|
`update_time` DATETIME NULL COMMENT '更新时间(审计填充)',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_notice_id` (`notice_id`),
|
||||||
|
KEY `idx_account` (`account`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知接收人关联表';
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 活动信息发布模块 · 建表脚本(sys_dict / fa_activity / fa_activity_read / fa_activity_signup / operation_log)
|
||||||
|
-- 说明:应用配置 ddl-auto=update 会自动建表,本脚本供 DBA 手工建库/核对字段使用。
|
||||||
|
-- 库:family_activity(本地开发;建库:CREATE DATABASE family_activity DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;)
|
||||||
|
-- 审计列 create_by / update_by 自本版起为 INT,存「平台用户ID」,不再存登录账号。
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `sys_dict` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`sch_id` INT NULL DEFAULT 0 COMMENT '学校ID(多租户隔离;0=全局种子,所有学校可见)',
|
||||||
|
`dict_type` VARCHAR(50) NULL COMMENT '字典类型:dept=主办部门 / category=活动分类',
|
||||||
|
`dict_key` VARCHAR(100) NULL COMMENT '字典key(前端 CodeID)',
|
||||||
|
`dict_value` VARCHAR(200) NULL COMMENT '字典value(显示名称)',
|
||||||
|
`sort_order` INT NULL DEFAULT 0 COMMENT '排序号',
|
||||||
|
`enable_flag` INT NULL DEFAULT 1 COMMENT '是否启用:0 禁用 / 1 启用',
|
||||||
|
`create_by` INT NULL COMMENT '创建人ID(平台用户ID)',
|
||||||
|
`create_time` DATETIME NULL COMMENT '创建时间',
|
||||||
|
`update_by` INT NULL COMMENT '更新人ID(平台用户ID)',
|
||||||
|
`update_time` DATETIME NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_sys_dict_sch_type` (`sch_id`, `dict_type`, `sort_order`)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '系统字典(主办部门/活动分类,按学校隔离)';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `fa_activity` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`sch_id` INT NULL COMMENT '学校ID(数据隔离)',
|
||||||
|
`title` VARCHAR(200) NULL COMMENT '标题',
|
||||||
|
`content` TEXT NULL COMMENT '活动内容(富文本)',
|
||||||
|
`category` VARCHAR(50) NULL COMMENT '活动分类',
|
||||||
|
`venue` VARCHAR(200) NULL COMMENT '活动地点',
|
||||||
|
`start_time` DATETIME NULL COMMENT '活动开始时间',
|
||||||
|
`end_time` DATETIME NULL COMMENT '活动结束时间',
|
||||||
|
`signup_deadline` DATETIME NULL COMMENT '报名截止时间',
|
||||||
|
`receipt_mode` VARCHAR(16) NOT NULL DEFAULT 'none' COMMENT '回执方式:none/signup/confirm',
|
||||||
|
`quota` INT NOT NULL DEFAULT 0 COMMENT '名额上限(0 不限)',
|
||||||
|
`allow_accompany` INT NOT NULL DEFAULT 0 COMMENT '是否允许家长随行:0/1',
|
||||||
|
`cost` DECIMAL(10,2) NULL COMMENT '活动费用(元)',
|
||||||
|
`dept_id` VARCHAR(50) NULL COMMENT '主办部门ID(sys_dict.dict_key)',
|
||||||
|
`dept_name` VARCHAR(100) NULL COMMENT '主办部门名称(冗余)',
|
||||||
|
`publisher_id` INT NULL COMMENT '发布人ID(平台用户ID;家长端=学生ID)',
|
||||||
|
`publisher_name` VARCHAR(50) NULL COMMENT '发布人姓名',
|
||||||
|
`status` INT NOT NULL DEFAULT 0 COMMENT '发送状态:0 草稿 / 1 已发布',
|
||||||
|
`topmost` INT NOT NULL DEFAULT 0 COMMENT '置顶:0/1',
|
||||||
|
`is_pulled` INT NOT NULL DEFAULT 0 COMMENT '撤回:0/1(家长端不可见)',
|
||||||
|
`timing_send_time` DATETIME NULL COMMENT '定时发送时间',
|
||||||
|
`sms_flag` INT NOT NULL DEFAULT 0 COMMENT '短信发送标记:0/1(仅存储)',
|
||||||
|
`receive_type` VARCHAR(16) NOT NULL DEFAULT 'class' COMMENT '接收对象类型:class=班级',
|
||||||
|
`receive_list` TEXT NULL COMMENT '接收列表:"ALL" 或班级id逗号串',
|
||||||
|
`source_id` INT NULL COMMENT '转发/复用源活动ID',
|
||||||
|
`create_by` INT NULL COMMENT '创建人ID(平台用户ID)',
|
||||||
|
`create_time` DATETIME NULL COMMENT '创建时间',
|
||||||
|
`update_by` INT NULL COMMENT '更新人ID(平台用户ID)',
|
||||||
|
`update_time` DATETIME NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_fa_activity_sch_status` (`sch_id`, `status`, `is_pulled`),
|
||||||
|
KEY `idx_fa_activity_publisher` (`sch_id`, `publisher_id`)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '活动主表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `fa_activity_read` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`activity_id` INT NOT NULL COMMENT '活动ID',
|
||||||
|
`class_id` VARCHAR(50) NOT NULL COMMENT '班级ID(已读按班级粒度)',
|
||||||
|
`student_id` INT NULL COMMENT '首读学生ID(家长=学生)',
|
||||||
|
`read_time` DATETIME NULL COMMENT '阅读时间',
|
||||||
|
`create_by` INT NULL COMMENT '创建人ID(平台用户ID)',
|
||||||
|
`create_time` DATETIME NULL COMMENT '创建时间',
|
||||||
|
`update_by` INT NULL COMMENT '更新人ID(平台用户ID)',
|
||||||
|
`update_time` DATETIME NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_fa_read_activity_class` (`activity_id`, `class_id`)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '活动已读记录(班级粒度)';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `fa_activity_signup` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键(前端 signupId)',
|
||||||
|
`activity_id` INT NOT NULL COMMENT '活动ID',
|
||||||
|
`sch_id` INT NULL COMMENT '学校ID(数据隔离)',
|
||||||
|
`student_id` INT NULL COMMENT '学生ID',
|
||||||
|
`student_name` VARCHAR(50) NULL COMMENT '学生姓名',
|
||||||
|
`class_id` VARCHAR(50) NULL COMMENT '班级ID',
|
||||||
|
`class_name` VARCHAR(100) NULL COMMENT '班级名称',
|
||||||
|
`guardian_name` VARCHAR(50) NULL COMMENT '家长姓名',
|
||||||
|
`guardian_phone` VARCHAR(20) NULL COMMENT '联系电话',
|
||||||
|
`join_count` INT NOT NULL DEFAULT 1 COMMENT '参加人数(含学生本人)',
|
||||||
|
`status` VARCHAR(16) NOT NULL DEFAULT 'none' COMMENT 'none/signed/confirmed/canceled',
|
||||||
|
`remark` VARCHAR(500) NULL COMMENT '备注(过敏史、随行说明等)',
|
||||||
|
`signed_at` DATETIME NULL COMMENT '回执时间',
|
||||||
|
`create_by` INT NULL COMMENT '创建人ID(平台用户ID)',
|
||||||
|
`create_time` DATETIME NULL COMMENT '创建时间',
|
||||||
|
`update_by` INT NULL COMMENT '更新人ID(平台用户ID)',
|
||||||
|
`update_time` DATETIME NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_fa_signup_activity_student` (`activity_id`, `student_id`),
|
||||||
|
KEY `idx_fa_signup_activity_status` (`activity_id`, `status`)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '活动回执/报名记录';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `operation_log` (
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`operator_id` INT NULL COMMENT '操作人平台用户ID',
|
||||||
|
`operate_time` DATETIME NULL COMMENT '操作时间',
|
||||||
|
`operator` VARCHAR(64) NULL COMMENT '操作人姓名',
|
||||||
|
`ip` VARCHAR(64) NULL COMMENT '客户端IP',
|
||||||
|
`module` VARCHAR(64) NULL COMMENT '模块名称',
|
||||||
|
`content` VARCHAR(3000) NULL COMMENT '操作内容',
|
||||||
|
`sch_id` INT NULL COMMENT '学校ID(超管=0)',
|
||||||
|
`request_uri` VARCHAR(255) NULL COMMENT '请求URI',
|
||||||
|
`request_method` VARCHAR(16) NULL COMMENT '请求方法',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_oplog_sch_time` (`sch_id`, `operate_time`)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '业务操作日志';
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 活动信息发布模块 · 账号改ID迁移脚本
|
||||||
|
-- 变更:实体不再保存教师/学生的登录账号(publisher_account / account /
|
||||||
|
-- guardian_account / user_account / creator_account / operation_log.account),
|
||||||
|
-- 统一改存平台用户ID / 学生ID。业务回退查询见各 Repository。
|
||||||
|
-- 说明:
|
||||||
|
-- 1. 新增 ID 列后,历史行无法在本库内把账号映射为平台用户ID(用户表在基础平台库),
|
||||||
|
-- publisher_id / creator_id 历史行为 NULL;fa_activity_read.student_id、
|
||||||
|
-- fa_activity_signup.student_id 本就落了 ID,无需回填。
|
||||||
|
-- 2. fa_activity_signup 唯一键由 (activity_id, guardian_account) 改为
|
||||||
|
-- (activity_id, student_id)。如库中该唯一键索引名由 Hibernate 自动生成(UK*),
|
||||||
|
-- 请先 SHOW CREATE TABLE fa_activity_signup 核对名称后替换。
|
||||||
|
-- 3. 本脚本可重复执行前请先核对列是否仍存在(MySQL DROP COLUMN 不存在会报错)。
|
||||||
|
-- =====================================================================
|
||||||
|
USE `family_activity`;
|
||||||
|
|
||||||
|
-- 1) 活动主表:发布人账号 → 发布人ID
|
||||||
|
ALTER TABLE `fa_activity`
|
||||||
|
ADD COLUMN `publisher_id` INT NULL COMMENT '发布人ID(平台用户ID;家长端=学生ID)' AFTER `publisher_name`;
|
||||||
|
ALTER TABLE `fa_activity` DROP COLUMN `publisher_account`;
|
||||||
|
-- 注:实体未声明 @Index 时 Hibernate 不会建 idx_fa_activity_publisher;
|
||||||
|
-- 若目标库手工建过该账号索引,请在此先 DROP 后按新列重建。
|
||||||
|
ALTER TABLE `fa_activity`
|
||||||
|
ADD INDEX `idx_fa_activity_publisher` (`sch_id`, `publisher_id`);
|
||||||
|
|
||||||
|
-- 2) 已读记录:操作账号 → 首读学生ID(已读仍按班级粒度去重,学生ID仅溯源)
|
||||||
|
ALTER TABLE `fa_activity_read`
|
||||||
|
ADD COLUMN `student_id` INT NULL COMMENT '首读学生ID(家长=学生)' AFTER `class_id`;
|
||||||
|
ALTER TABLE `fa_activity_read` DROP COLUMN `account`;
|
||||||
|
|
||||||
|
-- 3) 回执/报名记录:删除家长账号(家长=学生,student_id 即身份),唯一键随之迁移
|
||||||
|
ALTER TABLE `fa_activity_signup` DROP INDEX `UK68upl3jts93doy45ggqwqdf4o`;
|
||||||
|
ALTER TABLE `fa_activity_signup` DROP COLUMN `guardian_account`;
|
||||||
|
ALTER TABLE `fa_activity_signup`
|
||||||
|
ADD UNIQUE KEY `uk_fa_signup_activity_student` (`activity_id`, `student_id`);
|
||||||
|
|
||||||
|
-- 4) 联系组:创建人账号 → 创建人ID
|
||||||
|
ALTER TABLE `contacts_group`
|
||||||
|
ADD COLUMN `creator_id` INT NULL COMMENT '创建人ID(平台用户ID)' AFTER `creator_account`;
|
||||||
|
ALTER TABLE `contacts_group` DROP COLUMN `creator_account`;
|
||||||
|
|
||||||
|
-- 5) 联系组成员:删除用户账号快照(user_id 即身份)
|
||||||
|
ALTER TABLE `contacts_group_user` DROP COLUMN `user_account`;
|
||||||
|
|
||||||
|
-- 6) 操作日志:删除操作人账号(operator_id 即身份)
|
||||||
|
ALTER TABLE `operation_log` DROP COLUMN `account`;
|
||||||
Reference in New Issue
Block a user