通知
build / build-push (push) Failing after 1m13s

This commit is contained in:
wanghu
2026-09-10 15:40:38 +08:00
parent 3016a97f1b
commit e20ba5f38b
25 changed files with 955 additions and 0 deletions
+124
View File
@@ -0,0 +1,124 @@
#!/bin/bash
# CampusNotice 接口 curl 测试脚本 v2
# 用法: ./api-curl-test.sh [BASE] [PLATFORM]
# BASE 默认 http://localhost:8101
# PLATFORM 默认 manage(CAS 仅拦 /api/* 与 /mobiapi/*,manage 为匿名通道;生产环境应封死,见安全提醒)
# 注意:中文 JSON 体必须走 UTF-8 文件(--data-binary @file),
# 直接 -d '中文' 会被 Git Bash→curl.exe 的 argv 转换变成 GBK,服务端报
# Invalid UTF-8 start byte(HttpMessageNotReadable → code=6),属测试工具问题。
BASE="${1:-http://localhost:8101}"
PF="${2:-manage}"
U="$BASE/$PF"
TMPB="$(dirname "$0")/.curl-body.json"
PASS=0; BIZ=0; FAIL=0
CREATED_IDS=()
req() { # req 名称 方法 路径 [json体]
local name="$1" method="$2" path="$3" body="$4"
local args=(-s --max-time 15 -w "\n%{http_code}" -H "Accept: application/json")
if [ -n "$body" ]; then
printf '%s' "$body" > "$TMPB" # UTF-8 原样字节落盘
args+=(-X POST -H "Content-Type: application/json; charset=utf-8" --data-binary "@$(cygpath -w "$TMPB" 2>/dev/null || echo "$TMPB")")
elif [ "$method" = "POST" ]; then
args+=(-X POST)
fi
local out; out=$(curl "${args[@]}" "$U$path" 2>/dev/null)
local http="${out##*$'\n'}"; local json="${out%$'\n'*}"
local parsed; parsed=$(node -e "
try{const j=JSON.parse(process.argv[1]);
const d=j.data;
const ds=d===null||d===undefined?'null':Array.isArray(d)?'array['+d.length+']':typeof d==='object'?'{'+Object.keys(d).slice(0,4).join(',')+'}':String(d).slice(0,30);
console.log([j.code,(j.msg||'').slice(0,46),ds].join('\u0001'));
}catch(e){console.log(['-','-','-'].join('\u0001'))}" "$json" 2>/dev/null)
local code msg data
code=$(echo "$parsed" | cut -d$'\001' -f1)
msg=$(echo "$parsed" | cut -d$'\001' -f2)
data=$(echo "$parsed" | cut -d$'\001' -f3)
local st
if [ "$code" = "0" ]; then PASS=$((PASS+1)); st="✅PASS";
elif [ "$code" = "-" ] || [ "$http" != "200" ]; then FAIL=$((FAIL+1)); st="❌FAIL";
else BIZ=$((BIZ+1)); st="🟡BIZ"; fi
printf "%-26s | HTTP %s | code=%-3s | msg=%-46s | data=%s\n" "$name" "$http" "$code" "$msg" "$data"
}
extract_id() { # 从最近一次输出无法跨函数取值,改为调用方直接用 node 从 curl 结果提取
node -e "try{console.log(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).data ?? '')}catch(e){console.log('')}" "$1" 2>/dev/null
}
echo "================ CampusNotice curl 矩阵 (BASE=$BASE PF=$PF) ================"
echo "---- A. 静态(直连 BASE,不经 platform 前缀) ----"
for p in "/" "/api-test.html"; do
http=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$BASE$p")
if [ "$http" = "200" ]; then PASS=$((PASS+1)); printf "%-26s | HTTP %s\n" "静态 $p" "$http"; else FAIL=$((FAIL+1)); printf "%-26s | HTTP %s ❌\n" "静态 $p" "$http"; fi
done
http=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$BASE/api/notice/Statis")
if [ "$http" = "302" ]; then PASS=$((PASS+1)); printf "%-26s | HTTP 302 → CAS 登录墙(符合预期)\n" "/api 登录墙对照"; else FAIL=$((FAIL+1)); printf "%-26s | HTTP $http ❌(预期302)\n" "/api 登录墙对照"; fi
echo "---- B. 平台/基础(匿名语义) ----"
req "GetUserInfo" GET "/base/GetUserInfo"
req "GetUserMod(匿名预期拒)" GET "/base/GetUserMod"
req "GetSchoolAll" GET "/base/GetSchoolAll"
req "GetGroupUser(未接入桩)" GET "/base/GetGroupUser?gid=1&role=2"
echo "---- C. 通知查询 ----"
req "notice/List" GET "/notice/List?pageIndex=1&pageSize=3"
req "notice/SendList" GET "/notice/SendList?pageIndex=1&pageSize=3&status=1&scope=user"
req "notice/Statis" GET "/notice/Statis"
req "notice/GetNextPrev" GET "/notice/GetNextPrev?serid=1&type=0"
req "notice/DraftList" GET "/notice/DraftList?pageIndex=1&pageSize=3"
req "notice/ApproverSuggest" GET "/notice/ApproverSuggest"
req "ApprovalList(pending)" GET "/notice/ApprovalList?auditState=pending&pageIndex=1&pageSize=3"
req "ApprovalList(reviewed)" GET "/notice/ApprovalList?auditState=reviewed&pageIndex=1&pageSize=3"
echo "---- D. 推送范围(匿名预期拒) ----"
req "notice/ScopeTree" GET "/notice/ScopeTree"
req "notice/ScopePreview" GET "/notice/ScopePreview?scopes=%5B%7B%22scopeType%22%3A1%2C%22scopeId%22%3A0%2C%22scopeName%22%3A%22%E5%85%A8%E6%A0%A1%22%7D%5D"
echo "---- E. 写链路(UTF-8 文件体,测后自清理) ----"
printf '%s' '{"title":"【测试】curl直发","content":"<p>curl</p>","type":1,"deptId":"1","deptName":"校长室","topmost":0,"receiveList":"ALL","fileIds":"","timingSendTime":null,"smsFlag":0,"receiptFlag":1,"publishType":"direct"}' > "$TMPB"
resp=$(curl -s --max-time 15 -X POST -H "Content-Type: application/json; charset=utf-8" --data-binary "@$(cygpath -w "$TMPB" 2>/dev/null || echo "$TMPB")" "$U/notice/Create")
echo "$resp" > "$TMPB.out"
ID_DIRECT=$(extract_id "$TMPB.out")
if [ -n "$ID_DIRECT" ]; then PASS=$((PASS+1)); printf "%-26s | code=0 | id=%s ✅\n" "Create(direct)" "$ID_DIRECT"; else FAIL=$((FAIL+1)); printf "%-26s | ❌ 未返回id\n" "Create(direct)"; fi
CREATED_IDS+=("$ID_DIRECT")
printf '%s' '{"title":"【测试】curl草稿","content":"<p>curl</p>","type":1,"deptId":"1","deptName":"校长室","topmost":0,"receiveList":"ALL","fileIds":"","timingSendTime":null,"smsFlag":0,"receiptFlag":1}' > "$TMPB"
resp=$(curl -s --max-time 15 -X POST -H "Content-Type: application/json; charset=utf-8" --data-binary "@$(cygpath -w "$TMPB" 2>/dev/null || echo "$TMPB")" "$U/notice/SaveDraft")
echo "$resp" > "$TMPB.out"
ID_DRAFT=$(extract_id "$TMPB.out")
if [ -n "$ID_DRAFT" ]; then PASS=$((PASS+1)); printf "%-26s | code=0 | id=%s ✅\n" "SaveDraft" "$ID_DRAFT"; else FAIL=$((FAIL+1)); printf "%-26s | ❌ 未返回id\n" "SaveDraft"; fi
CREATED_IDS+=("$ID_DRAFT")
if [ -n "$ID_DRAFT" ]; then
printf '%s' "{\"serid\":$ID_DRAFT,\"approverAccounts\":[\"lijing\"],\"opinion\":\"请审批\"}" > "$TMPB"
resp=$(curl -s --max-time 15 -X POST -H "Content-Type: application/json; charset=utf-8" --data-binary "@$(cygpath -w "$TMPB" 2>/dev/null || echo "$TMPB")" "$U/notice/Submit")
code=$(node -e "try{console.log(JSON.parse(process.argv[1]).code)}catch(e){console.log('-')}" "$resp")
if [ "$code" = "0" ]; then PASS=$((PASS+1)); printf "%-26s | code=0 | 草稿→待审批 ✅\n" "Submit"; else BIZ=$((BIZ+1)); printf "%-26s | code=%s 🟡\n" "Submit" "$code"; fi
fi
req "Approve(匿名预期拒)" POST "/notice/Approve?serid=${ID_DRAFT:-0}&opinion=x"
req "Reject(匿名预期拒)" POST "/notice/Reject?serid=${ID_DRAFT:-0}&opinion=x"
echo "---- F. 其他模块 ----"
req "dict/ManageList" GET "/dict/ManageList"
req "template/Typelist" GET "/template/Typelist"
req "contacts/GetGroup" GET "/contacts/GetGroup"
req "sms/GetSchool" GET "/sms/GetSchool"
echo "---- G. 附件 ----"
f="$(dirname "$0")/.curl-upload-sample.txt"; echo "curl upload sample $(date +%s)" > "$f"
req "base/FileUpload" POST "/base/FileUpload" "" "file=@$(cygpath -w "$f" 2>/dev/null || echo "$f")"
rm -f "$f"
echo "---- H. 清理测试数据 ----"
if [ "${#CREATED_IDS[@]}" -gt 0 ]; then
ids=$(IFS=,; echo "${CREATED_IDS[*]}")
printf '%s' "[$ids]" > "$TMPB"
resp=$(curl -s --max-time 15 -X POST -H "Content-Type: application/json; charset=utf-8" --data-binary "@$(cygpath -w "$TMPB" 2>/dev/null || echo "$TMPB")" "$U/notice/Delete")
code=$(node -e "try{console.log(JSON.parse(process.argv[1]).code)}catch(e){console.log('-')}" "$resp")
if [ "$code" = "0" ]; then PASS=$((PASS+1)); printf "%-26s | code=0 | ids=[$ids] 已清理 ✅\n" "Delete清理"; else FAIL=$((FAIL+1)); printf "%-26s | code=%s ❌\n" "Delete清理" "$code"; fi
fi
rm -f "$TMPB" "$TMPB.out"
echo "================ 统计: PASS=$PASS BIZ(业务拒绝/环境预期)=$BIZ FAIL=$FAIL ================"
echo "提醒: /manage 匿名可访问全部业务接口——CAS 仅拦 /api/* 与 /mobiapi/*,生产环境务必封死(安全项)。"
+30
View File
@@ -0,0 +1,30 @@
-- =====================================================================
-- 修复:campus_notice 库字符集 latin1 → utf8mb4(2026-09-09 接口检测发现)
-- 现象:所有含中文的写入(notice Create/SaveDraft/Forward/Edit、审批人姓名、
-- 审批意见等)报 Incorrect string value → 全局兜底为「内部服务错误」。
-- 根因:建库未指定字符集(latin1_swedish_ci),Hibernate ddl-auto=update
-- 建表继承库默认,全部 14 张表均为 latin1。
-- 修复:在线转换(存量数据仅 ASCII,转换无损);应用无需重启。
-- DBA 建议:生产建库统一 utf8mb4_general_ci;新表继承库默认后即为 utf8mb4。
-- =====================================================================
ALTER DATABASE campus_notice CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci;
ALTER TABLE campus_notice.notice CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE campus_notice.notice_read CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE campus_notice.notice_receiver CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE campus_notice.notice_push_scope CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE campus_notice.notice_approval CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE campus_notice.notice_approver_memory CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE campus_notice.sys_dict CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE campus_notice.template_type CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE campus_notice.notice_template CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE campus_notice.up_file CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE campus_notice.contacts_group CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE campus_notice.contacts_group_user CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE campus_notice.sms_module CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE campus_notice.sms_school_config CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
-- 核查(预期全部 utf8mb4):
-- SELECT table_name, table_collation FROM information_schema.TABLES
-- WHERE table_schema='campus_notice' ORDER BY table_name;
@@ -0,0 +1,59 @@
package com.yunhai.campusnotice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 通知审批记录表 notice_approval(改造方案 2.4)。
* <p>
* 审批轨迹全量落本表(提交行含审批人快照,通过/驳回行记录实际处理人),
* 驳回重提产生新一轮提交行,历史轮次永久保留(详情页按时间线分组展示)。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "notice_approval",
indexes = @Index(name = "idx_approval_notice", columnList = "notice_id"))
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class NoticeApproval extends BaseEntity {
/** 审批动作:1 提交审批 / 2 通过 / 3 驳回(见 ApprovalActionEnum) */
public static final int ACTION_SUBMIT = 1;
public static final int ACTION_APPROVE = 2;
public static final int ACTION_REJECT = 3;
/** 通知ID(关联 notice.id) */
@Column(name = "notice_id", nullable = false)
private Integer noticeId;
/** 审批动作:1 提交审批 / 2 通过 / 3 驳回 */
@Column(name = "action", nullable = false)
private Integer action;
/** 操作人账号 */
@Column(name = "operator_account", length = 64)
private String operatorAccount;
/** 操作人姓名 */
@Column(name = "operator_name", length = 64)
private String operatorName;
/** 审批意见 */
@Column(name = "opinion", length = 1000)
private String opinion;
/** 仅 action=1 提交行使用:选定审批人快照 JSON [{account,name}] */
@Column(name = "assignees", columnDefinition = "LONGTEXT")
private String assignees;
/** 操作时间 */
@Column(name = "action_time")
private LocalDateTime actionTime;
}
@@ -0,0 +1,44 @@
package com.yunhai.campusnotice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 审批人记忆表 notice_approver_memory(改造方案 2.5)。
* <p>
* 记住发布人最近一次提交所选的审批人组合,表单再次进入审批流程时
* 经 ApproverSuggest 默认回显,免去重复选人。记忆仅是默认值:
* Submit 时后端仍逐个校验被选人资格,防止记忆中的人被撤销角色后带病提交。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "notice_approver_memory",
uniqueConstraints = @UniqueConstraint(
name = "uk_approver_memory_sch_publisher",
columnNames = {"sch_id", "publisher_account"}))
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class NoticeApproverMemory extends BaseEntity {
/** 学校ID(多租户隔离) */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 发布人账号(按人记忆) */
@Column(name = "publisher_account", nullable = false, length = 64)
private String publisherAccount;
/** 最近一次所选审批人账号 JSON ["a","b"] */
@Column(name = "approver_accounts", columnDefinition = "LONGTEXT")
private String approverAccounts;
/** 对应姓名快照 JSON(回显免二次查询) */
@Column(name = "approver_names", columnDefinition = "LONGTEXT")
private String approverNames;
}
@@ -0,0 +1,41 @@
package com.yunhai.campusnotice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
/**
* 通知推送范围表 notice_push_scope(改造方案 2.2)。
* <p>
* 范围选择本质是圈定一组学校组织节点,随通知快照保存(防止后续组织调整影响历史通知):
* 判定用 scopeId,展示用 scopeName。推送范围不在主表冗余 JSON 字段,统一以本表为准。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "notice_push_scope",
indexes = @Index(name = "idx_push_scope_notice", columnList = "notice_id"))
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class NoticePushScope extends BaseEntity {
/** 通知ID(关联 notice.id) */
@Column(name = "notice_id", nullable = false)
private Integer noticeId;
/** 范围类型:1 全校 / 2 学部 / 3 年级 / 4 班级 / 5 训练队 / 6 指定学员(见 PushScopeTypeEnum) */
@Column(name = "scope_type", nullable = false)
private Integer scopeType;
/** 对应组织节点ID:学部=学制ID、年级=gradeId、班级=class_id、学员=stuId、全校= schId(仅语义占位) */
@Column(name = "scope_id")
private Integer scopeId;
/** 快照名称(如"初二年级"),组织改名不影响历史通知展示 */
@Column(name = "scope_name", length = 128)
private String scopeName;
}
@@ -0,0 +1,34 @@
package com.yunhai.campusnotice.enums;
import lombok.Getter;
/**
* 通知审批动作(notice_approval.action)。
*/
@Getter
public enum ApprovalActionEnum {
SUBMIT(1, "提交审批"),
APPROVE(2, "审批通过"),
REJECT(3, "审批驳回");
private final int code;
private final String desc;
ApprovalActionEnum(int code, String desc) {
this.code = code;
this.desc = desc;
}
public static ApprovalActionEnum of(Integer code) {
if (code == null) {
return null;
}
for (ApprovalActionEnum e : values()) {
if (e.code == code) {
return e;
}
}
return null;
}
}
@@ -0,0 +1,39 @@
package com.yunhai.campusnotice.enums;
import lombok.Getter;
/**
* 推送范围节点类型(notice_push_scope.scope_type)。
* scopeId 对应 bschclass 组织节点:学段=学制ID、年级=gradeId、班级=class_id;
* 训练队数据源待确认(改造方案 5.1-#3),暂不支持圈人展开。
*/
@Getter
public enum PushScopeTypeEnum {
SCHOOL(1, "全校"),
SUB_SCHOOL(2, "学部"),
GRADE(3, "年级"),
CLAZZ(4, "班级"),
TEAM(5, "训练队"),
STUDENT(6, "指定学员");
private final int code;
private final String desc;
PushScopeTypeEnum(int code, String desc) {
this.code = code;
this.desc = desc;
}
public static PushScopeTypeEnum of(Integer code) {
if (code == null) {
return null;
}
for (PushScopeTypeEnum e : values()) {
if (e.code == code) {
return e;
}
}
return null;
}
}
@@ -0,0 +1,38 @@
package com.yunhai.campusnotice.job;
import com.yunhai.campusnotice.service.INoticeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 定时发送调度任务(改造方案 4.2)。
* <p>每分钟扫描 status=1 且 timingSendTime<=now 且 timing_published=0 的通知,
* 逐条 CAS 抢占(timing_published 0→1)后执行真实发布(圈人/落学员快照/发短信/统计口径对齐)。
* 幂等由 CAS 保证:重复扫描同一通知只触发一次真实发布。</p>
* <p>注意:当前按单实例部署实现(无分布式锁);若后续多实例部署,
* 需引入分布式锁(DB 行锁/Redis)或保留 CAS 抢占语义由数据库串行化。</p>
*/
@Component
public class TimingPublishJob {
private static final Logger log = LoggerFactory.getLogger(TimingPublishJob.class);
@Autowired
private INoticeService noticeService;
@Scheduled(fixedDelay = 60_000, initialDelay = 30_000)
public void publishDue() {
try {
int count = noticeService.timingPublishDue();
if (count > 0) {
log.info("[TimingPublishJob] 本轮定时发布 {} 条通知", count);
}
} catch (Exception e) {
// 单条失败不影响下一轮扫描(事务内单条异常会整体回滚该条,由日志暴露)
log.error("[TimingPublishJob] 定时发布调度异常", e);
}
}
}
@@ -0,0 +1,29 @@
package com.yunhai.campusnotice.model.bsch;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* bschclass 班级 DTO(/oapi/VSchClass/GetVClassList,SchClass 视图)。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class SchClassDto {
/** 班级业务ID(sch_class.class_id,scopeType=班级 的 scopeId;学生 ClassID 指向它) */
@JsonProperty("ClassID")
private Integer classId;
/** 班级名称 */
@JsonProperty("ClassName")
private String className;
/** 年级ID(= gradeId) */
@JsonProperty("Levell")
private Integer gradeId;
/** 排序号 */
@JsonProperty("sx")
private Integer sort;
}
@@ -0,0 +1,25 @@
package com.yunhai.campusnotice.model.bsch;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* bschclass 年级 DTO(/oapi/Schoolen/GetSchLenLevellist,BaseSchsystemGrade)。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class SchGradeDto {
/** 年级ID(=gradeId,scopeType=年级 的 scopeId;班级 Levell 指向它) */
@JsonProperty("Id")
private Integer id;
/** 年级名称(gradeNickname,如「初一」) */
@JsonProperty("Name")
private String name;
/** 所属学段(学制)ID */
@JsonProperty("OwnerId")
private Integer ownerId;
}
@@ -0,0 +1,25 @@
package com.yunhai.campusnotice.model.bsch;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* bschclass 学校信息 DTO(/oapi/SchInfo/GetUserSchFirst)。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class SchInfoDto {
/** 学校 GUID(bschclass 侧 SchSerId) */
@JsonProperty("SchSerId")
private String schSerId;
/** 用户中心学校组ID(与 CampusNotice schId 同域) */
@JsonProperty("UserSchSerId")
private Integer userSchSerId;
/** 学校名称 */
@JsonProperty("SchName")
private String schName;
}
@@ -0,0 +1,34 @@
package com.yunhai.campusnotice.model.bsch;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* bschclass 学生 DTO(/oapi/OAStu/*,SchStudent 视图)。
* 登录账号口径:学员端账号 = SchStudent.account(改造方案 5.1-#1)。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class SchStudentDto {
/** 学员登录账号 */
@JsonProperty("Account")
private String account;
/** 学生ID */
@JsonProperty("UserStuSerID")
private Integer stuId;
/** 学生姓名 */
@JsonProperty("StuName")
private String stuName;
/** 所在班级ID(sch_class.class_id) */
@JsonProperty("ClassID")
private Integer classId;
/** 所在组ID */
@JsonProperty("GroupID")
private Integer groupId;
}
@@ -0,0 +1,25 @@
package com.yunhai.campusnotice.model.bsch;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* bschclass 学段(学制)DTO(/oapi/Schoolen/GetSchLenlist,BaseSchsystem)。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class SchSystemDto {
/** 学制ID(scopeType=学部 的 scopeId) */
@JsonProperty("Id")
private Integer id;
/** 学段名称(如「小学部」「初中部」) */
@JsonProperty("Name")
private String name;
/** 学制类型 */
@JsonProperty("Type")
private Integer type;
}
@@ -0,0 +1,41 @@
package com.yunhai.campusnotice.model.bsch;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
/**
* bschclass 组织树节点 DTO(/oapi/SchGroup/GetTeaTree、GetStuTree,TeaTreeVO 视图)。
* type=1 组节点(学校/学段/年级/班级、教师组),type=2 人员叶子(学生/教师)。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class SchTreeNodeDto {
/** 节点ID(学校/学段/年级/班级为对应业务ID,叶子为学生 stuId 或教师 teaId) */
private Integer id;
/** 节点名称 */
private String name;
/** 父节点ID(根节点为 0) */
private Integer pid;
/** 排序号(组取 sort,叶子固定 0) */
private Integer no;
/** 1=组节点,2=人员叶子 */
private Integer type;
/** 是否父节点(组 true,叶子 false) */
@JsonProperty("isParent")
private Boolean isParent;
/** 头像(当前固定空串) */
private String photo;
/** 子节点(叶子为 null,组节点非 null) */
private List<SchTreeNodeDto> children;
}
@@ -0,0 +1,78 @@
package com.yunhai.campusnotice.model.notice;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 审批列表行 VO(notice/ApprovalList,待审/已审合一)。
* <p>已审行携带最新处理信息:或签下含「他人已处理」(handledByMe=false),
* 实际处理人/动作/时间/意见取 notice_approval 最新通过/驳回行。
*/
@Data
public class ApprovalListVo {
private Integer id;
private String title;
/** 通知类型:1 普通 / 2 重要 */
private Integer type;
private String typeDesc;
/** 状态:2 待审批 / 1 已发布 / 3 已驳回 */
private Integer status;
private String statusDesc;
/** 发布人账号 */
private String publisherAccount;
/** 发布人姓名 */
private String publisherName;
/** 本轮审批人姓名(来自提交行 assignees 快照,列表页免查用户中心) */
private List<String> approverNames = new ArrayList<>();
/** 最新一轮提交时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime submitTime;
/** 是否需要学员阅读回执 */
private Integer receiptFlag;
/** 定时发送时间(审批通过后到点真实发布) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime timingSendTime;
// ==================== 已审 Tab 字段(待审 Tab 为空) ====================
/** 是否已有处理结果(已审 Tab 恒 true,冗余字段便于前端判断) */
private boolean handled;
/** 是否由当前登录人处理(false=或签下他人已处理) */
private boolean handledByMe;
/** 实际处理人账号 */
private String handledAccount;
/** 实际处理人姓名 */
private String handledName;
/** 实际处理动作:2 通过 / 3 驳回 */
private Integer handledAction;
/** 实际处理动作描述 */
private String handledActionDesc;
/** 实际处理时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime handledTime;
/** 处理意见(长文本建议进详情查看) */
private String handledOpinion;
}
@@ -0,0 +1,46 @@
package com.yunhai.campusnotice.model.notice;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 审批轨迹记录 VO(详情页全量时间线,按轮次分组由前端按提交行切分)。
*/
@Data
public class ApprovalRecordVo {
/** 审批动作:1 提交审批 / 2 通过 / 3 驳回 */
private Integer action;
/** 动作描述 */
private String actionDesc;
/** 操作人账号 */
private String operatorAccount;
/** 操作人姓名 */
private String operatorName;
/** 审批意见(提交行=提交说明) */
private String opinion;
/** 仅提交行:该轮审批人快照 */
private List<Assignee> assignees = new ArrayList<>();
/** 操作时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime actionTime;
/**
* 审批人快照项。
*/
@Data
public static class Assignee {
private String account;
private String name;
}
}
@@ -0,0 +1,22 @@
package com.yunhai.campusnotice.model.notice;
import lombok.Data;
/**
* 审批人解析项:教师树节点ID(用户中心用户ID)与登录账号互转。
* <p>教师树(base/GetSchoolAll)叶子仅携带教师用户ID与姓名,审批快照按登录账号存储;
* 前端选人后经 ApproverResolve 换算账号提交,编辑回显时按账号换回节点ID勾树。
* 按账号解析失败时 id 为 null,按ID解析失败时 account 为 null(调用方据此提示)。</p>
*/
@Data
public class ApproverResolveVo {
/** 教师用户ID(教师树叶子节点 id) */
private Integer id;
/** 登录账号 */
private String account;
/** 姓名 */
private String name;
}
@@ -0,0 +1,20 @@
package com.yunhai.campusnotice.model.notice;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 审批人记忆回显 VO(notice/ApproverSuggest)。
* 无记忆时返回空列表,前端走通用选人。
*/
@Data
public class ApproverSuggestVo {
/** 审批人账号列表(与 names 下标对齐) */
private List<String> accounts = new ArrayList<>();
/** 审批人姓名快照列表 */
private List<String> names = new ArrayList<>();
}
@@ -0,0 +1,20 @@
package com.yunhai.campusnotice.model.notice;
import lombok.Data;
/**
* 推送范围项(发布/编辑入参与详情回显)。
* scopeType 见 {@link com.yunhai.campusnotice.enums.PushScopeTypeEnum}。
*/
@Data
public class PushScopeItem {
/** 范围类型:1 全校 / 2 学部 / 3 年级 / 4 班级 / 5 训练队 / 6 指定学员 */
private Integer scopeType;
/** 组织节点ID:学部=学制ID、年级=gradeId、班级=class_id、学员=stuId */
private Integer scopeId;
/** 节点名称快照 */
private String scopeName;
}
@@ -0,0 +1,66 @@
package com.yunhai.campusnotice.model.notice;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 已发送通知阅读回执 VO(notice/ReadReceipt)。
* 按身份快照分组:年级 → 班级 → 学生(前端已读/未读着色),教师接收行归「教师」组。
* 数据源=notice_receiver 学员快照 + notice_read 懒标记,不实时调用 bschclass。
*/
@Data
public class ReadReceiptVo {
/** 通知ID */
private Integer serid;
/** 应读人数(notice.allow_count) */
private Integer allowCount;
/** 已读人数(notice_read 实时统计) */
private Long readCount;
/** 年级分组 */
private List<GradeGroup> grades = new ArrayList<>();
/** 教师接收行(无年级/班级维度) */
private List<ReaderItem> teachers = new ArrayList<>();
/**
* 年级分组。
*/
@Data
public static class GradeGroup {
private Integer gradeId;
private String gradeName;
private List<ClassGroup> classes = new ArrayList<>();
}
/**
* 班级分组。
*/
@Data
public static class ClassGroup {
private Integer classId;
private String className;
private List<ReaderItem> students = new ArrayList<>();
}
/**
* 学生/教师阅读项。
*/
@Data
public static class ReaderItem {
private String account;
private String name;
/** 是否已读 */
private boolean read;
/** 阅读时间(未读为 null) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime readTime;
}
}
@@ -0,0 +1,19 @@
package com.yunhai.campusnotice.model.notice;
import lombok.Data;
/**
* 推送范围预估 VO(notice/ScopePreview)。
*/
@Data
public class ScopePreviewVo {
/** 范围展开学员数(按账号去重) */
private int studentCount;
/** 教师定向名单数(receiveList 解析) */
private int teacherCount;
/** 合计预估接收人数 */
private int totalCount;
}
@@ -0,0 +1,22 @@
package com.yunhai.campusnotice.model.notice;
import lombok.Data;
import java.util.List;
/**
* 提交审批入参(notice/Submit)。
* 审批人可多选,或签语义:任一审批人通过即发布、任一驳回即驳回。
*/
@Data
public class SubmitRequest {
/** 通知ID */
private Integer serid;
/** 选定审批人账号列表(至少一人) */
private List<String> approverAccounts;
/** 提交说明(可选) */
private String opinion;
}
@@ -0,0 +1,32 @@
package com.yunhai.campusnotice.repository;
import com.yunhai.campusnotice.entity.NoticeApproval;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
/**
* 通知审批记录仓储。
*/
public interface NoticeApprovalRepository extends JpaRepository<NoticeApproval, Integer> {
/** 某通知全量审批轨迹(按落库顺序,即轮次时间线) */
@Query("select a from NoticeApproval a where a.noticeId = :noticeId order by a.id asc")
List<NoticeApproval> findByNoticeIdOrderByIdAsc(@Param("noticeId") Integer noticeId);
/** 批量查 N 个通知的审批记录(审批列表/详情批量装配) */
@Query("select a from NoticeApproval a where a.noticeId in :noticeIds order by a.id asc")
List<NoticeApproval> findByNoticeIds(@Param("noticeIds") Collection<Integer> noticeIds);
/** 某通知最新一条指定动作的记录(或签「他人已处理」摘要 / 驳回意见回显) */
@Query("select a from NoticeApproval a where a.noticeId = :noticeId and a.action in :actions " +
"order by a.id desc")
List<NoticeApproval> findLatestByNoticeIdAndActions(@Param("noticeId") Integer noticeId,
@Param("actions") Collection<Integer> actions);
/** 按通知ID删除全部审批记录(通知删除时级联清理) */
void deleteByNoticeId(Integer noticeId);
}
@@ -0,0 +1,15 @@
package com.yunhai.campusnotice.repository;
import com.yunhai.campusnotice.entity.NoticeApproverMemory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
/**
* 审批人记忆仓储(唯一约束 sch_id + publisher_account,upsert 由 Service 完成)。
*/
public interface NoticeApproverMemoryRepository extends JpaRepository<NoticeApproverMemory, Integer> {
/** 按学校 + 发布人查最近一次所选审批人组合 */
Optional<NoticeApproverMemory> findBySchIdAndPublisherAccount(Integer schId, String publisherAccount);
}
@@ -0,0 +1,27 @@
package com.yunhai.campusnotice.repository;
import com.yunhai.campusnotice.entity.NoticePushScope;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
/**
* 通知推送范围仓储。
*/
public interface NoticePushScopeRepository extends JpaRepository<NoticePushScope, Integer> {
/** 按通知ID查询全部范围快照 */
List<NoticePushScope> findByNoticeId(Integer noticeId);
/** 批量查 N 个通知的范围(编辑回显/列表展示避免 N+1) */
@Query("select s from NoticePushScope s where s.noticeId in :noticeIds")
List<NoticePushScope> findByNoticeIds(@Param("noticeIds") List<Integer> noticeIds);
/** 通知是否存在推送范围(学员端可见性过滤仅对范围通知启用) */
boolean existsByNoticeId(Integer noticeId);
/** 按通知ID删除全部范围(编辑全量替换) */
void deleteByNoticeId(Integer noticeId);
}