Compare commits

...
11 Commits
Author SHA1 Message Date
ops 8dc7f38af0 fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:24:26 +00:00
ops cf77b6719c ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:36:57 +00:00
wanghu bb2bb93293 阅读
build / build-push (push) Successful in 58s
2026-09-23 17:19:45 +08:00
wanghu 33398a7f8a 阅读
build / build-push (push) Successful in 1m30s
2026-09-23 11:28:00 +08:00
wanghu 07c409c83c 阅读
build / build-push (push) Successful in 54s
2026-09-23 10:56:26 +08:00
wanghu 14b4e4c24f userid
build / build-push (push) Successful in 1m6s
2026-09-21 17:06:01 +08:00
wanghu 7edf4323dd 附件
build / build-push (push) Successful in 2m1s
2026-09-21 16:53:25 +08:00
wanghu 3eae05d77b userid
build / build-push (push) Successful in 32s
2026-09-20 11:10:39 +08:00
wanghu 58d0d6d973 base
build / build-push (push) Successful in 32s
2026-09-20 09:45:07 +08:00
wanghu 6ac8229474 Merge branch 'main' of http://gitea.yunhaixingtong.com/familyactivity/familyactivity-service
build / build-push (push) Failing after 10s
2026-09-20 09:35:41 +08:00
wanghu d8d11804db sql
Signed-off-by: wanghu <wanghu@yuanhai.com>
2026-09-20 09:34:54 +08:00
22 changed files with 718 additions and 127 deletions
+18 -9
View File
@@ -25,7 +25,10 @@ jobs:
options: --network infra-infra-net
steps:
- 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 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
@@ -55,18 +58,24 @@ jobs:
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
run: |
set -e
docker rm -f mavenbuild 2>/dev/null || true
docker run -d --name mavenbuild \
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
# 命名空间。固定名会在多仓/多条 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 \
-v /opt/docker/projman-m2:/root/.m2 \
-w /build maven:3.6.3-jdk-8 sleep 3600
docker cp "${PWD}/." mavenbuild:/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 mavenbuild sh -c 'cd /build && mvn -B -DskipTests clean package'
docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar'
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
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 "$BUILD_CONTAINER" sh -c 'cd /build && mvn -B -DskipTests clean package'
docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
mkdir -p "${PWD}/target"
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar"
docker rm -f mavenbuild
docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
docker rm -f "$BUILD_CONTAINER"
- name: 构建并推送镜像
run: |
+26 -7
View File
@@ -63,7 +63,7 @@ com.yunhai.familyactivity
├── entity/
│ ├── BaseEntity / SysDict / UpFile / ContactsGroup / ContactsGroupUser # 保留
│ ├── Activity # 新建:活动主表
│ ├── ActivityRead # 新建:已读记录(班级粒度)
│ ├── ActivityRead # 新建:已读记录(学生粒度)
│ ├── ActivitySignup # 新建:回执/报名记录
│ └── OperationLogRecord # 新建:操作日志表(参考 UserclassService)
├── enums/ # 新建:ActivityStatusEnum、ReceiptModeEnum、SignupStatusEnum
@@ -175,11 +175,14 @@ com.yunhai.familyactivity
| --- | --- | --- |
| id | int 自增 | |
| activityId | int | 活动 id |
| classId | varchar(50) | **班级粒度已读**(前端关键约定 4:`classRead`) |
| account | varchar(50) | 操作账号(家长=学生账号) |
| classId | varchar(50) | 班级 id |
| studentId | int | 阅读学生ID(家长端以学生身份打开;学生档案不可得时为 null) |
| 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`)
@@ -210,8 +213,8 @@ com.yunhai.familyactivity
1. **发布**(Create):draft=true 存草稿;否则置已发;`targetClassIds()` 统一解析 `receiveList`(`"ALL"`=全校班级);发布时按班级×学生**预生成回执记录**(receiptMode≠none 时,status=none),保证「应收人数」「未回执名单」可查。
2. **状态机**:`actStatus` 推导规则 —— status=草稿→`draft`;now < signupDeadline→`signup`;signupDeadline ≤ now < endTime→`closed`;now ≥ endTime→`finished`(receiptMode=none 时无报名态语义,仅展示)。
3. **进度统计**(`ActivityProgress`):`should`=应回执人数;`signed/confirmed/canceled` 按 signup 表 status 计数;`unsigned=should-signed-confirmed-canceled`;`usedQuota`=ΣjoinCount(canceled 不计);`full = quota>0 && usedQuota≥quota`;`signupRate`。`receiptMode=none` 或 `draft` 时 `progress=null`。
4. **已读**:`Detail(type=1)` / `MySignup` 标读(家长端打开即确认模式:`receiptMode=confirm` 且状态为 none 时同步置 `confirmed`);`InitializeRed` 一键全部已读;`ReadDetail` 按年级→班级分组返回读/未读。
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` 按年级→班级分组返回读/未读(该班任一学生已读即记为读)。
5. **报名**(Signup/CancelSignup):校验活动状态(signup 中)、截止时间、名额(quota>0 时 usedQuota+joinCount≤quota)、随行(allowAccompany);成功/失败回执业务码。
6. **管理动作**:Edit(含 sourceId 复用)、Delete(批量 + 级联删 read/signup)、UserPull 撤回、SetTopmost、ReceiveClasses 补发(新班级追加并补回执记录)、Expedite/Remind 催办(本期日志占位)、ExportSignups 导出 CSV(easyexcel 已在 pom,CSV 直接拼文本返回)。
7. **家长端**:`MyActivities` 分桶(todo 待回执/signed 已报名/finished 已结束/all)+ 计数;`MySignup` 活动详情+我的回执(同时标读/自动确认)。
@@ -238,7 +241,7 @@ com.yunhai.familyactivity
| POST | `/SetTopmost` | serid, topmost | 置顶 |
| POST | `/Expedite` | serid | 催办未读(日志占位) |
| POST | `/ReceiveClasses` | serid, classes | 追加接收班级(补回执) |
| GET | `/Signups` | serid, signupStatus | 回执名单(按班级分组+汇总) |
| GET | `/Signups` | serid, signupStatus | 回执名单(按班级分组+汇总;summary 含 `readCount`/`readRate`,名单项含 `read` 已读/未读) |
| GET | `/SignupDetail` | signupId | 单条回执详情 |
| POST | `/Review` | ReviewRequest | 处理回执 confirm/cancel/waitlist |
| 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 所列闭环验证。
> 供核对:`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;
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;
}
}
}
@@ -11,14 +11,15 @@ import java.time.LocalDateTime;
/**
* 活动已读记录(fa_activity_read)。
* 已读按「班级」粒度(前端 classRead 约定):应收/已读人数按班级 studentCount 累加;
* 同一活动同一班级只有一条记录,由唯一约束兜底。
* 已读按「学生(家庭)」粒度:每个家长账号打开一次记一条(activity+class+student 唯一),
* 已读人数 = 接收班级内的已读记录条数;班级的「读/未读」= 该班是否存在任一已读记录。
*/
@Getter
@Setter
@Entity
@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 {
/** 活动ID */
@@ -29,7 +30,7 @@ public class ActivityRead extends BaseEntity {
@Column(name = "class_id", length = 50, nullable = false)
private String classId;
/** 首读学生ID(家长端以学生身份打开,存学生ID) */
/** 阅读学生ID(家长端以学生身份打开;学生档案不可得时为 null,此时退化为班级粒度) */
@Column(name = "student_id")
private Integer studentId;
@@ -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;
@@ -43,8 +43,14 @@ public class ActivityProgress {
/** 待处理回执(名额超额/待定),统计用,列表场景恒为 0 */
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,
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) */
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;
@@ -40,6 +40,9 @@ public class SignupBrief {
/** 是否已回执/已报名 */
private boolean hasReceipt;
/** 阅读状态:该生本人是否已打开过活动(fa_activity_read 学生粒度) */
private boolean read;
/** 备注(如过敏史、随行人数说明) */
private String remark;
}
@@ -8,12 +8,17 @@ import java.util.List;
import java.util.Optional;
/**
* 活动已读记录仓库(班级粒度)。
* 活动已读记录仓库(学生粒度:activity + class + student 唯一)。
*/
public interface ActivityReadRepository extends JpaRepository<ActivityRead, Integer> {
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> findByActivityIdIn(Collection<Integer> activityIds);
@@ -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);
}
@@ -1,8 +1,8 @@
package com.yunhai.familyactivity.service;
import com.yunhai.familyactivity.entity.UpFile;
import com.yunhai.familyactivity.model.UpFileVo;
import com.yunhai.familyactivity.model.UpFileResultVo;
import com.yunhai.familyactivity.model.UpFileVo;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
@@ -504,20 +504,33 @@ public class ActivityService implements IActivityService {
.collect(Collectors.toList());
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()
.collect(Collectors.groupingBy(r -> r.getClassName() != null ? r.getClassName()
: classNameOf(classes, parseSafe(r.getClassId())), LinkedHashMap::new, Collectors.toList()));
List<SignupGroup> groups = byClass.entrySet().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());
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();
if (should == 0 && !records.isEmpty()) {
should = records.size(); // 班级数据不可达时降级为回执表实有人数
}
int readCount = (int) reads.stream()
.filter(r -> targetSet.contains(parseSafe(r.getClassId())))
.count();
SignupsResultVo vo = new SignupsResultVo();
vo.setActivityId(activity.getId());
@@ -532,7 +545,10 @@ public class ActivityService implements IActivityService {
ReceiptModeEnum mode = ReceiptModeEnum.of(activity.getReceiptMode());
vo.setReceiptMode(mode.getCode());
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);
return vo;
}
@@ -542,7 +558,11 @@ public class ActivityService implements IActivityService {
ActivitySignup record = activitySignupRepository.findById(signupId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "回执记录不存在"));
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();
vo.setSignupId(record.getId());
@@ -894,9 +914,10 @@ public class ActivityService implements IActivityService {
List<Integer> targetIds = targetClassIds(a, classes);
int allowCount = targetIds.stream().mapToInt(t -> classSize.getOrDefault(t, 0)).sum();
Set<Integer> targetSet = new LinkedHashSet<>(targetIds);
int readCount = readMap.getOrDefault(a.getId(), Collections.emptyList()).stream()
.mapToInt(r -> targetSet.contains(parseSafe(r.getClassId()))
? classSize.getOrDefault(parseSafe(r.getClassId()), 0) : 0).sum();
// 已读人数 = 接收班级内的已读记录条数(学生粒度),与列表/详情同口径
int readCount = (int) readMap.getOrDefault(a.getId(), Collections.emptyList()).stream()
.filter(r -> targetSet.contains(parseSafe(r.getClassId())))
.count();
fillProgressItem(item, computeProgress(a, records, allowCount), readCount, allowCount);
myItems.add(item);
}
@@ -1151,21 +1172,26 @@ public class ActivityService implements IActivityService {
if (pc.myClassId == null || candidates.isEmpty()) {
return Collections.emptySet();
}
Integer myStudentId = studentIdOf(pc);
List<Integer> ids = candidates.stream().map(Activity::getId).collect(Collectors.toList());
// 已读是班级粒度(fa_activity_read 唯一键为 activity+class,studentId 只是首读学生),
// 不能按学生过滤,否则同班其他学生永远「未读」
// 已读按学生粒度:本人(学生身份)打开过才算已读;studentId 缺失的存量数据退化为班级粒度
return activityReadRepository
.findByActivityIdIn(ids).stream()
.filter(r -> pc.myClassId.equals(r.getClassId()))
.filter(r -> myStudentId == null || myStudentId.equals(r.getStudentId()))
.map(ActivityRead::getActivityId)
.collect(Collectors.toSet());
}
/** 标读(班级粒度,幂等) */
/** 标读(学生粒度,幂等:同一家长重复打开只记一条;学生档案不可得时退化为班级粒度) */
private void markRead(Integer activityId, String classId, Integer studentId) {
if (studentId == null) {
if (activityReadRepository.findByActivityIdAndClassId(activityId, classId).isPresent()) {
return;
}
} else if (activityReadRepository.existsByActivityIdAndClassIdAndStudentId(activityId, classId, studentId)) {
return;
}
ActivityRead read = new ActivityRead();
read.setActivityId(activityId);
read.setClassId(classId);
@@ -1175,18 +1201,15 @@ public class ActivityService implements IActivityService {
activityReadRepository.save(read);
}
private ActivityRead findRead(Integer activityId, String classId) {
if (classId == null) {
return null;
}
return activityReadRepository.findByActivityIdAndClassId(activityId, classId).orElse(null);
}
/** confirm 模式:家长打开活动即自动确认(未回执 → 已确认) */
/** confirm 模式:家长打开活动即自动确认(未回执 → 已确认)。
* 仅报名截止时间内生效(活动处于「报名中」态):超过截止时间(转「已截止/已结束」)后打开只标已读,不再自动确认。 */
private void autoConfirmIfNeeded(Activity activity, Integer studentId) {
if (!ReceiptModeEnum.CONFIRM.getCode().equalsIgnoreCase(activity.getReceiptMode())) {
return;
}
if (deriveStatus(activity, LocalDateTime.now()) != ActivityStatusEnum.SIGNUP) {
return;
}
activitySignupRepository.findByActivityIdAndStudentId(activity.getId(), studentId)
.ifPresent(record -> {
if (SignupStatusEnum.NONE.getCode().equalsIgnoreCase(record.getStatus())) {
@@ -1272,7 +1295,8 @@ public class ActivityService implements IActivityService {
boolean full = quota > 0 && usedQuota >= quota;
double rate = should > 0 ? Math.round((signed + confirmed) * 1000.0 / should) / 10.0 : 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) {
@@ -1466,10 +1490,10 @@ public class ActivityService implements IActivityService {
List<Integer> targetIds = targetClassIds(a, classes);
Set<Integer> targetSet = new LinkedHashSet<>(targetIds);
int allowCount = targetIds.stream().mapToInt(t -> classSize.getOrDefault(t, 0)).sum();
// 已读只按目标班级累计(收窄接收班级后的残留已读行不计入)
int readCount = readMap.getOrDefault(a.getId(), Collections.emptyList()).stream()
.mapToInt(r -> targetSet.contains(parseSafe(r.getClassId()))
? classSize.getOrDefault(parseSafe(r.getClassId()), 0) : 0).sum();
// 已读人数 = 接收班级内的已读记录条数(学生粒度,一人一条;收窄接收班级后的出界记录不计入)
int readCount = (int) readMap.getOrDefault(a.getId(), Collections.emptyList()).stream()
.filter(r -> targetSet.contains(parseSafe(r.getClassId())))
.count();
vo.setAllowCount(allowCount);
vo.setReadCount(readCount);
// 管理端列表不填已读标志:fa_activity_read 是班级粒度且 studentId 只记首读学生,
@@ -1578,9 +1602,10 @@ public class ActivityService implements IActivityService {
List<Integer> targetIds = targetClassIds(a, classes);
Map<Integer, List<SchStudentDto>> byClass = studentsByClass(schId, targetIds);
int allowCount = byClass.values().stream().mapToInt(List::size).sum();
int readCount = activityReadRepository.findByActivityId(a.getId()).stream()
.mapToInt(r -> byClass.getOrDefault(parseSafe(r.getClassId()), Collections.emptyList()).size())
.sum();
// 已读人数 = 接收班级内的已读记录条数(学生粒度),与列表 readCount 同口径
int readCount = (int) activityReadRepository.findByActivityId(a.getId()).stream()
.filter(r -> byClass.containsKey(parseSafe(r.getClassId())))
.count();
vo.setNumcount(allowCount);
vo.setReadnumcount(readCount);
if (a.isPublished() && !ReceiptModeEnum.NONE.getCode().equalsIgnoreCase(a.getReceiptMode())) {
@@ -1591,7 +1616,7 @@ public class ActivityService implements IActivityService {
return vo;
}
private SignupBrief toBrief(ActivitySignup r) {
private SignupBrief toBrief(ActivitySignup r, Set<String> readKeys) {
SignupBrief brief = new SignupBrief();
brief.setSignupId(r.getId());
brief.setStudentId(r.getStudentId());
@@ -1605,10 +1630,16 @@ public class ActivityService implements IActivityService {
brief.setSignedAt(fmt(r.getSignedAt()));
boolean receipt = !SignupStatusEnum.NONE.getCode().equalsIgnoreCase(r.getStatus());
brief.setHasReceipt(receipt);
brief.setRead(readKeys.contains(readKey(r.getClassId(), r.getStudentId())));
brief.setRemark(r.getRemark());
return brief;
}
/** 学生粒度已读键:classId:studentId(studentId 为 null 的存量数据按 classId:null 匹配,互不误标) */
private String readKey(String classId, Integer studentId) {
return classId + ":" + studentId;
}
/** 是否占用名额(已报名/已确认) */
private boolean isOccupying(ActivitySignup r) {
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.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();
}
/**
@@ -1,8 +1,8 @@
package com.yunhai.familyactivity.service.impl;
import com.yunhai.familyactivity.entity.UpFile;
import com.yunhai.familyactivity.model.UpFileVo;
import com.yunhai.familyactivity.model.UpFileResultVo;
import com.yunhai.familyactivity.model.UpFileVo;
import com.yunhai.familyactivity.repository.UpFileRepository;
import com.yunhai.familyactivity.service.IFileService;
import com.yunhai.platform.apistd.BizException;
@@ -18,7 +18,6 @@ import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileInputStream;
import java.io.OutputStream;
import java.net.URLConnection;
@@ -26,15 +25,7 @@ import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.ArrayList;
import java.util.*;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -53,11 +44,9 @@ public class FileService implements IFileService {
@Autowired
private UpFileRepository upFileRepository;
@Value("${file.upload-path}")
@Value("${FILE_PATH}")
private String uploadPath;
private static final String DEFAULT_APP_TYPE = "activity";
// ============================ 上传 ============================
@Override
@@ -77,13 +66,10 @@ public class FileService implements IFileService {
ext = originalName.substring(dotIdx + 1).toLowerCase();
}
// 构造存储子路径:{appType}/{yyyyMMdd}/{uuid}.{ext}
String dateDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String savedName = UUID.randomUUID().toString().replace("-", "") + (ext.isEmpty() ? "" : "." + ext);
String relativePath = (appType == null ? DEFAULT_APP_TYPE : appType) + "/" + dateDir + "/" + savedName;
// 物理写入
Path fullPath = Paths.get(uploadPath, relativePath);
Path fullPath = Paths.get(uploadPath, savedName);
try {
Files.createDirectories(fullPath.getParent());
file.transferTo(fullPath.toFile());
@@ -94,10 +80,10 @@ public class FileService implements IFileService {
// 入库
UpFile entity = new UpFile();
entity.setTopicType(appType == null ? DEFAULT_APP_TYPE : appType);
entity.setTopicType(appType);
entity.setTopicId(topicId);
entity.setFileTrueName(originalName);
entity.setFileName(relativePath);
entity.setFileName(savedName);
entity.setFileSize((int) file.getSize());
entity.setFileType(ext);
upFileRepository.save(entity);
@@ -125,8 +111,7 @@ public class FileService implements IFileService {
@Override
public List<UpFileVo> listByTopic(String appType, Integer topicId) {
String type = appType == null ? DEFAULT_APP_TYPE : appType;
return upFileRepository.findByTopicTypeAndTopicId(type, topicId).stream()
return upFileRepository.findByTopicTypeAndTopicId(appType, topicId).stream()
.map(this::toVo)
.collect(Collectors.toList());
}
@@ -279,8 +264,7 @@ public class FileService implements IFileService {
@Override
public void downloadZipByTopic(String topicType, Integer topicId, String zipName,
HttpServletResponse response) {
String type = topicType == null ? DEFAULT_APP_TYPE : topicType;
List<UpFile> files = upFileRepository.findByTopicTypeAndTopicId(type, topicId);
List<UpFile> files = upFileRepository.findByTopicTypeAndTopicId(topicType, topicId);
downloadZip(files, zipName, response);
}
@@ -293,9 +277,8 @@ public class FileService implements IFileService {
if (files == null || files.length == 0) {
return results;
}
String type = appType != null ? appType : DEFAULT_APP_TYPE;
for (MultipartFile file : files) {
results.add(upload(file, type, null));
results.add(upload(file, appType, null));
}
return results;
}
@@ -4,7 +4,7 @@ import com.yunhai.familyactivity.model.SchTeaList;
import com.yunhai.familyactivity.service.IClassService;
import com.yunhai.familyactivity.service.IUserCenterService;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaModule;
@@ -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();
}
@@ -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`;