@@ -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 转换)。
|
||||
|
||||
@@ -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}。
|
||||
* <p>当前登录人取平台用户ID({@code UserContext.getUserId()},由 {@code ControllerUserContextAspect} 从
|
||||
* {@code BoaUser.id} 填充),审计列为 INT;不再落登录账号。无登录态(定时任务/脚本)时留空。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
|
||||
@@ -10,6 +10,8 @@ import javax.persistence.Table;
|
||||
|
||||
/**
|
||||
* 系统字典(发文部门等)。对应原 .NET Common_DeptDic 表。
|
||||
* <p>按学校隔离(sch_id):读写取当前 {@code UserContext.getSchId()};
|
||||
* {@code schId=0} 视为全局种子(学校无自有字典时兜底可用),历史 {@code sch_id} 为空的行同样按全局处理。</p>
|
||||
*/
|
||||
@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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -6,10 +6,22 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 系统字典仓储。
|
||||
* <p>多租户口径:读走 {@code ...SchId...}(服务层先查本校,空则回退 sch_id=0 全局种子);
|
||||
* 写走 {@code findById},由服务层做同校校验(必要时先 copy-on-write 复制种子)。</p>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.yunhai.familyactivity.entity.SysDict;
|
||||
import com.yunhai.familyactivity.repository.ActivityRepository;
|
||||
import com.yunhai.familyactivity.repository.SysDictRepository;
|
||||
import com.yunhai.familyactivity.service.IDictService;
|
||||
import com.yunhai.familyactivity.utils.UserContext;
|
||||
import com.yunhai.platform.auth.LoginUser;
|
||||
import com.yunhai.platform.auth.UserContextHolder;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
@@ -22,6 +23,17 @@ import java.util.stream.Collectors;
|
||||
* 系统字典服务实现。
|
||||
* 对应 C# CommonController + GetSysDict.cs 中的字典管理逻辑。
|
||||
* 支持两类字典: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
|
||||
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<SysDict> list = sysDictRepository.findByDictTypeAndEnableFlagOrderBySortOrderAsc(dictType, 1);
|
||||
List<SysDict> list = readVisible(dictType, true);
|
||||
if (list.isEmpty()) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, emptyTip(dictType));
|
||||
}
|
||||
// 按 sortOrder 升序返回 {k, v}
|
||||
return list.stream()
|
||||
.sorted(Comparator.comparingInt(d -> d.getSortOrder() == null ? Integer.MAX_VALUE : d.getSortOrder()))
|
||||
.sorted(Comparator.comparingInt(DictService::sortOf))
|
||||
.map(d -> {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("k", d.getDictKey());
|
||||
@@ -71,9 +88,9 @@ public class DictService implements IDictService {
|
||||
if (dictType == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误");
|
||||
}
|
||||
List<SysDict> list = sysDictRepository.findByDictTypeOrderBySortOrderAsc(dictType);
|
||||
List<SysDict> list = readVisible(dictType, false);
|
||||
return list.stream()
|
||||
.sorted(Comparator.comparingInt(d -> d.getSortOrder() == null ? 999 : d.getSortOrder()))
|
||||
.sorted(Comparator.comparingInt(DictService::sortOf))
|
||||
.map(d -> {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("SerID", d.getId());
|
||||
@@ -95,21 +112,25 @@ public class DictService implements IDictService {
|
||||
if (type == null || !StringUtils.hasText(name)) {
|
||||
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 ->
|
||||
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) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "已存在该分类,请重新填写!");
|
||||
}
|
||||
|
||||
// 计算 sortOrder = max + 1
|
||||
int maxSort = existing.stream()
|
||||
.filter(d -> d.getEnableFlag() != 3)
|
||||
.mapToInt(d -> d.getSortOrder() != null ? d.getSortOrder() : 0)
|
||||
.filter(d -> !isRemoved(d))
|
||||
.mapToInt(DictService::sortValue)
|
||||
.max().orElse(0);
|
||||
|
||||
SysDict dict = new SysDict();
|
||||
dict.setSchId(currentSchId());
|
||||
dict.setDictType(type);
|
||||
dict.setDictValue(name.trim());
|
||||
dict.setSortOrder(maxSort + 1);
|
||||
@@ -127,16 +148,17 @@ public class DictService implements IDictService {
|
||||
if (type == null || serID == null) {
|
||||
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 ->
|
||||
!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) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "已存在该分类,请重新填写!");
|
||||
}
|
||||
|
||||
SysDict dict = sysDictRepository.findById(serID)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "字典记录不存在"));
|
||||
SysDict dict = findOwned(type, serID);
|
||||
if (StringUtils.hasText(name)) {
|
||||
dict.setDictValue(name.trim());
|
||||
}
|
||||
@@ -155,8 +177,9 @@ public class DictService implements IDictService {
|
||||
if (type == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误!");
|
||||
}
|
||||
SysDict dict = sysDictRepository.findById(serID)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "字典记录不存在"));
|
||||
materialize(type);
|
||||
|
||||
SysDict dict = findOwned(type, serID);
|
||||
if (DICT_TYPE_CATEGORY.equals(type)) {
|
||||
// 活动分类:fa_activity.category 冗余存分类名称,按名称判断引用
|
||||
if (activityRepository.existsByCategory(dict.getDictValue())) {
|
||||
@@ -180,7 +203,9 @@ public class DictService implements IDictService {
|
||||
if (type == null || !StringUtils.hasText(ids)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误");
|
||||
}
|
||||
List<SysDict> list = sysDictRepository.findByDictTypeOrderBySortOrderAsc(type);
|
||||
materialize(type);
|
||||
|
||||
List<SysDict> list = rowsOf(currentSchId(), type, false);
|
||||
if (list.isEmpty()) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "字典类型错误,未找到该字典类型");
|
||||
}
|
||||
@@ -192,21 +217,20 @@ public class DictService implements IDictService {
|
||||
.mapToInt(Integer::parseInt)
|
||||
.toArray();
|
||||
|
||||
int maxSort = list.stream()
|
||||
.mapToInt(d -> d.getSortOrder() != null ? d.getSortOrder() : idArr.length)
|
||||
.max().orElse(idArr.length);
|
||||
int maxSort = list.stream().mapToInt(DictService::sortValue).max().orElse(idArr.length);
|
||||
|
||||
// lambda 中不能修改局部变量,用数组包装
|
||||
final int[] indexOrd = { maxSort };
|
||||
|
||||
// 逆序遍历,从最大序号递减
|
||||
// 逆序遍历,从最大序号递减;前端传来的 id 可能是种子 id,按 dict_key 归位到本校行
|
||||
for (int i = idArr.length - 1; i >= 0; i--) {
|
||||
int id = idArr[i];
|
||||
sysDictRepository.findById(id).ifPresent(dict -> {
|
||||
SysDict dict = ownedOrNull(type, idArr[i]);
|
||||
if (dict == null) {
|
||||
continue;
|
||||
}
|
||||
dict.setSortOrder(indexOrd[0]);
|
||||
sysDictRepository.save(dict);
|
||||
indexOrd[0]--;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,15 +252,129 @@ public class DictService implements IDictService {
|
||||
|
||||
/**
|
||||
* 按主办部门字典 key 解析部门名称(对应 C# GetDeptName)。
|
||||
* 先按本校字典解析,未命中回退全局种子,最后退回不区分学校的旧口径,
|
||||
* 保证历史活动(发布时未带学校上下文的字典)仍能展示部门名。
|
||||
*/
|
||||
@Override
|
||||
public String resolveDeptName(String deptId) {
|
||||
if (!StringUtils.hasText(deptId)) {
|
||||
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)
|
||||
.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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,7 @@ import lombok.Value;
|
||||
/**
|
||||
* 当前登录用户上下文(ThreadLocal)。
|
||||
* <p>由 {@link com.yunhai.familyactivity.aspect.ControllerUserContextAspect} 在请求进入控制器前填充、
|
||||
* 请求结束后清理;Service 层统一通过本类获取当前用户(发布人账号、schId 数据隔离、操作日志)。
|
||||
* 请求结束后清理;Service 层统一通过本类获取当前用户(发布人ID/姓名、schId 数据隔离、操作日志、审计字段)。
|
||||
* 参考实现:UserclassService {@code com.yunhai.bschclassmanage.utils.UserContext}。</p>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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`),
|
||||
|
||||
Reference in New Issue
Block a user