diff --git a/docs/后端重构计划-活动信息发布.md b/docs/后端重构计划-活动信息发布.md index bbdcbbc..bb0ce6b 100644 --- a/docs/后端重构计划-活动信息发布.md +++ b/docs/后端重构计划-活动信息发布.md @@ -429,3 +429,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 所列闭环验证。 > 供核对:`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 转换)。 diff --git a/src/main/java/com/yunhai/familyactivity/common/listener/NoticeAuditListener.java b/src/main/java/com/yunhai/familyactivity/common/listener/NoticeAuditListener.java index a1cfa0b..9ca1967 100644 --- a/src/main/java/com/yunhai/familyactivity/common/listener/NoticeAuditListener.java +++ b/src/main/java/com/yunhai/familyactivity/common/listener/NoticeAuditListener.java @@ -1,6 +1,7 @@ package com.yunhai.familyactivity.common.listener; import com.yunhai.familyactivity.entity.BaseEntity; +import com.yunhai.familyactivity.utils.UserContext; import com.yunhai.platform.auth.LoginUser; import com.yunhai.platform.auth.UserContextHolder; import org.springframework.stereotype.Component; @@ -12,7 +13,8 @@ import java.time.LocalDateTime; /** * JPA 审计监听器:在实体持久化/更新前自动填充创建人/更新人(平台当前登录人)与时间戳。 * 对应 {@link BaseEntity} 的 createBy / updateBy / createTime / updateTime 字段。 - * 当前登录人取自基础平台 {@link UserContextHolder}。 + *
当前登录人取平台用户ID({@code UserContext.getUserId()},由 {@code ControllerUserContextAspect} 从 + * {@code BoaUser.id} 填充),审计列为 INT;不再落登录账号。无登录态(定时任务/脚本)时留空。
*/ @Component public class NoticeAuditListener { @@ -22,26 +24,43 @@ public class NoticeAuditListener { LocalDateTime now = LocalDateTime.now(); entity.setCreateTime(now); entity.setUpdateTime(now); - String account = currentAccount(); - if (account != null) { + Integer userId = currentUserId(); + if (userId != null) { if (entity.getCreateBy() == null) { - entity.setCreateBy(account); + entity.setCreateBy(userId); } - entity.setUpdateBy(account); + entity.setUpdateBy(userId); } } @PreUpdate public void preUpdate(BaseEntity entity) { entity.setUpdateTime(LocalDateTime.now()); - String account = currentAccount(); - if (account != null) { - entity.setUpdateBy(account); + Integer userId = currentUserId(); + if (userId != null) { + 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(); - 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; + } } } diff --git a/src/main/java/com/yunhai/familyactivity/entity/BaseEntity.java b/src/main/java/com/yunhai/familyactivity/entity/BaseEntity.java index 81646dd..92ac7d8 100644 --- a/src/main/java/com/yunhai/familyactivity/entity/BaseEntity.java +++ b/src/main/java/com/yunhai/familyactivity/entity/BaseEntity.java @@ -24,10 +24,10 @@ public abstract class BaseEntity implements Serializable { private Integer id; /** - * 创建人登录账号 + * 创建人ID(平台用户ID,不再存登录账号) */ @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; /** - * 更新人登录账号 + * 更新人ID(平台用户ID,不再存登录账号) */ @Column(name = "update_by") - private String updateBy; + private Integer updateBy; /** * 更新时间 diff --git a/src/main/java/com/yunhai/familyactivity/entity/SysDict.java b/src/main/java/com/yunhai/familyactivity/entity/SysDict.java index 8e49bbe..1e473ef 100644 --- a/src/main/java/com/yunhai/familyactivity/entity/SysDict.java +++ b/src/main/java/com/yunhai/familyactivity/entity/SysDict.java @@ -10,6 +10,8 @@ import javax.persistence.Table; /** * 系统字典(发文部门等)。对应原 .NET Common_DeptDic 表。 + *按学校隔离(sch_id):读写取当前 {@code UserContext.getSchId()}; + * {@code schId=0} 视为全局种子(学校无自有字典时兜底可用),历史 {@code sch_id} 为空的行同样按全局处理。
*/ @NoArgsConstructor @Data @@ -18,6 +20,10 @@ import javax.persistence.Table; @EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) public class SysDict extends BaseEntity { + /** 学校ID(多租户隔离;0=全局种子,所有学校可见) */ + @Column(name = "sch_id") + private Integer schId = 0; + /** 字典类型(如 "dept"=发文部门) */ @Column(name = "dict_type", length = 50) private String dictType; diff --git a/src/main/java/com/yunhai/familyactivity/model/activity/ActivityVo.java b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityVo.java index 381f1c0..97865c9 100644 --- a/src/main/java/com/yunhai/familyactivity/model/activity/ActivityVo.java +++ b/src/main/java/com/yunhai/familyactivity/model/activity/ActivityVo.java @@ -105,11 +105,13 @@ public class ActivityVo { /** 回执/报名进度(草稿或仅通知为 null) */ private ActivityProgress progress; - private String createBy; + /** 创建人ID(平台用户ID) */ + private Integer createBy; private String createTime; - private String updateBy; + /** 更新人ID(平台用户ID) */ + private Integer updateBy; private String updateTime; diff --git a/src/main/java/com/yunhai/familyactivity/repository/SysDictRepository.java b/src/main/java/com/yunhai/familyactivity/repository/SysDictRepository.java index 853c961..35709d1 100644 --- a/src/main/java/com/yunhai/familyactivity/repository/SysDictRepository.java +++ b/src/main/java/com/yunhai/familyactivity/repository/SysDictRepository.java @@ -6,10 +6,22 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; import java.util.Optional; +/** + * 系统字典仓储。 + *多租户口径:读走 {@code ...SchId...}(服务层先查本校,空则回退 sch_id=0 全局种子); + * 写走 {@code findById},由服务层做同校校验(必要时先 copy-on-write 复制种子)。
+ */ public interface SysDictRepository extends JpaRepository多租户(按学校隔离,sys_dict.sch_id):
+ *这样既避免「A 校改名影响 B 校」的跨租户串改,也让前端拿到的 SerID 在首次写入后仍可稳定回写 + * (种子 id → 同 dict_key 的本校副本)。
*/ @Service public class DictService implements IDictService { @@ -33,6 +45,11 @@ public class DictService implements IDictService { /** 字典类型常量:活动分类 */ 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 private SysDictRepository sysDictRepository; @@ -47,13 +64,13 @@ public class DictService implements IDictService { if (dictType == null) { throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); } - List仅在本校该类型尚无任何数据时执行,故每个学校每种类型最多触发一次; + * 超管(schId=0)跳过,直接维护全局种子。
+ */ + private void materialize(String dictType) { + Integer schId = currentSchId(); + if (schId == GLOBAL_SCH_ID) { + return; + } + if (!sysDictRepository.findBySchIdAndDictTypeOrderBySortOrderAsc(schId, dictType).isEmpty()) { + return; + } + List由 {@link com.yunhai.familyactivity.aspect.ControllerUserContextAspect} 在请求进入控制器前填充、 - * 请求结束后清理;Service 层统一通过本类获取当前用户(发布人账号、schId 数据隔离、操作日志)。 + * 请求结束后清理;Service 层统一通过本类获取当前用户(发布人ID/姓名、schId 数据隔离、操作日志、审计字段)。 * 参考实现:UserclassService {@code com.yunhai.bschclassmanage.utils.UserContext}。
*/ public class UserContext { @@ -35,7 +35,10 @@ public class UserContext { public static void setAccount(String account) { ACCOUNT.set(account); } - /** 当前登录账号(家长端=学生账号;createBy/updateBy、publisherAccount 取自此) */ + /** + * 当前登录账号。仅用于需要以「账号」为键的外呼场景(如班级服务按账号反查家长名下学生); + * 业务落库一律用 {@link #getUserId()}(发布人ID、createBy/updateBy 审计列),不再存账号。 + */ public static String getAccount() { return ACCOUNT.get(); } diff --git a/src/main/resources/sql/fa_activity_tables.sql b/src/main/resources/sql/fa_activity_tables.sql index b3924e3..3ab9e1c 100644 --- a/src/main/resources/sql/fa_activity_tables.sql +++ b/src/main/resources/sql/fa_activity_tables.sql @@ -1,9 +1,26 @@ -- ===================================================================== --- 活动信息发布模块 · 建表脚本(fa_activity / fa_activity_read / fa_activity_signup / operation_log) +-- 活动信息发布模块 · 建表脚本(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(数据隔离)', @@ -30,9 +47,9 @@ CREATE TABLE IF NOT EXISTS `fa_activity` ( `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` VARCHAR(50) NULL COMMENT '创建人', + `create_by` INT NULL COMMENT '创建人ID(平台用户ID)', `create_time` DATETIME NULL COMMENT '创建时间', - `update_by` VARCHAR(50) 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`), @@ -45,9 +62,9 @@ CREATE TABLE IF NOT EXISTS `fa_activity_read` ( `class_id` VARCHAR(50) NOT NULL COMMENT '班级ID(已读按班级粒度)', `student_id` INT NULL COMMENT '首读学生ID(家长=学生)', `read_time` DATETIME NULL COMMENT '阅读时间', - `create_by` VARCHAR(50) NULL COMMENT '创建人', + `create_by` INT NULL COMMENT '创建人ID(平台用户ID)', `create_time` DATETIME NULL COMMENT '创建时间', - `update_by` VARCHAR(50) 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`) @@ -67,9 +84,9 @@ CREATE TABLE IF NOT EXISTS `fa_activity_signup` ( `status` VARCHAR(16) NOT NULL DEFAULT 'none' COMMENT 'none/signed/confirmed/canceled', `remark` VARCHAR(500) NULL COMMENT '备注(过敏史、随行说明等)', `signed_at` DATETIME NULL COMMENT '回执时间', - `create_by` VARCHAR(50) NULL COMMENT '创建人', + `create_by` INT NULL COMMENT '创建人ID(平台用户ID)', `create_time` DATETIME NULL COMMENT '创建时间', - `update_by` VARCHAR(50) 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`),