Compare commits

...

5 Commits
master ... dev

Author SHA1 Message Date
haomingming
b91077eda6 jenkins 2026-06-16 10:47:42 +08:00
haomingming
e0b089fbf8 优化 2026-06-16 10:32:04 +08:00
haomingming
52fd2e7560 优化 2026-06-04 10:42:23 +08:00
haomingming
db10401b13 0529 2026-05-29 10:38:34 +08:00
haomingming
edffa25ccd update MeetingPage 2026-05-20 18:47:06 +08:00
151 changed files with 43498 additions and 2506 deletions

96
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,96 @@
pipeline {
// 可以在这里指定运行的节点any 表示任何可用的 Jenkins 节点
agent any
environment {
// 定义镜像名称和标签
DOCKER_REGISTRY = '' // 如果有私有仓库,填在这里,如 'registry.cn-hangzhou.aliyuncs.com/my-ns/'
FRONTEND_IMAGE_NAME = 'writeoff-frontend'
BACKEND_IMAGE_NAME = 'writeoff-backend'
IMAGE_TAG = "${env.BUILD_NUMBER}" // 或者用 git commit hash
// 部署的目标服务器目录
DEPLOY_DIR = '/opt/writeoff'
}
stages {
// ---------------------------------------------------------
// 后端构建阶段
// ---------------------------------------------------------
stage('Build Backend') {
when {
// 仅当 backend 目录下的文件或 Dockerfile 发生变化时执行
changeset "backend/**"
}
steps {
echo "=============================="
echo "🚀 检测到后端代码变更,开始构建后端镜像..."
echo "=============================="
dir('backend') {
// 使用 Docker 构建后端镜像
sh "docker build -t ${DOCKER_REGISTRY}${BACKEND_IMAGE_NAME}:${IMAGE_TAG} ."
sh "docker tag ${DOCKER_REGISTRY}${BACKEND_IMAGE_NAME}:${IMAGE_TAG} ${DOCKER_REGISTRY}${BACKEND_IMAGE_NAME}:latest"
// 如果有私有仓库,可以在这里 push
// sh "docker push ${DOCKER_REGISTRY}${BACKEND_IMAGE_NAME}:${IMAGE_TAG}"
// sh "docker push ${DOCKER_REGISTRY}${BACKEND_IMAGE_NAME}:latest"
}
}
}
// ---------------------------------------------------------
// 前端构建与部署阶段 (方案1: 直接打包并复制静态文件)
// ---------------------------------------------------------
stage('Build & Deploy Frontend') {
when {
// 仅当 frontend 目录下的文件发生变化时执行
changeset "frontend/**"
}
steps {
echo "=============================="
echo "🚀 检测到前端代码变更,开始构建前端..."
echo "=============================="
dir('frontend') {
// 使用 npm 安装依赖并构建
sh "npm install"
sh "npm run build"
// 将打包好的静态文件复制到目标服务器的 Nginx 目录
// TODO: 请将 root@your-server:/usr/share/nginx/html/ 替换为实际部署路径
echo "📦 部署前端静态文件(dist)到 Nginx 目录..."
sh "scp -r dist/* root@your-server:/usr/share/nginx/html/"
}
}
}
// ---------------------------------------------------------
// 部署阶段 (使用 Docker Compose)
// ---------------------------------------------------------
stage('Deploy') {
steps {
echo "=============================="
echo "📦 开始部署项目..."
echo "=============================="
// 这里假设您是在 Jenkins 本机运行 docker-compose或者通过 SSH 连到目标服务器执行
// 示例中我们直接通过 docker-compose up -d 更新服务
sh "docker-compose up -d"
// 清理悬空镜像(可选项,防止磁盘占用过大)
sh "docker image prune -f"
}
}
}
// ---------------------------------------------------------
// 构建后处理
// ---------------------------------------------------------
post {
success {
echo "✅ 构建和部署成功!"
}
failure {
echo "❌ 构建或部署失败,请检查 Jenkins 日志。"
}
}
}

33
backend/Dockerfile Normal file
View File

@ -0,0 +1,33 @@
# 阶段 1构建环境
FROM maven:3.8.6-openjdk-8-slim as builder
WORKDIR /app
# 先复制 pom.xml 以下载依赖,利用 Docker 缓存机制
COPY pom.xml .
RUN mvn dependency:go-offline -B
# 复制源代码并打包
COPY src ./src
RUN mvn clean package -DskipTests
# 阶段 2运行环境
# 根据 pom.xml项目使用的是 Java 1.8
FROM openjdk:8-jre-alpine
WORKDIR /app
# 设置时区为上海(可选,但推荐)
ENV TZ=Asia/Shanghai
RUN apk add --no-cache tzdata && \
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \
echo $TZ > /etc/timezone
# 从构建阶段复制打包好的 jar 包
COPY --from=builder /app/target/*.jar app.jar
# 暴露 Spring Boot 默认端口
EXPOSE 8080
# 启动参数可根据需要调整
CMD ["java", "-jar", "app.jar"]

View File

@ -62,6 +62,11 @@
<artifactId>poi-ooxml</artifactId>
<version>5.2.5</version>
</dependency>
<dependency>
<groupId>com.belerweb</groupId>
<artifactId>pinyin4j</artifactId>
<version>2.5.1</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>

View File

@ -33,13 +33,23 @@ public class AuditController {
@RequestParam(value = "meetingId", required = false) Long meetingId,
@RequestParam(value = "pageNo", required = false) Integer pageNo,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
@RequestParam(value = "reviewFocus", required = false) String reviewFocus,
@RequestParam(value = "sortBy", required = false) String sortBy,
@RequestParam(value = "order", required = false) String order
) {
if (meetingId == null && pageNo == null && pageSize == null && sortBy == null && order == null) {
if (meetingId == null && pageNo == null && pageSize == null && reviewFocus == null && sortBy == null && order == null) {
return ApiResponse.success(auditService.listTasks(mine, scope));
}
return ApiResponse.success(auditService.listTasks(mine, scope, meetingId, pageNo, pageSize, sortBy, order));
return ApiResponse.success(auditService.listTasks(mine, scope, meetingId, pageNo, pageSize, reviewFocus, sortBy, order));
}
@GetMapping("/tasks/review-stat")
public ApiResponse<Map<String, Object>> reviewStat(
@RequestParam(value = "mine", required = false, defaultValue = "false") boolean mine,
@RequestParam(value = "scope", required = false) String scope,
@RequestParam(value = "reviewFocus", required = false) String reviewFocus
) {
return ApiResponse.success(auditService.reviewStat(mine, scope, reviewFocus));
}
@PostMapping("/tasks/{id}/approve")
@ -72,10 +82,16 @@ public class AuditController {
@GetMapping("/tasks/{id}/material")
@RequirePermission(value = "audit.material.read", dataScope = DataScopeType.MEETING_MODULE, auditAction = "AUDIT_MATERIAL_READ")
public ApiResponse<Map<String, Object>> readTaskMaterial(@PathVariable("id") Long id,
@RequestParam("moduleCode") String moduleCode) {
@RequestParam("moduleCode") String moduleCode) {
return ApiResponse.success(auditService.readTaskMaterial(id, moduleCode));
}
@GetMapping("/tasks/{id}")
@RequirePermission(value = "audit.material.read", dataScope = DataScopeType.MEETING, auditAction = "AUDIT_TASK_DETAIL_READ")
public ApiResponse<Map<String, Object>> taskDetail(@PathVariable("id") Long id) {
return ApiResponse.success(auditService.readTaskDetail(id));
}
@PostMapping("/tasks/{id}/material/approve-module")
@RequirePermission(value = "audit.approve", dataScope = DataScopeType.MEETING_MODULE, auditAction = "AUDIT_MATERIAL_APPROVE_MODULE")
public ApiResponse<Map<String, Object>> approveMaterialModule(@PathVariable("id") Long id,
@ -90,6 +106,13 @@ public class AuditController {
return ApiResponse.success(auditService.rejectMaterialItem(id, request));
}
@PostMapping("/tasks/{id}/issues/{issueId}/resolve")
@RequirePermission(value = "audit.approve", dataScope = DataScopeType.MEETING_MODULE, auditAction = "AUDIT_ISSUE_RESOLVE")
public ApiResponse<Map<String, Object>> confirmResolvedIssue(@PathVariable("id") Long id,
@PathVariable("issueId") Long issueId) {
return ApiResponse.success(auditService.confirmResolvedIssue(id, issueId));
}
@PostMapping("/tasks/{id}/transfer")
@RequirePermission(value = "audit.transfer", dataScope = DataScopeType.MEETING, auditAction = "AUDIT_TRANSFER")
public ApiResponse<Map<String, Object>> transfer(@PathVariable("id") Long id,

View File

@ -1,12 +1,13 @@
package com.writeoff.module.audit.dto;
import javax.validation.constraints.NotBlank;
import java.util.List;
public class AuditActionRequest {
@NotBlank(message = "幂等键不能为空")
private String idempotencyKey;
@NotBlank(message = "审核意见不能为空")
private String opinion;
private List<AuditIssueRequest> issues;
public String getIdempotencyKey() {
return idempotencyKey;
@ -23,4 +24,12 @@ public class AuditActionRequest {
public void setOpinion(String opinion) {
this.opinion = opinion;
}
public List<AuditIssueRequest> getIssues() {
return issues;
}
public void setIssues(List<AuditIssueRequest> issues) {
this.issues = issues;
}
}

View File

@ -0,0 +1,46 @@
package com.writeoff.module.audit.dto;
import javax.validation.constraints.NotBlank;
public class AuditIssueRequest {
@NotBlank(message = "模块编码不能为空")
private String moduleCode;
@NotBlank(message = "问题定位不能为空")
private String targetPath;
@NotBlank(message = "问题标签不能为空")
private String targetLabel;
@NotBlank(message = "问题原因不能为空")
private String reason;
public String getModuleCode() {
return moduleCode;
}
public void setModuleCode(String moduleCode) {
this.moduleCode = moduleCode;
}
public String getTargetPath() {
return targetPath;
}
public void setTargetPath(String targetPath) {
this.targetPath = targetPath;
}
public String getTargetLabel() {
return targetLabel;
}
public void setTargetLabel(String targetLabel) {
this.targetLabel = targetLabel;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
}

View File

@ -0,0 +1,125 @@
package com.writeoff.module.audit.model;
public class AuditIssue {
private Long id;
private Long taskId;
private Long meetingId;
private Long submissionVersionId;
private String reviewNode;
private String moduleCode;
private String targetPath;
private String targetLabel;
private String reason;
private String status;
private String responseText;
private Long createdBy;
private String createdAt;
private Long respondedBy;
private String respondedAt;
private Long resolvedBy;
private String resolvedAt;
public AuditIssue(Long id,
Long taskId,
Long meetingId,
Long submissionVersionId,
String reviewNode,
String moduleCode,
String targetPath,
String targetLabel,
String reason,
String status,
String responseText,
Long createdBy,
String createdAt,
Long respondedBy,
String respondedAt,
Long resolvedBy,
String resolvedAt) {
this.id = id;
this.taskId = taskId;
this.meetingId = meetingId;
this.submissionVersionId = submissionVersionId;
this.reviewNode = reviewNode;
this.moduleCode = moduleCode;
this.targetPath = targetPath;
this.targetLabel = targetLabel;
this.reason = reason;
this.status = status;
this.responseText = responseText;
this.createdBy = createdBy;
this.createdAt = createdAt;
this.respondedBy = respondedBy;
this.respondedAt = respondedAt;
this.resolvedBy = resolvedBy;
this.resolvedAt = resolvedAt;
}
public Long getId() {
return id;
}
public Long getTaskId() {
return taskId;
}
public Long getMeetingId() {
return meetingId;
}
public Long getSubmissionVersionId() {
return submissionVersionId;
}
public String getReviewNode() {
return reviewNode;
}
public String getModuleCode() {
return moduleCode;
}
public String getTargetPath() {
return targetPath;
}
public String getTargetLabel() {
return targetLabel;
}
public String getReason() {
return reason;
}
public String getStatus() {
return status;
}
public String getResponseText() {
return responseText;
}
public Long getCreatedBy() {
return createdBy;
}
public String getCreatedAt() {
return createdAt;
}
public Long getRespondedBy() {
return respondedBy;
}
public String getRespondedAt() {
return respondedAt;
}
public Long getResolvedBy() {
return resolvedBy;
}
public String getResolvedAt() {
return resolvedAt;
}
}

View File

@ -5,6 +5,7 @@ import java.util.List;
public class AuditTask {
private Long id;
private Long meetingId;
private Long submissionVersionId;
private AuditNode node;
private Long assigneeUserId;
private String assigneeUserName;
@ -20,7 +21,14 @@ public class AuditTask {
private Integer rejectCount;
private String lastRejectReason;
private String lastActionAt;
private String submittedAt;
private List<AuditFlowNodeInfo> flowNodes;
private Boolean resubmitted;
private Integer materialChangedCount;
private Integer unresolvedIssueCount;
private Integer resolvedIssueCount;
private Integer extraChangeCount;
private Integer riskScore;
public AuditTask(Long id, Long meetingId, AuditNode node, Long assigneeUserId, AuditTaskStatus status, String opinion) {
this(id, meetingId, node, assigneeUserId, status, opinion, null, 0, 0, false, null, null, null, 0, null, null);
@ -72,6 +80,10 @@ public class AuditTask {
return meetingId;
}
public Long getSubmissionVersionId() {
return submissionVersionId;
}
public AuditNode getNode() {
return node;
}
@ -132,14 +144,46 @@ public class AuditTask {
return lastActionAt;
}
public String getSubmittedAt() {
return submittedAt;
}
public List<AuditFlowNodeInfo> getFlowNodes() {
return flowNodes;
}
public Boolean getResubmitted() {
return resubmitted;
}
public Integer getMaterialChangedCount() {
return materialChangedCount;
}
public Integer getUnresolvedIssueCount() {
return unresolvedIssueCount;
}
public Integer getResolvedIssueCount() {
return resolvedIssueCount;
}
public Integer getExtraChangeCount() {
return extraChangeCount;
}
public Integer getRiskScore() {
return riskScore;
}
public void setStatus(AuditTaskStatus status) {
this.status = status;
}
public void setSubmissionVersionId(Long submissionVersionId) {
this.submissionVersionId = submissionVersionId;
}
public void setOpinion(String opinion) {
this.opinion = opinion;
}
@ -192,7 +236,35 @@ public class AuditTask {
this.lastActionAt = lastActionAt;
}
public void setSubmittedAt(String submittedAt) {
this.submittedAt = submittedAt;
}
public void setFlowNodes(List<AuditFlowNodeInfo> flowNodes) {
this.flowNodes = flowNodes;
}
public void setResubmitted(Boolean resubmitted) {
this.resubmitted = resubmitted;
}
public void setMaterialChangedCount(Integer materialChangedCount) {
this.materialChangedCount = materialChangedCount;
}
public void setUnresolvedIssueCount(Integer unresolvedIssueCount) {
this.unresolvedIssueCount = unresolvedIssueCount;
}
public void setResolvedIssueCount(Integer resolvedIssueCount) {
this.resolvedIssueCount = resolvedIssueCount;
}
public void setExtraChangeCount(Integer extraChangeCount) {
this.extraChangeCount = extraChangeCount;
}
public void setRiskScore(Integer riskScore) {
this.riskScore = riskScore;
}
}

View File

@ -13,6 +13,8 @@ public interface AuditTaskRepository {
List<AuditTask> findAll();
List<AuditTask> findMineTasks(Long assigneeUserId, boolean pendingOnly);
Optional<AuditTask> findLatestByMeetingId(Long meetingId);
int withdrawPendingByMeetingId(Long meetingId, String reason, Long operatorUserId);

View File

@ -30,6 +30,7 @@ public class InMemoryAuditTaskRepository implements AuditTaskRepository {
task.getStatus(),
task.getOpinion()
);
newTask.setSubmissionVersionId(task.getSubmissionVersionId());
store.put(newTask.getId(), newTask);
return newTask;
}
@ -47,6 +48,20 @@ public class InMemoryAuditTaskRepository implements AuditTaskRepository {
return new ArrayList<>(store.values());
}
@Override
public List<AuditTask> findMineTasks(Long assigneeUserId, boolean pendingOnly) {
if (assigneeUserId == null) {
return new ArrayList<>();
}
return store.values().stream()
.filter(task -> assigneeUserId.equals(task.getAssigneeUserId()))
.filter(task -> pendingOnly
? "PENDING".equals(task.getStatus().name())
: !"PENDING".equals(task.getStatus().name()))
.sorted(Comparator.comparingLong(AuditTask::getId).reversed())
.collect(java.util.stream.Collectors.toList());
}
@Override
public Optional<AuditTask> findLatestByMeetingId(Long meetingId) {
return store.values().stream()

View File

@ -22,24 +22,29 @@ import java.util.Optional;
@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "jdbc", matchIfMissing = true)
public class JdbcAuditTaskRepository implements AuditTaskRepository {
private final JdbcTemplate jdbcTemplate;
private static final RowMapper<AuditTask> ROW_MAPPER = (rs, n) -> new AuditTask(
rs.getLong("id"),
rs.getLong("meeting_id"),
AuditNode.valueOf(rs.getString("audit_node")),
rs.getObject("assignee_user_id") == null ? null : rs.getLong("assignee_user_id"),
AuditTaskStatus.valueOf(rs.getString("status")),
rs.getString("opinion"),
rs.getString("sla_deadline_at"),
rs.getInt("timeout_level"),
rs.getInt("overtime_hours"),
rs.getInt("is_overtime") == 1,
rs.getObject("transfer_from_user_id") == null ? null : rs.getLong("transfer_from_user_id"),
rs.getString("transfer_reason"),
rs.getString("return_reason"),
rs.getInt("reject_count"),
rs.getString("last_reject_reason"),
rs.getString("last_action_at")
);
private static final RowMapper<AuditTask> ROW_MAPPER = (rs, n) -> {
AuditTask task = new AuditTask(
rs.getLong("id"),
rs.getLong("meeting_id"),
AuditNode.valueOf(rs.getString("audit_node")),
rs.getObject("assignee_user_id") == null ? null : rs.getLong("assignee_user_id"),
AuditTaskStatus.valueOf(rs.getString("status")),
rs.getString("opinion"),
rs.getString("sla_deadline_at"),
rs.getInt("timeout_level"),
rs.getInt("overtime_hours"),
rs.getInt("is_overtime") == 1,
rs.getObject("transfer_from_user_id") == null ? null : rs.getLong("transfer_from_user_id"),
rs.getString("transfer_reason"),
rs.getString("return_reason"),
rs.getInt("reject_count"),
rs.getString("last_reject_reason"),
rs.getString("last_action_at")
);
task.setSubmissionVersionId(rs.getObject("submission_version_id") == null ? null : rs.getLong("submission_version_id"));
task.setSubmittedAt(rs.getString("submitted_at"));
return task;
};
public JdbcAuditTaskRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
@ -51,26 +56,30 @@ public class JdbcAuditTaskRepository implements AuditTaskRepository {
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(
"INSERT INTO audit_task (tenant_id, meeting_id, audit_node, assignee_user_id, status, opinion, sla_deadline_at, timeout_level, overtime_hours, is_overtime, " +
"INSERT INTO audit_task (tenant_id, meeting_id, submission_version_id, audit_node, assignee_user_id, status, opinion, sla_deadline_at, timeout_level, overtime_hours, is_overtime, " +
"transfer_from_user_id, transfer_reason, return_reason, reject_count, last_reject_reason, last_action_at, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 24 HOUR), 0, 0, 0, NULL, NULL, NULL, 0, NULL, NOW(), 0, 0)",
"VALUES (?, ?, ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 24 HOUR), 0, 0, 0, NULL, NULL, NULL, 0, NULL, NOW(), 0, 0)",
Statement.RETURN_GENERATED_KEYS
);
ps.setLong(1, tenantId());
ps.setLong(2, task.getMeetingId());
ps.setString(3, task.getNode().name());
ps.setObject(4, task.getAssigneeUserId());
ps.setString(5, task.getStatus().name());
ps.setString(6, task.getOpinion());
ps.setObject(3, task.getSubmissionVersionId());
ps.setString(4, task.getNode().name());
ps.setObject(5, task.getAssigneeUserId());
ps.setString(6, task.getStatus().name());
ps.setString(7, task.getOpinion());
return ps;
}, keyHolder);
Number key = keyHolder.getKey();
Long id = key == null ? null : key.longValue();
return new AuditTask(id, task.getMeetingId(), task.getNode(), task.getAssigneeUserId(), task.getStatus(), task.getOpinion());
AuditTask saved = new AuditTask(id, task.getMeetingId(), task.getNode(), task.getAssigneeUserId(), task.getStatus(), task.getOpinion());
saved.setSubmissionVersionId(task.getSubmissionVersionId());
return saved;
}
jdbcTemplate.update(
"UPDATE audit_task SET status=?, opinion=?, assignee_user_id=?, transfer_from_user_id=?, transfer_reason=?, return_reason=?, reject_count=?, " +
"UPDATE audit_task SET submission_version_id=?, status=?, opinion=?, assignee_user_id=?, transfer_from_user_id=?, transfer_reason=?, return_reason=?, reject_count=?, " +
"last_reject_reason=?, last_action_at=NOW(), updated_by=0 WHERE tenant_id=? AND id=?",
task.getSubmissionVersionId(),
task.getStatus().name(),
task.getOpinion(),
task.getAssigneeUserId(),
@ -88,11 +97,13 @@ public class JdbcAuditTaskRepository implements AuditTaskRepository {
@Override
public Optional<AuditTask> findById(Long id) {
List<AuditTask> list = jdbcTemplate.query(
"SELECT id, meeting_id, audit_node, assignee_user_id, status, opinion, " +
"SELECT id, meeting_id, submission_version_id, audit_node, assignee_user_id, status, opinion, " +
"DATE_FORMAT(sla_deadline_at, '%Y-%m-%d %H:%i:%s') AS sla_deadline_at, IFNULL(timeout_level, 0) AS timeout_level, " +
"IFNULL(overtime_hours, 0) AS overtime_hours, IFNULL(is_overtime, 0) AS is_overtime, " +
"transfer_from_user_id, transfer_reason, return_reason, IFNULL(reject_count, 0) AS reject_count, last_reject_reason, " +
"DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at " +
"DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at, " +
"COALESCE((SELECT DATE_FORMAT(msv.created_at, '%Y-%m-%d %H:%i:%s') FROM meeting_submission_version msv " +
"WHERE msv.tenant_id=audit_task.tenant_id AND msv.id=audit_task.submission_version_id), DATE_FORMAT(audit_task.created_at, '%Y-%m-%d %H:%i:%s')) AS submitted_at " +
"FROM audit_task WHERE tenant_id=? AND id=? AND is_deleted=0",
ROW_MAPPER, tenantId(), id
);
@ -103,24 +114,45 @@ public class JdbcAuditTaskRepository implements AuditTaskRepository {
public List<AuditTask> findAll() {
refreshTimeoutLevels();
return jdbcTemplate.query(
"SELECT id, meeting_id, audit_node, assignee_user_id, status, opinion, " +
"SELECT id, meeting_id, submission_version_id, audit_node, assignee_user_id, status, opinion, " +
"DATE_FORMAT(sla_deadline_at, '%Y-%m-%d %H:%i:%s') AS sla_deadline_at, IFNULL(timeout_level, 0) AS timeout_level, " +
"IFNULL(overtime_hours, 0) AS overtime_hours, IFNULL(is_overtime, 0) AS is_overtime, " +
"transfer_from_user_id, transfer_reason, return_reason, IFNULL(reject_count, 0) AS reject_count, last_reject_reason, " +
"DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at " +
"DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at, " +
"COALESCE((SELECT DATE_FORMAT(msv.created_at, '%Y-%m-%d %H:%i:%s') FROM meeting_submission_version msv " +
"WHERE msv.tenant_id=audit_task.tenant_id AND msv.id=audit_task.submission_version_id), DATE_FORMAT(audit_task.created_at, '%Y-%m-%d %H:%i:%s')) AS submitted_at " +
"FROM audit_task WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC",
ROW_MAPPER, tenantId()
);
}
@Override
public Optional<AuditTask> findLatestByMeetingId(Long meetingId) {
List<AuditTask> list = jdbcTemplate.query(
"SELECT id, meeting_id, audit_node, assignee_user_id, status, opinion, " +
public List<AuditTask> findMineTasks(Long assigneeUserId, boolean pendingOnly) {
refreshTimeoutLevels();
String statusSql = pendingOnly ? "status='PENDING'" : "status<>'PENDING'";
return jdbcTemplate.query(
"SELECT id, meeting_id, submission_version_id, audit_node, assignee_user_id, status, opinion, " +
"DATE_FORMAT(sla_deadline_at, '%Y-%m-%d %H:%i:%s') AS sla_deadline_at, IFNULL(timeout_level, 0) AS timeout_level, " +
"IFNULL(overtime_hours, 0) AS overtime_hours, IFNULL(is_overtime, 0) AS is_overtime, " +
"transfer_from_user_id, transfer_reason, return_reason, IFNULL(reject_count, 0) AS reject_count, last_reject_reason, " +
"DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at " +
"DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at, " +
"COALESCE((SELECT DATE_FORMAT(msv.created_at, '%Y-%m-%d %H:%i:%s') FROM meeting_submission_version msv " +
"WHERE msv.tenant_id=audit_task.tenant_id AND msv.id=audit_task.submission_version_id), DATE_FORMAT(audit_task.created_at, '%Y-%m-%d %H:%i:%s')) AS submitted_at " +
"FROM audit_task WHERE tenant_id=? AND assignee_user_id=? AND " + statusSql + " AND is_deleted=0 ORDER BY id DESC",
ROW_MAPPER, tenantId(), assigneeUserId
);
}
@Override
public Optional<AuditTask> findLatestByMeetingId(Long meetingId) {
List<AuditTask> list = jdbcTemplate.query(
"SELECT id, meeting_id, submission_version_id, audit_node, assignee_user_id, status, opinion, " +
"DATE_FORMAT(sla_deadline_at, '%Y-%m-%d %H:%i:%s') AS sla_deadline_at, IFNULL(timeout_level, 0) AS timeout_level, " +
"IFNULL(overtime_hours, 0) AS overtime_hours, IFNULL(is_overtime, 0) AS is_overtime, " +
"transfer_from_user_id, transfer_reason, return_reason, IFNULL(reject_count, 0) AS reject_count, last_reject_reason, " +
"DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at, " +
"COALESCE((SELECT DATE_FORMAT(msv.created_at, '%Y-%m-%d %H:%i:%s') FROM meeting_submission_version msv " +
"WHERE msv.tenant_id=audit_task.tenant_id AND msv.id=audit_task.submission_version_id), DATE_FORMAT(audit_task.created_at, '%Y-%m-%d %H:%i:%s')) AS submitted_at " +
"FROM audit_task WHERE tenant_id=? AND meeting_id=? AND is_deleted=0 ORDER BY id DESC LIMIT 1",
ROW_MAPPER, tenantId(), meetingId
);

View File

@ -0,0 +1,248 @@
package com.writeoff.module.audit.service;
import com.writeoff.common.exception.BusinessException;
import com.writeoff.module.audit.dto.AuditIssueRequest;
import com.writeoff.module.audit.model.AuditIssue;
import com.writeoff.module.audit.model.AuditTask;
import com.writeoff.security.AuthContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class AuditIssueService {
private static final RowMapper<AuditIssue> ROW_MAPPER = (rs, n) -> new AuditIssue(
rs.getLong("id"),
rs.getLong("task_id"),
rs.getLong("meeting_id"),
rs.getObject("submission_version_id") == null ? null : rs.getLong("submission_version_id"),
rs.getString("review_node"),
rs.getString("module_code"),
rs.getString("target_path"),
rs.getString("target_label"),
rs.getString("reason"),
rs.getString("status"),
rs.getString("response_text"),
rs.getObject("created_by") == null ? null : rs.getLong("created_by"),
rs.getString("created_at"),
rs.getObject("responded_by") == null ? null : rs.getLong("responded_by"),
rs.getString("responded_at"),
rs.getObject("resolved_by") == null ? null : rs.getLong("resolved_by"),
rs.getString("resolved_at")
);
private final JdbcTemplate jdbcTemplate;
public AuditIssueService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public List<AuditIssue> listByTaskId(Long taskId) {
if (taskId == null || taskId <= 0L) {
return new ArrayList<>();
}
return jdbcTemplate.query(
"SELECT ai.id, ai.task_id, ai.meeting_id, ai.submission_version_id, ai.review_node, ai.module_code, ai.target_path, ai.target_label, ai.reason, ai.status, ai.response_text, " +
"ai.created_by, " +
"(SELECT ir.responded_by FROM issue_response ir WHERE ir.tenant_id=ai.tenant_id AND ir.issue_id=ai.id ORDER BY ir.id DESC LIMIT 1) AS responded_by, " +
"CASE WHEN ai.status='RESOLVED' THEN ai.updated_by ELSE NULL END AS resolved_by, " +
"DATE_FORMAT(ai.created_at, '%Y-%m-%d %H:%i:%s') AS created_at, " +
"DATE_FORMAT(ai.responded_at, '%Y-%m-%d %H:%i:%s') AS responded_at, " +
"CASE WHEN ai.status='RESOLVED' THEN DATE_FORMAT(ai.updated_at, '%Y-%m-%d %H:%i:%s') ELSE NULL END AS resolved_at " +
"FROM audit_issue ai WHERE ai.tenant_id=? AND ai.task_id=? ORDER BY ai.id ASC",
ROW_MAPPER,
tenantId(),
taskId
);
}
public List<Map<String, Object>> listRowsByTaskId(Long taskId) {
List<AuditIssue> issues = listByTaskId(taskId);
Map<Long, String> userNameMap = mapUserNames(collectUserIds(issues));
List<Map<String, Object>> rows = new ArrayList<>();
for (AuditIssue issue : issues) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", issue.getId());
row.put("taskId", issue.getTaskId());
row.put("meetingId", issue.getMeetingId());
row.put("submissionVersionId", issue.getSubmissionVersionId());
row.put("reviewNode", issue.getReviewNode());
row.put("moduleCode", issue.getModuleCode());
row.put("targetPath", issue.getTargetPath());
row.put("targetLabel", issue.getTargetLabel());
row.put("reason", issue.getReason());
row.put("status", issue.getStatus());
row.put("responseText", issue.getResponseText());
row.put("createdBy", issue.getCreatedBy());
row.put("createdByName", userNameMap.get(issue.getCreatedBy()));
row.put("createdAt", issue.getCreatedAt());
row.put("respondedBy", issue.getRespondedBy());
row.put("respondedByName", userNameMap.get(issue.getRespondedBy()));
row.put("respondedAt", issue.getRespondedAt());
row.put("resolvedBy", issue.getResolvedBy());
row.put("resolvedByName", userNameMap.get(issue.getResolvedBy()));
row.put("resolvedAt", issue.getResolvedAt());
rows.add(row);
}
return rows;
}
@Transactional
public int replaceTaskIssues(AuditTask task, List<AuditIssueRequest> issues) {
if (task == null || task.getId() == null) {
throw new BusinessException(10001, "审核任务不存在");
}
jdbcTemplate.update("DELETE FROM audit_issue WHERE tenant_id=? AND task_id=? AND status='OPEN'", tenantId(), task.getId());
if (issues == null || issues.isEmpty()) {
return 0;
}
int count = 0;
for (AuditIssueRequest issue : issues) {
if (issue == null) {
continue;
}
String moduleCode = str(issue.getModuleCode());
String targetPath = str(issue.getTargetPath());
String targetLabel = str(issue.getTargetLabel());
String reason = str(issue.getReason());
if (moduleCode.isEmpty() || targetPath.isEmpty() || targetLabel.isEmpty() || reason.isEmpty()) {
throw new BusinessException(10001, "结构化驳回问题缺少必填字段");
}
count += jdbcTemplate.update(
"INSERT INTO audit_issue (tenant_id, task_id, meeting_id, submission_version_id, review_node, module_code, target_path, target_label, reason, status, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'OPEN', ?, ?)",
tenantId(),
task.getId(),
task.getMeetingId(),
task.getSubmissionVersionId(),
task.getNode() == null ? null : task.getNode().name(),
moduleCode,
targetPath,
targetLabel,
reason,
safeUserId(),
safeUserId()
);
}
return count;
}
@Transactional
public Map<String, Object> confirmResolved(Long taskId, Long issueId) {
if (taskId == null || taskId <= 0L || issueId == null || issueId <= 0L) {
throw new BusinessException(10001, "问题确认参数不能为空");
}
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT ai.id, ai.status, ai.target_label, ai.target_path, ai.task_id " +
"FROM audit_issue ai " +
"WHERE ai.tenant_id=? AND ai.id=? AND ai.status IN ('OPEN', 'PENDING_CONFIRM', 'RESOLVED') AND (" +
"ai.task_id=? OR EXISTS (" +
"SELECT 1 FROM audit_task at WHERE at.tenant_id=ai.tenant_id AND at.id=? AND at.meeting_id=ai.meeting_id AND at.submission_version_id=ai.submission_version_id" +
")" +
") LIMIT 1",
tenantId(),
issueId,
taskId,
taskId
);
if (rows.isEmpty()) {
throw new BusinessException(10003, "审核问题不存在");
}
Map<String, Object> current = rows.get(0);
String status = str(String.valueOf(current.get("status")));
if ("RESOLVED".equalsIgnoreCase(status)) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("issueId", issueId);
result.put("status", "RESOLVED");
result.put("statusText", "已确认解决");
return result;
}
Number ownerTaskIdNum = (Number) current.get("task_id");
Long ownerTaskId = ownerTaskIdNum == null ? null : ownerTaskIdNum.longValue();
jdbcTemplate.update(
"UPDATE audit_issue SET status='RESOLVED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?",
safeUserId(),
tenantId(),
issueId
);
Map<String, Object> result = new LinkedHashMap<>();
result.put("issueId", issueId);
result.put("taskId", ownerTaskId);
result.put("status", "RESOLVED");
result.put("statusText", "已确认解决");
result.put("targetLabel", str(String.valueOf(firstNonNull(current.get("target_label"), current.get("target_path")))));
return result;
}
private Object firstNonNull(Object first, Object second) {
return first != null ? first : second;
}
private Set<Long> collectUserIds(List<AuditIssue> issues) {
Set<Long> userIds = new HashSet<>();
if (issues == null) {
return userIds;
}
for (AuditIssue issue : issues) {
addUserId(userIds, issue.getCreatedBy());
addUserId(userIds, issue.getRespondedBy());
addUserId(userIds, issue.getResolvedBy());
}
return userIds;
}
private void addUserId(Set<Long> userIds, Long userId) {
if (userId != null && userId > 0L) {
userIds.add(userId);
}
}
private Map<Long, String> mapUserNames(Set<Long> userIds) {
Map<Long, String> result = new HashMap<>();
if (userIds == null || userIds.isEmpty()) {
return result;
}
String placeholders = userIds.stream().map(id -> "?").collect(Collectors.joining(","));
List<Object> args = new ArrayList<>();
args.add(tenantId());
args.addAll(userIds);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, user_name FROM sys_user WHERE tenant_id=? AND is_deleted=0 AND id IN (" + placeholders + ")",
args.toArray()
);
for (Map<String, Object> row : rows) {
Number idNum = (Number) row.get("id");
if (idNum == null) {
continue;
}
String userName = str(String.valueOf(row.get("user_name")));
if (!userName.isEmpty()) {
result.put(idNum.longValue(), userName);
}
}
return result;
}
private String str(String value) {
return value == null ? "" : value.trim();
}
private Long tenantId() {
return AuthContext.requireTenantId();
}
private Long safeUserId() {
Long userId = AuthContext.userId();
return userId == null ? 0L : userId;
}
}

View File

@ -120,7 +120,7 @@ public class AuthController {
.body(ApiErrorResponse.of(11005, "账号已被锁定,请" + (remaining / 60 + 1) + "分钟后重试", errors));
}
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT u.id, u.tenant_id, u.user_name, u.phone, u.status, u.password_hash, u.valid_from, u.valid_to, " +
"SELECT u.id, u.tenant_id, u.user_name, u.phone, u.email, u.status, u.password_hash, u.valid_from, u.valid_to, " +
"t.tenant_code, t.tenant_name, t.status AS tenant_status " +
"FROM sys_user u JOIN tenant t ON u.tenant_id=t.id " +
"WHERE u.phone=? AND t.tenant_code=? AND u.is_deleted=0 AND t.is_deleted=0 LIMIT 1",
@ -169,7 +169,7 @@ public class AuthController {
String refreshToken = String.valueOf(issueResult.get("refreshToken"));
String token = jwtTokenService.createTenantToken(userId, tenantId, request.getPhone(), sessionId);
setRefreshCookie(httpResponse, refreshToken, false);
Map<String, Object> data = buildTenantAuthData(userId, tenantId, String.valueOf(row.get("user_name")), request.getPhone(), token);
Map<String, Object> data = buildTenantAuthData(userId, tenantId, String.valueOf(row.get("user_name")), request.getPhone(), String.valueOf(row.get("email")), token);
return ResponseEntity.ok(ApiResponse.success(data));
}
@ -194,7 +194,7 @@ public class AuthController {
.body(ApiErrorResponse.of(11005, "账号已被锁定,请" + (remaining / 60 + 1) + "分钟后重试", errors));
}
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, user_name, phone, status, password_hash, valid_from, valid_to " +
"SELECT id, user_name, phone, email, status, password_hash, valid_from, valid_to " +
"FROM platform_user WHERE phone=? AND is_deleted=0 LIMIT 1",
request.getPhone()
);
@ -235,7 +235,7 @@ public class AuthController {
String refreshToken = String.valueOf(issueResult.get("refreshToken"));
String token = jwtTokenService.createPlatformToken(userId, request.getPhone(), sessionId);
setRefreshCookie(httpResponse, refreshToken, false);
Map<String, Object> data = buildPlatformAuthData(userId, String.valueOf(row.get("user_name")), request.getPhone(), token);
Map<String, Object> data = buildPlatformAuthData(userId, String.valueOf(row.get("user_name")), request.getPhone(), String.valueOf(row.get("email")), token);
return ResponseEntity.ok(ApiResponse.success(data));
}
@ -292,36 +292,29 @@ public class AuthController {
Map<String, Object> data;
if (scope == AuthScope.TENANT) {
validateTenantSession(userId, tenantId);
String phone = jdbcTemplate.queryForObject(
"SELECT phone FROM sys_user WHERE id=? AND tenant_id=? AND is_deleted=0 LIMIT 1",
String.class,
userId,
tenantId
);
String userName = jdbcTemplate.queryForObject(
"SELECT user_name FROM sys_user WHERE id=? AND tenant_id=? AND is_deleted=0 LIMIT 1",
String.class,
Map<String, Object> uRow = jdbcTemplate.queryForMap(
"SELECT phone, user_name, email FROM sys_user WHERE id=? AND tenant_id=? AND is_deleted=0 LIMIT 1",
userId,
tenantId
);
String phone = String.valueOf(uRow.get("phone"));
String userName = String.valueOf(uRow.get("user_name"));
String email = String.valueOf(uRow.get("email"));
Long sessionId = rotateResult.get("sessionId") == null ? null : ((Number) rotateResult.get("sessionId")).longValue();
String token = jwtTokenService.createTenantToken(userId, tenantId, phone, sessionId);
data = buildTenantAuthData(userId, tenantId, userName, phone, token);
data = buildTenantAuthData(userId, tenantId, userName, phone, email, token);
} else {
validatePlatformSession(userId);
String phone = jdbcTemplate.queryForObject(
"SELECT phone FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1",
String.class,
userId
);
String userName = jdbcTemplate.queryForObject(
"SELECT user_name FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1",
String.class,
Map<String, Object> puRow = jdbcTemplate.queryForMap(
"SELECT phone, user_name, email FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1",
userId
);
String phone = String.valueOf(puRow.get("phone"));
String userName = String.valueOf(puRow.get("user_name"));
String email = String.valueOf(puRow.get("email"));
Long sessionId = rotateResult.get("sessionId") == null ? null : ((Number) rotateResult.get("sessionId")).longValue();
String token = jwtTokenService.createPlatformToken(userId, phone, sessionId);
data = buildPlatformAuthData(userId, userName, phone, token);
data = buildPlatformAuthData(userId, userName, phone, email, token);
}
setRefreshCookie(response, nextRefreshToken, false);
return ApiResponse.success(data);
@ -377,7 +370,7 @@ public class AuthController {
String phone = String.valueOf(targetIdentity.get("phone"));
String token = jwtTokenService.createTenantToken(targetUserId, targetTenantId, phone, sessionId);
setRefreshCookie(httpResponse, refreshToken, false);
return ApiResponse.success(buildTenantAuthData(targetUserId, targetTenantId, String.valueOf(targetIdentity.get("user_name")), phone, token));
return ApiResponse.success(buildTenantAuthData(targetUserId, targetTenantId, String.valueOf(targetIdentity.get("user_name")), phone, String.valueOf(targetIdentity.get("email")), token));
}
@PostMapping("/logout")
@ -462,7 +455,7 @@ public class AuthController {
return dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
private Map<String, Object> buildTenantAuthData(Long userId, Long tenantId, String userName, String phone, String token) {
private Map<String, Object> buildTenantAuthData(Long userId, Long tenantId, String userName, String phone, String email, String token) {
Map<String, Object> tenant = loadTenantInfo(tenantId);
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("token", token);
@ -476,11 +469,12 @@ public class AuthController {
data.put("roles", systemUserService.getUserRoles(userId, tenantId));
data.put("permissions", permissionService.getPermissions(userId, tenantId));
data.put("phone", phone);
data.put("email", email);
data.put("appearance", loadTenantPreferences(userId, tenantId));
return data;
}
private Map<String, Object> buildPlatformAuthData(Long userId, String userName, String phone, String token) {
private Map<String, Object> buildPlatformAuthData(Long userId, String userName, String phone, String email, String token) {
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("token", token);
data.put("scope", AuthScope.PLATFORM.name());
@ -490,6 +484,7 @@ public class AuthController {
data.put("roles", permissionService.getPlatformRoles(userId));
data.put("permissions", permissionService.getPlatformPermissions(userId));
data.put("phone", phone);
data.put("email", email);
data.put("appearance", loadPlatformPreferences(userId));
return data;
}
@ -678,7 +673,7 @@ public class AuthController {
String normalizedSwitchAccountKey = normalizeTenantSwitchAccountKey(switchAccountKey);
if (!normalizedSwitchAccountKey.isEmpty()) {
List<Map<String, Object>> accountKeyRows = jdbcTemplate.queryForList(
"SELECT u.id AS user_id, u.tenant_id, u.user_name, u.phone, u.password_hash, u.tenant_switch_account_key " +
"SELECT u.id AS user_id, u.tenant_id, u.user_name, u.phone, u.email, u.password_hash, u.tenant_switch_account_key " +
"FROM sys_user u " +
"JOIN tenant t ON u.tenant_id=t.id " +
"WHERE u.tenant_id=? AND u.tenant_switch_account_key=? " +
@ -692,7 +687,7 @@ public class AuthController {
}
}
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT u.id AS user_id, u.tenant_id, u.user_name, u.phone, u.password_hash, u.tenant_switch_account_key " +
"SELECT u.id AS user_id, u.tenant_id, u.user_name, u.phone, u.email, u.password_hash, u.tenant_switch_account_key " +
"FROM sys_user u " +
"JOIN tenant t ON u.tenant_id=t.id " +
"WHERE u.tenant_id=? AND u.phone=? AND u.password_hash=? " +

View File

@ -12,6 +12,11 @@ import com.writeoff.module.expert.model.ExpertBankCardInfo;
import com.writeoff.module.expert.model.ExpertInfo;
import com.writeoff.module.system.service.DataPermissionService;
import com.writeoff.security.AuthContext;
import net.sourceforge.pinyin4j.PinyinHelper;
import net.sourceforge.pinyin4j.format.HanyuPinyinCaseType;
import net.sourceforge.pinyin4j.format.HanyuPinyinOutputFormat;
import net.sourceforge.pinyin4j.format.HanyuPinyinToneType;
import net.sourceforge.pinyin4j.format.HanyuPinyinVCharType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
@ -114,10 +119,29 @@ public class ExpertService {
StringBuilder whereClause = new StringBuilder(
"WHERE e.tenant_id=" + PLATFORM_TENANT_ID + " AND e.is_deleted=0"
);
List<Object> countArgs = new ArrayList<>();
if (keyword != null && !keyword.trim().isEmpty()) {
String kw = keyword.trim().replace("'", "''");
whereClause.append(" AND (e.expert_name LIKE '%").append(kw).append("%' OR e.id_no LIKE '%").append(kw).append("%' OR e.phone LIKE '%").append(kw).append("%')");
whereClause.append(" AND (e.expert_name LIKE '%").append(kw).append("%' OR e.id_no LIKE '%").append(kw).append("%')");
}
com.writeoff.module.system.service.DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope();
if (!scope.isExpertAll()) {
boolean hasIdScope = !scope.getExpertIds().isEmpty();
boolean hasOwnerScope = scope.isExpertOwnerOnly();
if (!hasIdScope && !hasOwnerScope) {
return new PageResult<ExpertInfo>(new ArrayList<>(), 0, safePage, safeSize);
}
whereClause.append(" AND (1=0");
if (hasIdScope) {
whereClause.append(" OR e.id IN (").append(scope.getExpertIds().stream().map(String::valueOf).collect(Collectors.joining(","))).append(")");
}
if (hasOwnerScope) {
Long userId = AuthContext.userId();
if (userId != null) {
whereClause.append(" OR e.created_by=").append(userId);
}
}
whereClause.append(")");
}
Integer total = jdbcTemplate.queryForObject(
@ -138,7 +162,6 @@ public class ExpertService {
sql.append(whereClause);
sql.append(" ORDER BY e.id DESC LIMIT ").append(safeSize).append(" OFFSET ").append(offset);
List<ExpertInfo> list = jdbcTemplate.query(sql.toString(), EXPERT_ROW_MAPPER);
list = filterByExpertScope(list);
List<ExpertInfo> maskedList = new java.util.ArrayList<ExpertInfo>(list.size());
for (ExpertInfo item : list) {
maskedList.add(maskSensitiveFields(item));
@ -195,11 +218,13 @@ public class ExpertService {
@Transactional(rollbackFor = Exception.class)
public ExpertInfo create(CreateExpertRequest request) {
String idNo = request.getIdNo() == null ? "" : request.getIdNo().trim().toUpperCase();
request.setIdNo(idNo);
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM expert WHERE tenant_id=? AND id_no=? AND is_deleted=0",
Integer.class,
PLATFORM_TENANT_ID,
request.getIdNo()
idNo
);
if (count != null && count > 0) {
throw new BusinessException(10001, "身份证号已存在");
@ -617,11 +642,76 @@ public class ExpertService {
name
);
if (byName.isEmpty()) {
throw new BusinessException(10001, label + "不在平台字典内,请先在平台字典中维护");
String pinyinCode = getPinyin(name);
if (pinyinCode.length() > 40) {
pinyinCode = pinyinCode.substring(0, 40);
}
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM platform_dictionary_item WHERE dict_type=? AND dict_code=?",
Integer.class,
dictType,
pinyinCode
);
if (count != null && count > 0) {
int i = 1;
while(true) {
String newCode = pinyinCode + "_" + i;
if (newCode.length() > 50) {
newCode = newCode.substring(newCode.length() - 50);
}
Integer c = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM platform_dictionary_item WHERE dict_type=? AND dict_code=?",
Integer.class, dictType, newCode
);
if (c == null || c == 0) {
pinyinCode = newCode;
break;
}
i++;
}
}
Integer maxSort = jdbcTemplate.queryForObject(
"SELECT MAX(sort_no) FROM platform_dictionary_item WHERE dict_type=?",
Integer.class,
dictType
);
int nextSort = (maxSort == null ? 0 : maxSort) + 10;
jdbcTemplate.update(
"INSERT INTO platform_dictionary_item (dict_type, dict_code, dict_name, sort_no, status, remark, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, 'ENABLED', 'Auto-imported', ?, ?)",
dictType, pinyinCode, name, nextSort, safeUserId(), safeUserId()
);
return new DictionaryItem(pinyinCode, name);
}
return byName.get(0);
}
private String getPinyin(String src) {
if (src == null || src.trim().isEmpty()) {
return "";
}
StringBuilder result = new StringBuilder();
HanyuPinyinOutputFormat format = new HanyuPinyinOutputFormat();
format.setCaseType(HanyuPinyinCaseType.UPPERCASE);
format.setToneType(HanyuPinyinToneType.WITHOUT_TONE);
format.setVCharType(HanyuPinyinVCharType.WITH_V);
for (char c : src.trim().toCharArray()) {
if (Character.toString(c).matches("[\\u4E00-\\u9FA5]+")) {
try {
String[] pinyins = PinyinHelper.toHanyuPinyinStringArray(c, format);
if (pinyins != null && pinyins.length > 0) {
result.append(pinyins[0]);
}
} catch (Exception e) {
result.append(c);
}
} else {
result.append(Character.toUpperCase(c));
}
}
return result.toString();
}
private void validateImportExpert(CreateExpertRequest request, Set<String> batchIdNos, Set<String> batchPhones) {
if (request == null) {
throw new BusinessException(10001, "导入行不能为空");

View File

@ -11,6 +11,11 @@ import com.writeoff.module.file.service.OssService;
import com.writeoff.module.expert.model.ExpertBankCardInfo;
import com.writeoff.module.expert.model.ExpertInfo;
import com.writeoff.security.AuthContext;
import net.sourceforge.pinyin4j.PinyinHelper;
import net.sourceforge.pinyin4j.format.HanyuPinyinCaseType;
import net.sourceforge.pinyin4j.format.HanyuPinyinOutputFormat;
import net.sourceforge.pinyin4j.format.HanyuPinyinToneType;
import net.sourceforge.pinyin4j.format.HanyuPinyinVCharType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
@ -109,11 +114,14 @@ public class PlatformExpertService {
"LEFT JOIN platform_dictionary_item dh ON dh.dict_type='EXPERT_HOSPITAL' AND dh.dict_code=e.hospital_code AND dh.is_deleted=0 " +
"WHERE e.tenant_id=" + PLATFORM_TENANT_ID + " AND e.is_deleted=0"
);
String idNoKeyword = normalizeIdNoKeyword(keyword);
List<Object> params = new ArrayList<Object>();
if (idNoKeyword != null) {
sql.append(" AND e.id_no = ?");
params.add(idNoKeyword);
String normalizedKeyword = keyword == null ? "" : keyword.trim();
if (!normalizedKeyword.isEmpty()) {
String likeKeyword = "%" + normalizedKeyword + "%";
sql.append(" AND (e.expert_name LIKE ? OR e.id_no LIKE ? OR e.phone LIKE ?)");
params.add(likeKeyword);
params.add(likeKeyword);
params.add(likeKeyword);
}
sql.append(" ORDER BY e.id DESC LIMIT 200");
List<ExpertInfo> list = params.isEmpty()
@ -126,6 +134,38 @@ public class PlatformExpertService {
return new PageResult<ExpertInfo>(maskedList, maskedList.size(), 1, 200);
}
public PageResult<ExpertInfo> listForMeetingBinding(String keyword) {
String normalizedKeyword = keyword == null ? "" : keyword.trim();
if (normalizedKeyword.isEmpty()) {
return new PageResult<ExpertInfo>(new ArrayList<ExpertInfo>(), 0, 1, 200);
}
StringBuilder sql = new StringBuilder(
"SELECT e.id, e.expert_name, e.gender, e.birthday, e.id_no, e.id_card_valid_until, e.id_card_front_oss_key, e.id_card_back_oss_key, e.phone, " +
"e.title_code, IFNULL(dt.dict_name, e.title) AS title_name, " +
"e.hospital_code, IFNULL(dh.dict_name, e.organization) AS hospital_name, " +
"e.status, e.status_reason, e.status_changed_at, e.export_restricted " +
"FROM expert e " +
"LEFT JOIN platform_dictionary_item dt ON dt.dict_type='EXPERT_TITLE' AND dt.dict_code=e.title_code AND dt.is_deleted=0 " +
"LEFT JOIN platform_dictionary_item dh ON dh.dict_type='EXPERT_HOSPITAL' AND dh.dict_code=e.hospital_code AND dh.is_deleted=0 " +
"WHERE e.tenant_id=" + PLATFORM_TENANT_ID + " AND e.is_deleted=0 AND (e.expert_name = ?"
);
List<Object> params = new ArrayList<Object>();
params.add(normalizedKeyword);
if (ID_NO_PATTERN.matcher(normalizedKeyword).matches()) {
sql.append(" OR e.id_no = ?");
params.add(normalizedKeyword.toUpperCase());
}
sql.append(") ORDER BY e.id DESC LIMIT 200");
List<ExpertInfo> list = jdbcTemplate.query(sql.toString(), EXPERT_ROW_MAPPER, params.toArray());
List<ExpertInfo> maskedList = new ArrayList<ExpertInfo>(list.size());
for (ExpertInfo item : list) {
maskedList.add(maskSensitiveFields(item));
}
return new PageResult<ExpertInfo>(maskedList, maskedList.size(), 1, 200);
}
public ExpertInfo get(Long id) {
return findById(id);
}
@ -153,11 +193,13 @@ public class PlatformExpertService {
@Transactional(rollbackFor = Exception.class)
public ExpertInfo create(CreateExpertRequest request) {
String idNo = request.getIdNo() == null ? "" : request.getIdNo().trim().toUpperCase();
request.setIdNo(idNo);
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM expert WHERE tenant_id=? AND id_no=? AND is_deleted=0",
Integer.class,
PLATFORM_TENANT_ID,
request.getIdNo()
idNo
);
if (count != null && count > 0) {
throw new BusinessException(10001, "身份证号已存在");
@ -579,11 +621,76 @@ public class PlatformExpertService {
name
);
if (byName.isEmpty()) {
throw new BusinessException(10001, label + "不在平台字典内,请先在平台字典中维护");
String pinyinCode = getPinyin(name);
if (pinyinCode.length() > 40) {
pinyinCode = pinyinCode.substring(0, 40);
}
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM platform_dictionary_item WHERE dict_type=? AND dict_code=?",
Integer.class,
dictType,
pinyinCode
);
if (count != null && count > 0) {
int i = 1;
while(true) {
String newCode = pinyinCode + "_" + i;
if (newCode.length() > 50) {
newCode = newCode.substring(newCode.length() - 50);
}
Integer c = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM platform_dictionary_item WHERE dict_type=? AND dict_code=?",
Integer.class, dictType, newCode
);
if (c == null || c == 0) {
pinyinCode = newCode;
break;
}
i++;
}
}
Integer maxSort = jdbcTemplate.queryForObject(
"SELECT MAX(sort_no) FROM platform_dictionary_item WHERE dict_type=?",
Integer.class,
dictType
);
int nextSort = (maxSort == null ? 0 : maxSort) + 10;
jdbcTemplate.update(
"INSERT INTO platform_dictionary_item (dict_type, dict_code, dict_name, sort_no, status, remark, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, 'ENABLED', 'Auto-imported', ?, ?)",
dictType, pinyinCode, name, nextSort, safeUserId(), safeUserId()
);
return new DictionaryItem(pinyinCode, name);
}
return byName.get(0);
}
private String getPinyin(String src) {
if (src == null || src.trim().isEmpty()) {
return "";
}
StringBuilder result = new StringBuilder();
HanyuPinyinOutputFormat format = new HanyuPinyinOutputFormat();
format.setCaseType(HanyuPinyinCaseType.UPPERCASE);
format.setToneType(HanyuPinyinToneType.WITHOUT_TONE);
format.setVCharType(HanyuPinyinVCharType.WITH_V);
for (char c : src.trim().toCharArray()) {
if (Character.toString(c).matches("[\\u4E00-\\u9FA5]+")) {
try {
String[] pinyins = PinyinHelper.toHanyuPinyinStringArray(c, format);
if (pinyins != null && pinyins.length > 0) {
result.append(pinyins[0]);
}
} catch (Exception e) {
result.append(c);
}
} else {
result.append(Character.toUpperCase(c));
}
}
return result.toString();
}
private DictionaryItem resolveOptionalDictionaryItem(String dictType, String dictCode, String dictName, String label) {
boolean hasCode = dictCode != null && !dictCode.trim().isEmpty();
boolean hasName = dictName != null && !dictName.trim().isEmpty();

View File

@ -23,8 +23,9 @@ public class ExportTaskController {
@GetMapping
@RequirePermission(value = "export.task.read", dataScope = DataScopeType.TENANT, auditAction = "EXPORT_TASK_LIST")
public ApiResponse<PageResult<ExportTaskInfo>> list() {
return ApiResponse.success(exportTaskService.list());
public ApiResponse<PageResult<ExportTaskInfo>> list(@RequestParam(value = "pageNo", defaultValue = "1") int pageNo,
@RequestParam(value = "pageSize", defaultValue = "10") int pageSize) {
return ApiResponse.success(exportTaskService.list(pageNo, pageSize));
}
@PostMapping

View File

@ -64,16 +64,32 @@ public class ExportTaskService {
this.meetingSummaryExportService = meetingSummaryExportService;
}
public PageResult<ExportTaskInfo> list() {
List<ExportTaskInfo> list = jdbcTemplate.query(
"SELECT id, task_code, biz_type, biz_id, file_name, file_oss_key, status, retry_count, IFNULL(download_count,0) AS download_count, " +
"DATE_FORMAT(download_token_expire_at, '%Y-%m-%d %H:%i:%s') AS token_expire_at, error_message, " +
"DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at, DATE_FORMAT(finished_at, '%Y-%m-%d %H:%i:%s') AS finished_at " +
"FROM export_task WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC LIMIT 300",
ROW_MAPPER,
public PageResult<ExportTaskInfo> list(int pageNo, int pageSize) {
int page = Math.max(1, pageNo);
int size = Math.max(1, Math.min(1000, pageSize));
int offset = (page - 1) * size;
Integer totalObj = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM export_task WHERE tenant_id=? AND is_deleted=0",
Integer.class,
tenantId()
);
return new PageResult<ExportTaskInfo>(list, list.size(), 1, 300);
int totalElements = totalObj == null ? 0 : totalObj;
List<ExportTaskInfo> list = new ArrayList<>();
if (totalElements > 0) {
list = jdbcTemplate.query(
"SELECT id, task_code, biz_type, biz_id, file_name, file_oss_key, status, retry_count, IFNULL(download_count,0) AS download_count, " +
"DATE_FORMAT(download_token_expire_at, '%Y-%m-%d %H:%i:%s') AS token_expire_at, error_message, " +
"DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at, DATE_FORMAT(finished_at, '%Y-%m-%d %H:%i:%s') AS finished_at " +
"FROM export_task WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC LIMIT ? OFFSET ?",
ROW_MAPPER,
tenantId(),
size,
offset
);
}
return new PageResult<ExportTaskInfo>(list, totalElements, page, size);
}
@Transactional(rollbackFor = Exception.class)

View File

@ -16,6 +16,7 @@ import com.writeoff.module.meeting.dto.MeetingInvoiceConfigRequest;
import com.writeoff.module.meeting.dto.MeetingLaborAgreementExtractApplyRequest;
import com.writeoff.module.meeting.dto.MeetingLaborAgreementExtractQueryRequest;
import com.writeoff.module.meeting.dto.MeetingLaborAgreementExtractSubmitRequest;
import com.writeoff.module.meeting.dto.MeetingMaterialResubmitPreviewRequest;
import com.writeoff.module.meeting.dto.SaveMeetingMaterialRequest;
import com.writeoff.module.meeting.dto.SubmitMeetingRequest;
import com.writeoff.module.meeting.dto.SubmitMeetingMaterialRequest;
@ -76,10 +77,19 @@ public class MeetingController {
return ApiResponse.success(meetingService.list(query));
}
@GetMapping("/default-budget")
@RequirePermission(value = "meeting.create", dataScope = DataScopeType.TENANT, auditAction = "MEETING_DEFAULT_BUDGET")
public ApiResponse<Map<String, Long>> getDefaultBudget(@RequestParam("projectId") Long projectId) {
long defaultBudgetCent = meetingService.getDefaultMeetingBudgetCent(projectId);
java.util.Map<String, Long> result = new java.util.LinkedHashMap<>();
result.put("defaultMeetingBudgetCent", defaultBudgetCent);
return ApiResponse.success(result);
}
@GetMapping("/tenant-experts")
@RequirePermission(value = "meeting.material.read", dataScope = DataScopeType.TENANT, auditAction = "MEETING_TENANT_EXPERT_LIST")
public ApiResponse<PageResult<ExpertInfo>> tenantExperts(@RequestParam(value = "keyword", required = false) String keyword) {
return ApiResponse.success(platformExpertService.list(keyword));
return ApiResponse.success(platformExpertService.listForMeetingBinding(keyword));
}
@PostMapping("/tenant-experts")
@ -115,22 +125,29 @@ public class MeetingController {
return ApiResponse.success(meetingExpertBindingService.unbindOne(id, expertId));
}
@PostMapping("/{id}/labor-agreement-extract/upload-sign")
@RequirePermission(value = "meeting.labor-agreement.extract", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_UPLOAD_SIGN")
public ApiResponse<Map<String, Object>> laborAgreementUploadSign(@PathVariable("id") Long id,
@RequestBody @Valid MeetingMaterialUploadSignRequest request) {
return ApiResponse.success(meetingMaterialService.presignMaterialUpload(id, "EXPERT_LIST", request.getFileName(), request.getContentType()));
}
@PostMapping("/{id}/labor-agreement-extract/task")
@RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_EXTRACT_SUBMIT")
@RequirePermission(value = "meeting.labor-agreement.extract", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_EXTRACT_SUBMIT")
public ApiResponse<DocumentExtractTaskSubmitResponse> submitLaborAgreementExtractTask(@PathVariable("id") Long id,
@RequestBody @Valid MeetingLaborAgreementExtractSubmitRequest request) {
return ApiResponse.success(meetingLaborAgreementExtractService.submit(id, request));
}
@PostMapping("/{id}/labor-agreement-extract/query")
@RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_EXTRACT_QUERY")
@RequirePermission(value = "meeting.labor-agreement.extract", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_EXTRACT_QUERY")
public ApiResponse<MeetingLaborAgreementExtractResult> queryLaborAgreementExtract(@PathVariable("id") Long id,
@RequestBody @Valid MeetingLaborAgreementExtractQueryRequest request) {
return ApiResponse.success(meetingLaborAgreementExtractService.query(id, request.getTaskId()));
}
@PostMapping("/{id}/labor-agreement-extract/apply")
@RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_EXTRACT_APPLY")
@RequirePermission(value = "meeting.labor-agreement.extract", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_EXTRACT_APPLY")
public ApiResponse<Map<String, Object>> applyLaborAgreementExtract(@PathVariable("id") Long id,
@RequestBody @Valid MeetingLaborAgreementExtractApplyRequest request) {
return ApiResponse.success(meetingLaborAgreementExtractService.apply(id, request));
@ -155,7 +172,7 @@ public class MeetingController {
}
@PutMapping("/{id}")
@RequirePermission(value = "meeting.create", dataScope = DataScopeType.MEETING, auditAction = "MEETING_UPDATE")
@RequirePermission(value = "meeting.update", dataScope = DataScopeType.MEETING, auditAction = "MEETING_UPDATE")
public ApiResponse<Meeting> update(@PathVariable("id") Long id,
@RequestBody @Valid CreateMeetingRequest request) {
return ApiResponse.success(meetingService.update(id, request));
@ -175,6 +192,12 @@ public class MeetingController {
return ApiResponse.success(meetingService.submit(id, request));
}
@GetMapping("/{id}/pending-issues")
@RequirePermission(value = "meeting.read", dataScope = DataScopeType.MEETING, auditAction = "MEETING_PENDING_ISSUE_LIST")
public ApiResponse<List<Map<String, Object>>> pendingIssues(@PathVariable("id") Long id) {
return ApiResponse.success(meetingService.listPendingIssues(id));
}
@PostMapping("/{id}/withdraw")
@RequirePermission(value = "meeting.withdraw", dataScope = DataScopeType.MEETING, auditAction = "MEETING_WITHDRAW")
public ApiResponse<Map<String, Object>> withdraw(@PathVariable("id") Long id,
@ -241,6 +264,15 @@ public class MeetingController {
return ApiResponse.success(meetingMaterialService.history(id, moduleCode));
}
@PostMapping("/{id}/materials/{moduleCode}/resubmit-preview")
@RequirePermission(value = "meeting.material.read", dataScope = DataScopeType.MEETING_MODULE, auditAction = "MEETING_MATERIAL_CURRENT")
public ApiResponse<Map<String, Object>> materialResubmitPreview(@PathVariable("id") Long id,
@PathVariable("moduleCode") String moduleCode,
@RequestBody(required = false) MeetingMaterialResubmitPreviewRequest request) {
String contentJson = request == null ? null : request.getContentJson();
return ApiResponse.success(meetingMaterialService.previewResubmitSummary(id, moduleCode, contentJson));
}
@GetMapping("/{id}/matched-templates")
@RequirePermission(value = "meeting.material.read", dataScope = DataScopeType.MEETING, auditAction = "MEETING_MATCHED_TEMPLATES")
public ApiResponse<List<TemplateInfo>> matchedTemplates(@PathVariable("id") Long id) {

View File

@ -0,0 +1,27 @@
package com.writeoff.module.meeting.dto;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
public class MeetingIssueResponseRequest {
@NotNull(message = "问题ID不能为空")
private Long issueId;
@NotBlank(message = "问题处理说明不能为空")
private String responseText;
public Long getIssueId() {
return issueId;
}
public void setIssueId(Long issueId) {
this.issueId = issueId;
}
public String getResponseText() {
return responseText;
}
public void setResponseText(String responseText) {
this.responseText = responseText;
}
}

View File

@ -1,4 +1,4 @@
package com.writeoff.module.meeting.dto;
package com.writeoff.module.meeting.dto;
import javax.validation.constraints.NotBlank;

View File

@ -0,0 +1,22 @@
package com.writeoff.module.meeting.dto;
public class MeetingMaterialResubmitPreviewRequest {
private String contentJson;
private String remark;
public String getContentJson() {
return contentJson;
}
public void setContentJson(String contentJson) {
this.contentJson = contentJson;
}
public String getRemark() {
return remark;
}
public void setRemark(String remark) {
this.remark = remark;
}
}

View File

@ -2,33 +2,50 @@ package com.writeoff.module.meeting.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "会议列表筛选参数")
@Schema(description = "Meeting list query request")
public class MeetingQueryRequest {
@Schema(description = "项目ID")
@Schema(description = "Project ID")
private Long projectId;
@Schema(description = "项目名称(模糊匹配)")
@Schema(description = "Project name, fuzzy match")
private String projectName;
@Schema(description = "会议主题(模糊匹配)")
@Schema(description = "Meeting topic, fuzzy match")
private String topic;
@Schema(description = "会议状态NOT_STARTED/IN_PROGRESS/COMPLETED/CANCELED/DELAYED/FROZEN")
@Schema(description = "Meeting status")
private String meetingStatus;
@Schema(description = "会议审核状态PENDING/IN_REVIEW/APPROVED/REJECTED")
@Schema(description = "Meeting audit status")
private String auditStatus;
@Schema(description = "当前审核节点")
@Schema(description = "Current audit node")
private String currentAuditNode;
@Schema(description = "当前审核人用户ID")
@Schema(description = "Current auditor user ID")
private Long currentAuditorUserId;
@Schema(description = "会议开始时间范围-起格式yyyy-MM-dd HH:mm:ss")
@Schema(description = "Meeting start time from, format yyyy-MM-dd HH:mm:ss")
private String meetingStartFrom;
@Schema(description = "会议开始时间范围-止格式yyyy-MM-dd HH:mm:ss")
@Schema(description = "Meeting start time to, format yyyy-MM-dd HH:mm:ss")
private String meetingStartTo;
@Schema(description = "最后提交时间范围-起格式yyyy-MM-ddTHH:mm:ss")
@Schema(description = "Last submit time from, format yyyy-MM-ddTHH:mm:ss")
private String lastSubmitFrom;
@Schema(description = "最后提交时间范围-止格式yyyy-MM-ddTHH:mm:ss")
@Schema(description = "Last submit time to, format yyyy-MM-ddTHH:mm:ss")
private String lastSubmitTo;
@Schema(description = "是否包含已删除会议")
@Schema(description = "Whether to include deleted meetings")
private Boolean includeDeleted;
@Schema(description = "Page number, starts from 1")
private Integer pageNo;
@Schema(description = "Page size")
private Integer pageSize;
public Long getProjectId() {
return projectId;
}
@ -124,4 +141,20 @@ public class MeetingQueryRequest {
public void setIncludeDeleted(Boolean includeDeleted) {
this.includeDeleted = includeDeleted;
}
public Integer getPageNo() {
return pageNo;
}
public void setPageNo(Integer pageNo) {
this.pageNo = pageNo;
}
public Integer getPageSize() {
return pageSize;
}
public void setPageSize(Integer pageSize) {
this.pageSize = pageSize;
}
}

View File

@ -1,11 +1,13 @@
package com.writeoff.module.meeting.dto;
import javax.validation.constraints.NotBlank;
import java.util.List;
public class SubmitMeetingMaterialRequest {
@NotBlank(message = "资料内容不能为空")
private String contentJson;
private String remark;
private List<MeetingIssueResponseRequest> issueResponses;
public String getContentJson() {
return contentJson;
@ -22,4 +24,12 @@ public class SubmitMeetingMaterialRequest {
public void setRemark(String remark) {
this.remark = remark;
}
public List<MeetingIssueResponseRequest> getIssueResponses() {
return issueResponses;
}
public void setIssueResponses(List<MeetingIssueResponseRequest> issueResponses) {
this.issueResponses = issueResponses;
}
}

View File

@ -1,11 +1,13 @@
package com.writeoff.module.meeting.dto;
import javax.validation.constraints.NotBlank;
import java.util.List;
public class SubmitMeetingRequest {
@NotBlank(message = "幂等键不能为空")
private String idempotencyKey;
private String remark;
private List<MeetingIssueResponseRequest> issueResponses;
public String getIdempotencyKey() {
return idempotencyKey;
@ -22,4 +24,12 @@ public class SubmitMeetingRequest {
public void setRemark(String remark) {
this.remark = remark;
}
public List<MeetingIssueResponseRequest> getIssueResponses() {
return issueResponses;
}
public void setIssueResponses(List<MeetingIssueResponseRequest> issueResponses) {
this.issueResponses = issueResponses;
}
}

View File

@ -10,6 +10,7 @@ public class Meeting {
private Long projectId;
@Schema(description = "项目名称(展示字段)")
private String projectName;
private int laborAgreementSignType = 1;
@Schema(description = "会议主题")
private String topic;
@Schema(description = "会议类别")
@ -203,6 +204,10 @@ public class Meeting {
return projectName;
}
public int getLaborAgreementSignType() {
return laborAgreementSignType;
}
public String getTopic() {
return topic;
}
@ -347,6 +352,10 @@ public class Meeting {
this.projectName = projectName;
}
public void setLaborAgreementSignType(int laborAgreementSignType) {
this.laborAgreementSignType = laborAgreementSignType;
}
public void setCurrentAuditorUserId(Long currentAuditorUserId) {
this.currentAuditorUserId = currentAuditorUserId;
}

View File

@ -116,6 +116,7 @@ public class MeetingLaborAgreementExtractResult {
private String phone;
private String laborFeeText;
private Long laborFeeCent;
private Long laborFeePreTaxCent;
private String bankName;
private String bankCardNo;
private String accountName;
@ -162,6 +163,14 @@ public class MeetingLaborAgreementExtractResult {
this.laborFeeCent = laborFeeCent;
}
public Long getLaborFeePreTaxCent() {
return laborFeePreTaxCent;
}
public void setLaborFeePreTaxCent(Long laborFeePreTaxCent) {
this.laborFeePreTaxCent = laborFeePreTaxCent;
}
public String getBankName() {
return bankName;
}

View File

@ -15,6 +15,8 @@ public class MeetingMaterial {
private Integer versionNo;
private Boolean latestVersion;
private String updatedAt;
private String draftContentJson;
private String draftRemark;
public MeetingMaterial(Long id,
Long meetingId,
@ -30,6 +32,26 @@ public class MeetingMaterial {
Integer versionNo,
Boolean latestVersion,
String updatedAt) {
this(id, meetingId, moduleCode, contentJson, status, auditNodeStatus, auditAggregateStatus, submitRemark,
rejectCount, lastRejectReason, resubmitAt, versionNo, latestVersion, updatedAt, null, null);
}
public MeetingMaterial(Long id,
Long meetingId,
String moduleCode,
String contentJson,
String status,
String auditNodeStatus,
String auditAggregateStatus,
String submitRemark,
Integer rejectCount,
String lastRejectReason,
String resubmitAt,
Integer versionNo,
Boolean latestVersion,
String updatedAt,
String draftContentJson,
String draftRemark) {
this.id = id;
this.meetingId = meetingId;
this.moduleCode = moduleCode;
@ -44,6 +66,8 @@ public class MeetingMaterial {
this.versionNo = versionNo;
this.latestVersion = latestVersion;
this.updatedAt = updatedAt;
this.draftContentJson = draftContentJson;
this.draftRemark = draftRemark;
}
public Long getId() {
@ -101,4 +125,12 @@ public class MeetingMaterial {
public String getUpdatedAt() {
return updatedAt;
}
public String getDraftContentJson() {
return draftContentJson;
}
public String getDraftRemark() {
return draftRemark;
}
}

View File

@ -0,0 +1,62 @@
package com.writeoff.module.meeting.model;
public class MeetingSubmissionVersion {
private Long id;
private Long meetingId;
private Integer versionNo;
private String remark;
private String snapshotJson;
private Long createdBy;
private String createdByName;
private String createdAt;
public MeetingSubmissionVersion(Long id,
Long meetingId,
Integer versionNo,
String remark,
String snapshotJson,
Long createdBy,
String createdByName,
String createdAt) {
this.id = id;
this.meetingId = meetingId;
this.versionNo = versionNo;
this.remark = remark;
this.snapshotJson = snapshotJson;
this.createdBy = createdBy;
this.createdByName = createdByName;
this.createdAt = createdAt;
}
public Long getId() {
return id;
}
public Long getMeetingId() {
return meetingId;
}
public Integer getVersionNo() {
return versionNo;
}
public String getRemark() {
return remark;
}
public String getSnapshotJson() {
return snapshotJson;
}
public Long getCreatedBy() {
return createdBy;
}
public String getCreatedByName() {
return createdByName;
}
public String getCreatedAt() {
return createdAt;
}
}

View File

@ -57,6 +57,7 @@ public class JdbcMeetingRepository implements MeetingRepository {
rs.getString("invoice_config_json")
);
meeting.setProjectName(rs.getString("project_name"));
meeting.setLaborAgreementSignType(rs.getObject("labor_agreement_sign_type") == null ? 1 : rs.getInt("labor_agreement_sign_type"));
meeting.setDeleted(rs.getInt("is_deleted") == 1);
return meeting;
};
@ -198,7 +199,7 @@ public class JdbcMeetingRepository implements MeetingRepository {
@Override
public Optional<Meeting> findById(Long id) {
List<Meeting> list = jdbcTemplate.query(
"SELECT m.id, m.project_id, p.project_name, m.topic, m.meeting_category, m.meeting_form, m.location, " +
"SELECT m.id, m.project_id, p.project_name, p.labor_agreement_sign_type, m.topic, m.meeting_category, m.meeting_form, m.location, " +
"DATE_FORMAT(m.start_time, '%Y-%m-%d %H:%i:%s') AS start_time, DATE_FORMAT(m.end_time, '%Y-%m-%d %H:%i:%s') AS end_time, " +
"m.budget_cent, m.labor_ratio, m.catering_ratio, m.meeting_status, m.audit_status, m.current_audit_node, " +
"DATE_FORMAT(m.last_submit_at, '%Y-%m-%dT%H:%i:%s') AS last_submit_at, m.last_reject_reason, m.overdue_days, m.risk_flags_json, m.is_frozen, m.freeze_reason, " +
@ -217,7 +218,7 @@ public class JdbcMeetingRepository implements MeetingRepository {
public List<Meeting> findAll(boolean includeDeleted) {
String whereSql = includeDeleted ? "WHERE m.tenant_id=? " : "WHERE m.tenant_id=? AND m.is_deleted=0 ";
return jdbcTemplate.query(
"SELECT m.id, m.project_id, p.project_name, m.topic, m.meeting_category, m.meeting_form, m.location, " +
"SELECT m.id, m.project_id, p.project_name, p.labor_agreement_sign_type, m.topic, m.meeting_category, m.meeting_form, m.location, " +
"DATE_FORMAT(m.start_time, '%Y-%m-%d %H:%i:%s') AS start_time, DATE_FORMAT(m.end_time, '%Y-%m-%d %H:%i:%s') AS end_time, " +
"m.budget_cent, m.labor_ratio, m.catering_ratio, m.meeting_status, m.audit_status, m.current_audit_node, " +
"DATE_FORMAT(m.last_submit_at, '%Y-%m-%dT%H:%i:%s') AS last_submit_at, m.last_reject_reason, m.overdue_days, m.risk_flags_json, m.is_frozen, m.freeze_reason, " +

View File

@ -16,6 +16,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -64,7 +65,7 @@ public class MeetingExpertBindingService {
meetingService.getById(meetingId);
List<MeetingExpertBinding> beforeBindings = listByMeetingId(meetingId);
List<Long> rawIds = request.getExpertIds() == null ? new ArrayList<Long>() : request.getExpertIds();
Set<Long> idSet = new HashSet<Long>();
Set<Long> idSet = new LinkedHashSet<Long>();
for (Long id : rawIds) {
if (id != null && id > 0) {
idSet.add(id);

View File

@ -0,0 +1,210 @@
package com.writeoff.module.meeting.service;
import com.writeoff.module.meeting.dto.MeetingIssueResponseRequest;
import com.writeoff.security.AuthContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@Service
public class MeetingIssueResponseService {
private final JdbcTemplate jdbcTemplate;
public MeetingIssueResponseService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public List<Map<String, Object>> listOpenIssuesByMeetingId(Long meetingId) {
return listIssuesByMeetingId(meetingId, false);
}
public List<Map<String, Object>> listIssuesByMeetingId(Long meetingId) {
return listIssuesByMeetingId(meetingId, true);
}
public List<Map<String, Object>> listIssuesByMeetingAndModule(Long meetingId, String moduleCode) {
return filterIssuesByModule(listIssuesByMeetingId(meetingId), moduleCode);
}
public List<Map<String, Object>> listOpenIssuesByMeetingAndModule(Long meetingId, String moduleCode) {
return filterIssuesByModule(listOpenIssuesByMeetingId(meetingId), moduleCode);
}
private List<Map<String, Object>> listIssuesByMeetingId(Long meetingId, boolean includeResolved) {
if (meetingId == null || meetingId <= 0L) {
return new ArrayList<>();
}
String statusSql = includeResolved
? "ai.status IN ('OPEN', 'PENDING_CONFIRM', 'RESOLVED')"
: "ai.status IN ('OPEN', 'PENDING_CONFIRM')";
return jdbcTemplate.query(
"SELECT ai.id, ai.task_id, ai.meeting_id, ai.submission_version_id, ai.review_node, ai.module_code, ai.target_path, ai.target_label, ai.reason, ai.status, " +
"DATE_FORMAT(ai.created_at, '%Y-%m-%d %H:%i:%s') AS created_at, " +
"(SELECT ir.response_text FROM issue_response ir WHERE ir.tenant_id=ai.tenant_id AND ir.issue_id=ai.id ORDER BY ir.id DESC LIMIT 1) AS latest_response_text, " +
"(SELECT DATE_FORMAT(ir.responded_at, '%Y-%m-%d %H:%i:%s') FROM issue_response ir WHERE ir.tenant_id=ai.tenant_id AND ir.issue_id=ai.id ORDER BY ir.id DESC LIMIT 1) AS latest_responded_at " +
"FROM audit_issue ai " +
"WHERE ai.tenant_id=? AND ai.meeting_id=? AND " + statusSql + " AND ai.task_id=(" +
"SELECT at.id FROM audit_task at WHERE at.tenant_id=ai.tenant_id AND at.meeting_id=ai.meeting_id AND at.status='REJECTED' AND at.is_deleted=0 ORDER BY at.id DESC LIMIT 1" +
") ORDER BY ai.id ASC",
(rs, n) -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", rs.getLong("id"));
row.put("taskId", rs.getLong("task_id"));
row.put("meetingId", rs.getLong("meeting_id"));
row.put("submissionVersionId", rs.getObject("submission_version_id") == null ? null : rs.getLong("submission_version_id"));
row.put("reviewNode", rs.getString("review_node"));
row.put("moduleCode", rs.getString("module_code"));
row.put("targetPath", rs.getString("target_path"));
row.put("targetLabel", rs.getString("target_label"));
row.put("reason", rs.getString("reason"));
row.put("status", rs.getString("status"));
row.put("createdAt", rs.getString("created_at"));
row.put("latestResponseText", rs.getString("latest_response_text"));
row.put("latestRespondedAt", rs.getString("latest_responded_at"));
return row;
},
tenantId(),
meetingId
);
}
private List<Map<String, Object>> filterIssuesByModule(List<Map<String, Object>> all, String moduleCode) {
if (moduleCode == null || moduleCode.trim().isEmpty()) {
return all;
}
String normalized = moduleCode.trim().toUpperCase(Locale.ROOT);
List<Map<String, Object>> filtered = new ArrayList<>();
for (Map<String, Object> issue : all) {
String currentModuleCode = String.valueOf(issue.get("moduleCode") == null ? "" : issue.get("moduleCode")).trim().toUpperCase(Locale.ROOT);
if (normalized.equals(currentModuleCode)) {
filtered.add(issue);
}
}
return filtered;
}
@Transactional
public int saveMeetingResponses(Long meetingId, Long submissionVersionId, List<MeetingIssueResponseRequest> responses) {
List<Map<String, Object>> openIssues = listOpenIssuesByMeetingId(meetingId);
return saveResponses(openIssues, submissionVersionId, responses);
}
@Transactional
public int markMeetingIssuesResolved(Long meetingId) {
if (meetingId == null || meetingId <= 0L) {
return 0;
}
return jdbcTemplate.update(
"UPDATE audit_issue SET status='RESOLVED', updated_by=?, updated_at=CURRENT_TIMESTAMP " +
"WHERE tenant_id=? AND meeting_id=? AND status IN ('OPEN', 'PENDING_CONFIRM')",
safeUserId(),
tenantId(),
meetingId
);
}
public void validateResponsesRequired(Long meetingId, List<MeetingIssueResponseRequest> responses) {
// Response text is optional. Re-submit is judged by actual content changes.
}
public void validateResponsesRequired(Long meetingId, String moduleCode, List<MeetingIssueResponseRequest> responses) {
// Response text is optional. Re-submit is judged by actual content changes.
}
public int saveMeetingResponses(Long meetingId, String moduleCode, Long submissionVersionId, List<MeetingIssueResponseRequest> responses) {
List<Map<String, Object>> openIssues = listOpenIssuesByMeetingAndModule(meetingId, moduleCode);
return saveResponses(openIssues, submissionVersionId, responses);
}
@Transactional
public int closeIssuesForRejectedTask(Long taskId, String closeStatus) {
if (taskId == null || taskId <= 0L) {
return 0;
}
String normalizedStatus = str(closeStatus).toUpperCase();
if (normalizedStatus.isEmpty()) {
normalizedStatus = "RESOLVED";
}
return jdbcTemplate.update(
"UPDATE audit_issue SET status=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " +
"WHERE tenant_id=? AND task_id=? AND status IN ('OPEN', 'PENDING_CONFIRM')",
normalizedStatus,
safeUserId(),
tenantId(),
taskId
);
}
private int saveResponses(List<Map<String, Object>> openIssues, Long submissionVersionId, List<MeetingIssueResponseRequest> responses) {
if (openIssues.isEmpty() || responses == null || responses.isEmpty()) {
return 0;
}
Map<Long, Map<String, Object>> issueMap = new LinkedHashMap<>();
for (Map<String, Object> issue : openIssues) {
Number idNum = (Number) issue.get("id");
if (idNum != null) {
issueMap.put(idNum.longValue(), issue);
}
}
Map<Long, String> responseTextMap = new LinkedHashMap<>();
for (MeetingIssueResponseRequest response : responses) {
if (response == null || response.getIssueId() == null) {
continue;
}
String text = str(response.getResponseText());
if (!text.isEmpty()) {
responseTextMap.put(response.getIssueId(), text);
}
}
int count = 0;
for (Map.Entry<Long, String> entry : responseTextMap.entrySet()) {
Map<String, Object> issue = issueMap.get(entry.getKey());
if (issue == null) {
continue;
}
String responseText = str(entry.getValue());
if (responseText.isEmpty()) {
continue;
}
jdbcTemplate.update(
"INSERT INTO issue_response (tenant_id, issue_id, submission_version_id, response_text, response_status, responded_by, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, 'PENDING_CONFIRM', ?, ?, ?)",
tenantId(),
entry.getKey(),
submissionVersionId,
responseText,
safeUserId(),
safeUserId(),
safeUserId()
);
jdbcTemplate.update(
"UPDATE audit_issue SET status='PENDING_CONFIRM', response_text=?, responded_at=CURRENT_TIMESTAMP, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?",
responseText,
safeUserId(),
tenantId(),
entry.getKey()
);
count++;
}
return count;
}
private Long tenantId() {
return AuthContext.requireTenantId();
}
private Long safeUserId() {
Long userId = AuthContext.userId();
return userId == null ? 0L : userId;
}
private String str(String value) {
return value == null ? "" : value.trim();
}
}

View File

@ -1,4 +1,4 @@
package com.writeoff.module.meeting.service;
package com.writeoff.module.meeting.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@ -21,6 +21,8 @@ import com.writeoff.module.ocr.service.BaiduDocumentExtractService;
import com.writeoff.module.system.model.PlatformDictionaryItem;
import com.writeoff.module.system.service.PlatformDictionaryService;
import com.writeoff.security.AuthContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -36,16 +38,17 @@ import java.util.Set;
@Service
public class MeetingLaborAgreementExtractService {
private static final Logger log = LoggerFactory.getLogger(MeetingLaborAgreementExtractService.class);
private static final String MODULE_CODE = "EXPERT_LIST";
private static final String HOSPITAL_DICT_TYPE = "EXPERT_HOSPITAL";
private static final List<String> OCR_KEYS_NAME = asList("涔欐柟");
private static final List<String> OCR_KEYS_HOSPITAL = asList("宸ヤ綔鍗曚綅");
private static final List<String> OCR_KEYS_PHONE = asList("鑱旂郴鐢佃瘽");
private static final List<String> OCR_KEYS_FEE = asList("鍔冲姟璐?);
private static final List<String> OCR_KEYS_BANK_NAME = asList("寮€鎴烽摱琛?);
private static final List<String> OCR_KEYS_BANK_CARD = asList("寮€鎴峰笎鍙?, "鎴疯处鍙?);
private static final List<String> OCR_KEYS_ID_NO = asList("韬唤璇佸彿鐮?, "唤璇佸彿");
private static final List<String> OCR_KEYS_ACCOUNT_NAME = asList("璐︽埛鍚?);
private static final List<String> OCR_KEYS_NAME = asList("乙方");
private static final List<String> OCR_KEYS_HOSPITAL = asList("工作单位");
private static final List<String> OCR_KEYS_PHONE = asList("联系电话");
private static final List<String> OCR_KEYS_FEE = asList("劳务费");
private static final List<String> OCR_KEYS_BANK_NAME = asList("开户银行");
private static final List<String> OCR_KEYS_BANK_CARD = asList("开户帐号", "开户账号");
private static final List<String> OCR_KEYS_ID_NO = asList("身份证号码");
private static final List<String> OCR_KEYS_ACCOUNT_NAME = asList("账户名", "帐户名");
private final MeetingService meetingService;
private final BaiduDocumentExtractService documentExtractService;
@ -88,20 +91,48 @@ public class MeetingLaborAgreementExtractService {
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> apply(Long meetingId, MeetingLaborAgreementExtractApplyRequest request) {
assertMeetingEditable(meetingId);
log.info("meeting labor agreement apply start, meetingId={}, taskId={}, requestedExpertId={}, updateExisting={}, objectKey={}, fileName={}",
meetingId,
trimToEmpty(request.getTaskId()),
request.getExistingExpertId(),
request.getUpdateExistingExpert(),
trimToEmpty(request.getObjectKey()),
trimToEmpty(request.getFileName()));
MeetingLaborAgreementExtractResult result = buildResult(documentExtractService.queryTask(request.getTaskId()));
log.info("meeting labor agreement apply query result, meetingId={}, taskId={}, status={}, reason={}",
meetingId,
trimToEmpty(request.getTaskId()),
trimToEmpty(result.getStatus()),
trimToEmpty(result.getReason()));
if (!"Success".equalsIgnoreCase(trimToEmpty(result.getStatus()))) {
throw new BusinessException(ErrorCodes.INVALID_STATE, "OCR浠诲姟鏈畬鎴愶紝涓嶈兘搴旂敤");
throw new BusinessException(ErrorCodes.INVALID_STATE, "OCR任务未完成,不能应用");
}
MeetingLaborAgreementExtractResult.ParsedExpert parsed = result.getParsedExpert();
if (parsed == null) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "鏈В鏋愬埌涓撳淇℃伅");
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "未解析到专家信息");
}
log.info("meeting labor agreement parsed expert, meetingId={}, taskId={}, expertName={}, idNo={}, phone={}, laborFeeCent={}, bankName={}, bankCardNo={}, accountName={}, nameMismatchFlag={}",
meetingId,
trimToEmpty(request.getTaskId()),
trimToEmpty(parsed.getExpertName()),
trimToEmpty(parsed.getIdNo()),
trimToEmpty(parsed.getPhone()),
parsed.getLaborFeeCent(),
trimToEmpty(parsed.getBankName()),
trimToEmpty(parsed.getBankCardNo()),
trimToEmpty(parsed.getAccountName()),
parsed.getNameMismatchFlag());
String idNo = trimToNull(parsed.getIdNo());
if (idNo == null) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "鏈瘑鍒埌韬唤璇佸彿鐮?);
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "未识别到身份证号码");
}
ExpertInfo existing = platformExpertService.findByExactIdNo(idNo);
log.info("meeting labor agreement existing expert lookup, meetingId={}, taskId={}, matchedExistingExpertId={}, matchedExistingExpertName={}",
meetingId,
trimToEmpty(request.getTaskId()),
existing == null ? null : existing.getId(),
existing == null ? "" : trimToEmpty(existing.getExpertName()));
Long requestedExpertId = request.getExistingExpertId();
boolean updateExisting = Boolean.TRUE.equals(request.getUpdateExistingExpert());
ExpertInfo targetExpert;
@ -110,10 +141,10 @@ public class MeetingLaborAgreementExtractService {
if (existing != null) {
if (!updateExisting) {
throw new BusinessException(ErrorCodes.INVALID_STATE, "宸插瓨鍦ㄥ悓韬唤璇佷笓瀹讹紝璇风‘璁ゆ槸鍚﹀鐢ㄥ苟鏇存柊");
throw new BusinessException(ErrorCodes.INVALID_STATE, "已存在同身份证专家,请确认是否复用并更新");
}
if (requestedExpertId == null || !existing.getId().equals(requestedExpertId)) {
throw new BusinessException(ErrorCodes.INVALID_STATE, "纭涓撳涓庣郴缁熷尮閰嶇粨鏋滀笉涓€鑷?);
throw new BusinessException(ErrorCodes.INVALID_STATE, "确认专家与系统匹配结果不一致");
}
targetExpert = updateExistingExpert(existing, parsed);
updated = true;
@ -125,6 +156,14 @@ public class MeetingLaborAgreementExtractService {
upsertBankCard(targetExpert.getId(), parsed);
bindExpertToMeeting(meetingId, targetExpert.getId());
saveLaborToMeeting(meetingId, targetExpert, parsed, request.getObjectKey(), request.getFileName());
log.info("meeting labor agreement apply success, meetingId={}, taskId={}, expertId={}, createdExpert={}, updatedExpert={}, protocolObjectKey={}, protocolFileName={}",
meetingId,
trimToEmpty(request.getTaskId()),
targetExpert.getId(),
created,
updated,
trimToEmpty(request.getObjectKey()),
trimToEmpty(request.getFileName()));
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("taskId", request.getTaskId());
@ -184,6 +223,7 @@ public class MeetingLaborAgreementExtractService {
String laborFeeText = firstWord(singleKey, OCR_KEYS_FEE);
expert.setLaborFeeText(laborFeeText);
expert.setLaborFeeCent(parseAmountCent(laborFeeText));
expert.setLaborFeePreTaxCent(calculateByAfterTaxCent(expert.getLaborFeeCent()));
expert.setBankName(firstWord(singleKey, OCR_KEYS_BANK_NAME));
expert.setBankCardNo(normalizeBankCardNo(firstWord(singleKey, OCR_KEYS_BANK_CARD)));
expert.setAccountName(firstWord(singleKey, OCR_KEYS_ACCOUNT_NAME));
@ -238,7 +278,9 @@ public class MeetingLaborAgreementExtractService {
request.setIsDefault(Boolean.TRUE);
boolean mismatch = Boolean.TRUE.equals(parsed.getNameMismatchFlag());
request.setInconsistentNameApproved(mismatch ? Boolean.TRUE : Boolean.FALSE);
request.setChangeReason(mismatch ? "鍔冲姟鍗忚OCR璇嗗埆鍒拌处鎴峰悕涓庝箼鏂逛笉涓€鑷达紝宸叉墦鏍囦繚瀛? : "鍔冲姟鍗忚OCR鑷姩瀵煎叆");
request.setChangeReason(mismatch
? "劳务协议OCR识别到账户名与乙方不一致已打标保存"
: "劳务协议OCR自动导入");
platformExpertService.addOrUpdateDefaultCard(expertId, request);
}
@ -274,6 +316,11 @@ public class MeetingLaborAgreementExtractService {
if (!(invoiceDetail.get("invoices") instanceof List)) {
invoiceDetail.put("invoices", new ArrayList<Object>());
}
log.info("meeting labor agreement save to material start, meetingId={}, expertId={}, currentContentLength={}, currentDetailCount={}",
meetingId,
expert.getId(),
contentJson == null ? 0 : contentJson.length(),
details.size());
long expertId = expert.getId() == null ? 0L : expert.getId();
Map<String, Object> targetRow = null;
@ -309,12 +356,26 @@ public class MeetingLaborAgreementExtractService {
if (!(targetRow.get("invoiceFiles") instanceof List)) {
targetRow.put("invoiceFiles", new ArrayList<Object>());
}
targetRow.put("amountCent", parsed.getLaborFeeCent() == null ? 0L : parsed.getLaborFeeCent());
long afterTaxAmountCent = parsed.getLaborFeeCent() == null ? 0L : parsed.getLaborFeeCent();
long preTaxAmountCent = parsed.getLaborFeePreTaxCent() == null ? calculateByAfterTaxCent(afterTaxAmountCent) : parsed.getLaborFeePreTaxCent();
targetRow.put("amountCent", preTaxAmountCent);
targetRow.put("preTaxAmountCent", preTaxAmountCent);
targetRow.put("afterTaxAmountCent", afterTaxAmountCent);
targetRow.put("preTaxAmountSource", "LABOR_AGREEMENT_OCR");
targetRow.put("afterTaxAmountSource", "LABOR_AGREEMENT_OCR");
log.info("meeting labor agreement material row prepared, meetingId={}, expertId={}, detailCount={}, protocolOssKey={}, protocolFileName={}, amountCent={}, invoiceFileCount={}",
meetingId,
expertId,
details.size(),
trimToEmpty(protocolFile.get("ossKey")),
trimToEmpty(protocolFile.get("fileName")),
targetRow.get("afterTaxAmountCent"),
((List<?>) targetRow.get("invoiceFiles")).size());
String remark = Boolean.TRUE.equals(parsed.getNameMismatchFlag())
? "OCR璇嗗埆鎻愮ず锛氫箼鏂逛笌璐︽埛鍚嶄笉涓€鑷达紝璇蜂汉宸ュ鏍?
? "OCR识别提示:乙方与账户名不一致,请人工复核"
: "";
targetRow.put("remark", remark);
meetingMaterialService.saveRawContent(meetingId, MODULE_CODE, toJson(root), "鍔冲姟鍗忚OCR鑷姩瀵煎叆");
meetingMaterialService.saveRawContent(meetingId, MODULE_CODE, toJson(root), "劳务协议OCR自动导入");
}
private PlatformDictionaryItem ensureHospitalDictionary(String hospitalName) {
@ -326,27 +387,27 @@ public class MeetingLaborAgreementExtractService {
if (existing != null) {
return existing;
}
return platformDictionaryService.createEnabledItem(HOSPITAL_DICT_TYPE, name, "AUTO_HOSPITAL", "鍔冲姟鍗忚OCR鑷姩鍒涘缓");
return platformDictionaryService.createEnabledItem(HOSPITAL_DICT_TYPE, name, "AUTO_HOSPITAL", "劳务协议OCR自动创建");
}
private void assertMeetingEditable(Long meetingId) {
Meeting meeting = meetingService.getById(meetingId);
MeetingAuditStatus auditStatus = meeting.getAuditStatus();
if (auditStatus == MeetingAuditStatus.IN_REVIEW || auditStatus == MeetingAuditStatus.APPROVED) {
throw new BusinessException(ErrorCodes.INVALID_STATE, "璇ヤ細璁祫鏂欏鏍镐腑鎴栧凡瀹℃牳閫氳繃锛屼笉鍏佽鍐嶄慨鏀?);
throw new BusinessException(ErrorCodes.INVALID_STATE, "该会议资料审核中或已审核通过,不允许再修改");
}
}
private List<DocumentExtractTaskSubmitRequest.ManifestField> buildManifest() {
List<DocumentExtractTaskSubmitRequest.ManifestField> list = new ArrayList<DocumentExtractTaskSubmitRequest.ManifestField>();
list.add(manifestField("涔欐柟"));
list.add(manifestField("宸ヤ綔鍗曚綅"));
list.add(manifestField("鑱旂郴鐢佃瘽"));
list.add(manifestField("鍔冲姟璐?));
list.add(manifestField("寮€鎴烽摱琛?));
list.add(manifestField("寮€鎴峰笎鍙?));
list.add(manifestField("璐︽埛鍚?));
list.add(manifestField("韬唤璇佸彿鐮?));
list.add(manifestField("乙方"));
list.add(manifestField("工作单位"));
list.add(manifestField("联系电话"));
list.add(manifestField("劳务费"));
list.add(manifestField("开户银行"));
list.add(manifestField("开户帐号"));
list.add(manifestField("账户名"));
list.add(manifestField("身份证号码"));
return list;
}
@ -368,6 +429,25 @@ public class MeetingLaborAgreementExtractService {
return list;
}
private long calculateByAfterTaxCent(Long afterTaxCent) {
long normalized = afterTaxCent == null ? 0L : Math.max(0L, afterTaxCent);
if (normalized <= 0L) {
return 0L;
}
double afterTax = normalized / 100D;
double preTax;
if (afterTax <= 3360D) {
preTax = (afterTax - 160D) / 0.8D;
} else if (afterTax <= 21000D) {
preTax = afterTax / 0.84D;
} else if (afterTax <= 49500D) {
preTax = (afterTax - 2000D) / 0.76D;
} else {
preTax = (afterTax - 7000D) / 0.68D;
}
return Math.max(0L, Math.round(Math.round(preTax * 100D) / 100D * 100D));
}
private String firstWord(Map<String, Object> singleKey, List<String> keys) {
for (String key : keys) {
Object rowsObj = singleKey.get(key);
@ -392,7 +472,11 @@ public class MeetingLaborAgreementExtractService {
if (raw == null) {
return 0L;
}
String normalized = raw.replace(",", "").replace("锛?, "").replace("?, "").replace("浜烘皯甯?, "").trim();
String normalized = raw.replace(",", "")
.replace("", "")
.replace("", "")
.replace("人民币", "")
.trim();
if (normalized.isEmpty()) {
return 0L;
}
@ -433,7 +517,7 @@ public class MeetingLaborAgreementExtractService {
try {
return objectMapper.writeValueAsString(value);
} catch (Exception ex) {
throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "JSON搴忓垪鍖栧け璐?);
throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "JSON序列化失败");
}
}
@ -441,10 +525,8 @@ public class MeetingLaborAgreementExtractService {
Map<String, Object> value = asMap(parent.get(key));
if (value.isEmpty() && !(parent.get(key) instanceof Map)) {
value = new LinkedHashMap<String, Object>();
parent.put(key, value);
} else if (!(parent.get(key) instanceof Map)) {
parent.put(key, value);
}
parent.put(key, value);
return value;
}
@ -537,5 +619,3 @@ public class MeetingLaborAgreementExtractService {
return text.isEmpty() ? null : text;
}
}

View File

@ -0,0 +1,718 @@
package com.writeoff.module.meeting.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.writeoff.common.exception.BusinessException;
import com.writeoff.common.exception.ErrorCodes;
import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.DataFormat;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@Service
public class MeetingLaborSummaryExportService {
private static final long PLATFORM_TENANT_ID = 0L;
private static final String BASIC_INFO_MODULE_CODE = "BASIC_INFO";
private static final String EXPERT_LIST_MODULE_CODE = "EXPERT_LIST";
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final List<String> HEADERS = Arrays.asList(
"序号",
"银行(开户行)",
"账号所在省份",
"账号所在地市",
"卡号",
"姓名",
"实发",
"备注",
"个税",
"应发",
"单位",
"电话",
"身份证号",
"科室",
"任务"
);
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper = new ObjectMapper();
public MeetingLaborSummaryExportService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public byte[] buildWorkbook(Long tenantId, Long meetingId) {
Map<String, Object> meeting = findMeeting(tenantId, meetingId);
Map<String, String> materialJsonByCode = queryMeetingMaterialJsonByCode(tenantId, meetingId);
Map<String, Object> expertList = parseJsonObject(materialJsonByCode.get(EXPERT_LIST_MODULE_CODE));
Map<String, Object> basicInfo = parseJsonObject(materialJsonByCode.get(BASIC_INFO_MODULE_CODE));
List<LaborSummaryRow> rows = buildRows(tenantId, meetingId, meeting, expertList, basicInfo);
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("劳务汇总表");
configureSheet(sheet);
Styles styles = createStyles(workbook);
int rowIndex = 0;
Row titleRow = sheet.createRow(rowIndex++);
titleRow.setHeightInPoints(28F);
createTextCell(titleRow, 0, buildTitle(meeting), styles.title);
for (int i = 1; i < HEADERS.size(); i++) {
createTextCell(titleRow, i, "", styles.title);
}
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, HEADERS.size() - 1));
Row headerRow = sheet.createRow(rowIndex++);
headerRow.setHeightInPoints(24F);
for (int i = 0; i < HEADERS.size(); i++) {
createTextCell(headerRow, i, HEADERS.get(i), styles.header);
}
int dataStartExcelRow = rowIndex + 1;
for (LaborSummaryRow item : rows) {
Row row = sheet.createRow(rowIndex++);
int col = 0;
createTextCell(row, col++, String.valueOf(item.sequenceNo), styles.text);
createTextCell(row, col++, item.bankName, styles.text);
createTextCell(row, col++, item.bankProvince, styles.text);
createTextCell(row, col++, item.bankCity, styles.text);
createTextCell(row, col++, item.bankCardNo, styles.text);
createTextCell(row, col++, item.name, styles.text);
createNumberCell(row, col++, item.netAmountYuan, styles.amount);
createTextCell(row, col++, item.remark, styles.text);
createNumberCell(row, col++, item.taxAmountYuan, styles.amount);
createNumberCell(row, col++, item.grossAmountYuan, styles.amount);
createTextCell(row, col++, item.organization, styles.text);
createTextCell(row, col++, item.phone, styles.text);
createTextCell(row, col++, item.idNo, styles.text);
createTextCell(row, col++, item.department, styles.text);
createTextCell(row, col, item.task, styles.text);
}
Row totalRow = sheet.createRow(rowIndex);
createTextCell(totalRow, 0, "", styles.total);
createTextCell(totalRow, 1, "共计", styles.total);
for (int i = 2; i <= 5; i++) {
createTextCell(totalRow, i, "", styles.total);
}
sheet.addMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 1, 5));
if (rows.isEmpty()) {
createNumberCell(totalRow, 6, 0D, styles.totalAmount);
createTextCell(totalRow, 7, "", styles.total);
createNumberCell(totalRow, 8, 0D, styles.totalAmount);
createNumberCell(totalRow, 9, 0D, styles.totalAmount);
} else {
int dataEndExcelRow = rowIndex;
createFormulaCell(totalRow, 6, "SUM(G" + dataStartExcelRow + ":G" + dataEndExcelRow + ")", styles.totalAmount);
createTextCell(totalRow, 7, "", styles.total);
createFormulaCell(totalRow, 8, "SUM(I" + dataStartExcelRow + ":I" + dataEndExcelRow + ")", styles.totalAmount);
createFormulaCell(totalRow, 9, "SUM(J" + dataStartExcelRow + ":J" + dataEndExcelRow + ")", styles.totalAmount);
}
for (int i = 10; i < HEADERS.size(); i++) {
createTextCell(totalRow, i, "", styles.total);
}
workbook.write(outputStream);
return outputStream.toByteArray();
} catch (IOException ex) {
throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "劳务汇总表生成失败");
}
}
private Map<String, Object> findMeeting(Long tenantId, Long meetingId) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, topic, location, DATE_FORMAT(start_time, '%Y-%m-%d %H:%i:%s') AS start_time " +
"FROM meeting WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1",
tenantId,
meetingId
);
if (rows.isEmpty()) {
throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在");
}
return rows.get(0);
}
private Map<String, String> queryMeetingMaterialJsonByCode(Long tenantId, Long meetingId) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT module_code, content_json FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND is_deleted=0",
tenantId,
meetingId
);
Map<String, String> result = new LinkedHashMap<String, String>();
for (Map<String, Object> row : rows) {
String moduleCode = stringValue(row.get("module_code"));
if (!moduleCode.isEmpty()) {
result.put(moduleCode, stringValue(row.get("content_json")));
}
}
return result;
}
private List<LaborSummaryRow> buildRows(Long tenantId,
Long meetingId,
Map<String, Object> meeting,
Map<String, Object> expertList,
Map<String, Object> basicInfo) {
Map<String, Object> laborProtocol = mapValue(expertList.get("laborProtocol"));
List<Map<String, Object>> details = listOfMap(laborProtocol.get("details"));
if (details.isEmpty()) {
return Collections.emptyList();
}
List<Long> expertIds = extractExpertIds(details);
Map<Long, MeetingExpertSnapshot> meetingExperts = queryMeetingExpertSnapshots(tenantId, meetingId);
Map<Long, PlatformExpertSnapshot> platformExperts = queryPlatformExpertSnapshots(expertIds);
Map<Long, BankCardSnapshot> bankCards = queryDefaultBankCards(expertIds);
Map<Long, String> taskMap = buildTaskMap(basicInfo);
String remark = buildRemark(meeting);
List<LaborSummaryRow> rows = new ArrayList<LaborSummaryRow>();
int sequenceNo = 1;
for (Map<String, Object> detail : details) {
Long expertId = longValue(detail.get("expertId"));
MeetingExpertSnapshot meetingExpert = expertId == null ? null : meetingExperts.get(expertId);
PlatformExpertSnapshot platformExpert = expertId == null ? null : platformExperts.get(expertId);
BankCardSnapshot bankCard = expertId == null ? null : bankCards.get(expertId);
long netAmountCent = firstPositiveLong(detail.get("afterTaxAmountCent"));
long grossAmountCent = firstPositiveLong(detail.get("preTaxAmountCent"), detail.get("amountCent"), detail.get("afterTaxAmountCent"));
long taxAmountCent = Math.max(0L, grossAmountCent - netAmountCent);
LaborSummaryRow row = new LaborSummaryRow();
row.sequenceNo = sequenceNo++;
row.bankName = buildBankName(bankCard);
row.bankProvince = bankCard == null ? "" : bankCard.bankProvince;
row.bankCity = bankCard == null ? "" : bankCard.bankCity;
row.bankCardNo = bankCard == null ? "" : bankCard.bankCardNo;
row.name = firstNonEmpty(
bankCard == null ? "" : bankCard.accountName,
stringValue(detail.get("expertName")),
meetingExpert == null ? "" : meetingExpert.expertName,
platformExpert == null ? "" : platformExpert.expertName
);
row.netAmountYuan = centToYuan(netAmountCent);
row.remark = remark;
row.taxAmountYuan = centToYuan(taxAmountCent);
row.grossAmountYuan = centToYuan(grossAmountCent);
row.organization = firstNonEmpty(
platformExpert == null ? "" : platformExpert.organization,
meetingExpert == null ? "" : meetingExpert.organization
);
row.phone = firstNonEmpty(
platformExpert == null ? "" : platformExpert.phone,
meetingExpert == null ? "" : meetingExpert.phone
);
row.idNo = platformExpert == null ? "" : platformExpert.idNo;
row.department = "";
row.task = expertId == null ? "" : stringValue(taskMap.get(expertId));
rows.add(row);
}
return rows;
}
private Map<Long, MeetingExpertSnapshot> queryMeetingExpertSnapshots(Long tenantId, Long meetingId) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT expert_id, expert_name, phone, organization FROM meeting_expert_binding " +
"WHERE tenant_id=? AND meeting_id=? ORDER BY id ASC",
tenantId,
meetingId
);
Map<Long, MeetingExpertSnapshot> result = new LinkedHashMap<Long, MeetingExpertSnapshot>();
for (Map<String, Object> row : rows) {
Long expertId = longValue(row.get("expert_id"));
if (expertId == null || expertId <= 0L || result.containsKey(expertId)) {
continue;
}
result.put(expertId, new MeetingExpertSnapshot(
expertId,
stringValue(row.get("expert_name")),
stringValue(row.get("phone")),
stringValue(row.get("organization"))
));
}
return result;
}
private Map<Long, PlatformExpertSnapshot> queryPlatformExpertSnapshots(List<Long> expertIds) {
if (expertIds.isEmpty()) {
return Collections.emptyMap();
}
List<Object> args = new ArrayList<Object>();
args.add(PLATFORM_TENANT_ID);
args.addAll(expertIds);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT e.id, e.expert_name, e.phone, e.id_no, IFNULL(dh.dict_name, e.organization) AS hospital_name " +
"FROM expert e " +
"LEFT JOIN platform_dictionary_item dh ON dh.dict_type='EXPERT_HOSPITAL' AND dh.dict_code=e.hospital_code AND dh.is_deleted=0 " +
"WHERE e.tenant_id=? AND e.is_deleted=0 AND e.id IN (" + placeholders(expertIds.size()) + ")",
args.toArray()
);
Map<Long, PlatformExpertSnapshot> result = new LinkedHashMap<Long, PlatformExpertSnapshot>();
for (Map<String, Object> row : rows) {
Long expertId = longValue(row.get("id"));
if (expertId == null || expertId <= 0L) {
continue;
}
result.put(expertId, new PlatformExpertSnapshot(
expertId,
stringValue(row.get("expert_name")),
stringValue(row.get("phone")),
stringValue(row.get("id_no")),
stringValue(row.get("hospital_name"))
));
}
return result;
}
private Map<Long, BankCardSnapshot> queryDefaultBankCards(List<Long> expertIds) {
if (expertIds.isEmpty()) {
return Collections.emptyMap();
}
List<Object> args = new ArrayList<Object>();
args.add(PLATFORM_TENANT_ID);
args.addAll(expertIds);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT expert_id, bank_name, bank_province, bank_city, bank_branch_name, bank_card_no, account_name, is_default " +
"FROM expert_bank_card " +
"WHERE tenant_id=? AND is_deleted=0 AND expert_id IN (" + placeholders(expertIds.size()) + ") " +
"ORDER BY expert_id ASC, CASE WHEN is_default='Y' THEN 0 ELSE 1 END ASC, id DESC",
args.toArray()
);
Map<Long, BankCardSnapshot> result = new LinkedHashMap<Long, BankCardSnapshot>();
for (Map<String, Object> row : rows) {
Long expertId = longValue(row.get("expert_id"));
if (expertId == null || expertId <= 0L || result.containsKey(expertId)) {
continue;
}
result.put(expertId, new BankCardSnapshot(
expertId,
stringValue(row.get("bank_name")),
stringValue(row.get("bank_province")),
stringValue(row.get("bank_city")),
stringValue(row.get("bank_branch_name")),
stringValue(row.get("bank_card_no")),
stringValue(row.get("account_name"))
));
}
return result;
}
private Map<Long, String> buildTaskMap(Map<String, Object> basicInfo) {
Map<Long, LinkedHashSet<String>> taskSets = new LinkedHashMap<Long, LinkedHashSet<String>>();
mergeTask(taskSets, basicInfo.get("chairmanExpertIds"), "主席");
mergeTask(taskSets, basicInfo.get("speakerExpertIds"), "讲者");
mergeTask(taskSets, basicInfo.get("hostExpertIds"), "主持");
mergeTask(taskSets, basicInfo.get("discussionGuestExpertIds"), "讨论嘉宾");
Map<Long, String> result = new LinkedHashMap<Long, String>();
for (Map.Entry<Long, LinkedHashSet<String>> entry : taskSets.entrySet()) {
result.put(entry.getKey(), String.join("", entry.getValue()));
}
return result;
}
private void mergeTask(Map<Long, LinkedHashSet<String>> taskSets, Object rawIds, String label) {
for (Long expertId : parseIdList(rawIds)) {
if (expertId == null || expertId <= 0L) {
continue;
}
LinkedHashSet<String> labels = taskSets.get(expertId);
if (labels == null) {
labels = new LinkedHashSet<String>();
taskSets.put(expertId, labels);
}
labels.add(label);
}
}
private List<Long> parseIdList(Object raw) {
if (raw == null) {
return Collections.emptyList();
}
List<Long> result = new ArrayList<Long>();
if (raw instanceof Collection) {
for (Object item : (Collection<?>) raw) {
Long value = longValue(item);
if (value != null) {
result.add(value);
}
}
return result;
}
String text = stringValue(raw);
if (text.isEmpty()) {
return result;
}
for (String item : text.split(",")) {
Long value = longValue(item);
if (value != null) {
result.add(value);
}
}
return result;
}
private List<Long> extractExpertIds(List<Map<String, Object>> details) {
LinkedHashSet<Long> ids = new LinkedHashSet<Long>();
for (Map<String, Object> detail : details) {
Long expertId = longValue(detail.get("expertId"));
if (expertId != null && expertId > 0L) {
ids.add(expertId);
}
}
return new ArrayList<Long>(ids);
}
private String buildTitle(Map<String, Object> meeting) {
String topic = stringValue(meeting.get("topic"));
if (topic.isEmpty()) {
topic = "会议";
}
return "" + topic + "” 劳务费信息表";
}
private String buildRemark(Map<String, Object> meeting) {
String monthDay = formatMonthDay(stringValue(meeting.get("start_time")));
String topic = stringValue(meeting.get("topic"));
String location = stringValue(meeting.get("location"));
StringBuilder builder = new StringBuilder();
if (!monthDay.isEmpty()) {
builder.append(monthDay);
}
if (!topic.isEmpty()) {
builder.append(topic);
}
if (builder.length() > 0 && !location.isEmpty()) {
builder.append("-").append(location);
} else if (builder.length() == 0) {
builder.append(location);
}
return builder.toString();
}
private String formatMonthDay(String startTime) {
if (startTime == null || startTime.trim().isEmpty()) {
return "";
}
try {
LocalDateTime dateTime = LocalDateTime.parse(startTime.trim(), DATE_TIME_FORMATTER);
return dateTime.getMonthValue() + "." + dateTime.getDayOfMonth();
} catch (Exception ignored) {
}
try {
LocalDate date = LocalDate.parse(startTime.trim(), DATE_FORMATTER);
return date.getMonthValue() + "." + date.getDayOfMonth();
} catch (Exception ignored) {
return "";
}
}
private void configureSheet(Sheet sheet) {
int[] widths = new int[] {8, 24, 14, 14, 20, 12, 12, 32, 12, 12, 28, 16, 22, 14, 14, 18, 14, 14};
for (int i = 0; i < widths.length; i++) {
sheet.setColumnWidth(i, widths[i] * 256);
}
sheet.createFreezePane(0, 2);
sheet.setDefaultRowHeightInPoints(22F);
}
private Styles createStyles(XSSFWorkbook workbook) {
short greyFill = IndexedColors.GREY_25_PERCENT.getIndex();
DataFormat dataFormat = workbook.createDataFormat();
short amountFormat = dataFormat.getFormat("0.00");
Font titleFont = workbook.createFont();
titleFont.setBold(true);
titleFont.setFontHeightInPoints((short) 14);
Font boldFont = workbook.createFont();
boldFont.setBold(true);
CellStyle title = workbook.createCellStyle();
applyBorder(title);
title.setAlignment(HorizontalAlignment.CENTER);
title.setVerticalAlignment(VerticalAlignment.CENTER);
title.setFont(titleFont);
CellStyle header = workbook.createCellStyle();
applyBorder(header);
header.setAlignment(HorizontalAlignment.CENTER);
header.setVerticalAlignment(VerticalAlignment.CENTER);
header.setFillForegroundColor(greyFill);
header.setFillPattern(FillPatternType.SOLID_FOREGROUND);
header.setFont(boldFont);
CellStyle text = workbook.createCellStyle();
applyBorder(text);
text.setVerticalAlignment(VerticalAlignment.CENTER);
CellStyle amount = workbook.createCellStyle();
amount.cloneStyleFrom(text);
amount.setAlignment(HorizontalAlignment.RIGHT);
amount.setDataFormat(amountFormat);
CellStyle total = workbook.createCellStyle();
applyBorder(total);
total.setVerticalAlignment(VerticalAlignment.CENTER);
total.setFillForegroundColor(greyFill);
total.setFillPattern(FillPatternType.SOLID_FOREGROUND);
total.setFont(boldFont);
CellStyle totalAmount = workbook.createCellStyle();
totalAmount.cloneStyleFrom(total);
totalAmount.setAlignment(HorizontalAlignment.RIGHT);
totalAmount.setDataFormat(amountFormat);
return new Styles(title, header, text, amount, total, totalAmount);
}
private void applyBorder(CellStyle style) {
style.setBorderTop(BorderStyle.THIN);
style.setBorderBottom(BorderStyle.THIN);
style.setBorderLeft(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
}
private void createTextCell(Row row, int columnIndex, String value, CellStyle style) {
Cell cell = row.createCell(columnIndex);
cell.setCellValue(value == null ? "" : value);
cell.setCellStyle(style);
}
private void createNumberCell(Row row, int columnIndex, double value, CellStyle style) {
Cell cell = row.createCell(columnIndex);
cell.setCellValue(value);
cell.setCellStyle(style);
}
private void createFormulaCell(Row row, int columnIndex, String formula, CellStyle style) {
Cell cell = row.createCell(columnIndex);
cell.setCellFormula(formula);
cell.setCellStyle(style);
}
private String buildBankName(BankCardSnapshot bankCard) {
if (bankCard == null) {
return "";
}
String bankName = stringValue(bankCard.bankName);
String branchName = stringValue(bankCard.bankBranchName);
if (branchName.isEmpty()) {
return bankName;
}
if (bankName.isEmpty()) {
return branchName;
}
if (branchName.replace(" ", "").contains(bankName.replace(" ", ""))) {
return branchName;
}
return bankName + branchName;
}
private double centToYuan(long cent) {
return Math.max(0L, cent) / 100D;
}
private long firstPositiveLong(Object... values) {
if (values == null) {
return 0L;
}
for (Object value : values) {
Long parsed = longValue(value);
if (parsed != null && parsed > 0L) {
return parsed;
}
}
return 0L;
}
private String firstNonEmpty(String... values) {
if (values == null) {
return "";
}
for (String value : values) {
String text = stringValue(value);
if (!text.isEmpty()) {
return text;
}
}
return "";
}
private String placeholders(int count) {
return String.join(",", Collections.nCopies(count, "?"));
}
private Map<String, Object> parseJsonObject(String json) {
if (json == null || json.trim().isEmpty()) {
return new LinkedHashMap<String, Object>();
}
try {
return objectMapper.readValue(json, new TypeReference<Map<String, Object>>() {});
} catch (Exception ex) {
return new LinkedHashMap<String, Object>();
}
}
private List<Map<String, Object>> listOfMap(Object value) {
if (!(value instanceof List)) {
return Collections.emptyList();
}
List<Map<String, Object>> result = new ArrayList<Map<String, Object>>();
for (Object item : (List<?>) value) {
if (item instanceof Map) {
result.add((Map<String, Object>) item);
}
}
return result;
}
private Map<String, Object> mapValue(Object value) {
if (value instanceof Map) {
return (Map<String, Object>) value;
}
return Collections.emptyMap();
}
private String stringValue(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}
private Long longValue(Object value) {
if (value instanceof Number) {
return ((Number) value).longValue();
}
try {
String text = stringValue(value);
return text.isEmpty() ? null : Long.valueOf(text);
} catch (Exception ex) {
return null;
}
}
private static class MeetingExpertSnapshot {
private final Long expertId;
private final String expertName;
private final String phone;
private final String organization;
private MeetingExpertSnapshot(Long expertId, String expertName, String phone, String organization) {
this.expertId = expertId;
this.expertName = expertName;
this.phone = phone;
this.organization = organization;
}
}
private static class PlatformExpertSnapshot {
private final Long expertId;
private final String expertName;
private final String phone;
private final String idNo;
private final String organization;
private PlatformExpertSnapshot(Long expertId,
String expertName,
String phone,
String idNo,
String organization) {
this.expertId = expertId;
this.expertName = expertName;
this.phone = phone;
this.idNo = idNo;
this.organization = organization;
}
}
private static class BankCardSnapshot {
private final Long expertId;
private final String bankName;
private final String bankProvince;
private final String bankCity;
private final String bankBranchName;
private final String bankCardNo;
private final String accountName;
private BankCardSnapshot(Long expertId,
String bankName,
String bankProvince,
String bankCity,
String bankBranchName,
String bankCardNo,
String accountName) {
this.expertId = expertId;
this.bankName = bankName;
this.bankProvince = bankProvince;
this.bankCity = bankCity;
this.bankBranchName = bankBranchName;
this.bankCardNo = bankCardNo;
this.accountName = accountName;
}
}
private static class LaborSummaryRow {
private int sequenceNo;
private String bankName = "";
private String bankProvince = "";
private String bankCity = "";
private String bankCardNo = "";
private String name = "";
private double netAmountYuan;
private String remark = "";
private double taxAmountYuan;
private double grossAmountYuan;
private String organization = "";
private String phone = "";
private String idNo = "";
private String department = "";
private String task = "";
}
private static class Styles {
private final CellStyle title;
private final CellStyle header;
private final CellStyle text;
private final CellStyle amount;
private final CellStyle total;
private final CellStyle totalAmount;
private Styles(CellStyle title,
CellStyle header,
CellStyle text,
CellStyle amount,
CellStyle total,
CellStyle totalAmount) {
this.title = title;
this.header = header;
this.text = text;
this.amount = amount;
this.total = total;
this.totalAmount = totalAmount;
}
}
}

View File

@ -51,11 +51,15 @@ public class MeetingMaterialExportService {
private final JdbcTemplate jdbcTemplate;
private final OssService ossService;
private final MeetingLaborSummaryExportService meetingLaborSummaryExportService;
private final ObjectMapper objectMapper = new ObjectMapper();
public MeetingMaterialExportService(JdbcTemplate jdbcTemplate, OssService ossService) {
public MeetingMaterialExportService(JdbcTemplate jdbcTemplate,
OssService ossService,
MeetingLaborSummaryExportService meetingLaborSummaryExportService) {
this.jdbcTemplate = jdbcTemplate;
this.ossService = ossService;
this.meetingLaborSummaryExportService = meetingLaborSummaryExportService;
}
public byte[] buildZip(Long tenantId, Long meetingId) {
@ -104,6 +108,24 @@ public class MeetingMaterialExportService {
List<ExportAttachment> attachments = extractAttachments(moduleSpec.moduleCode, parsedJson);
moduleManifest.put("jsonEntryPath", jsonEntryPath);
moduleManifest.put("attachmentCount", attachments.size());
int generatedFileCount = 0;
if ("EXPERT_LIST".equalsIgnoreCase(moduleSpec.moduleCode)) {
String laborSummaryFileName = "劳务汇总表.xlsx";
String laborSummaryEntryPath = uniqueEntryPath(moduleFolder, laborSummaryFileName, usedEntries);
writeBinaryEntry(zipOutputStream, laborSummaryEntryPath, meetingLaborSummaryExportService.buildWorkbook(tenantId, meetingId));
manifestFiles.add(buildManifestFile(
moduleSpec.moduleCode,
moduleSpec.folderName.substring(3),
"生成文件",
laborSummaryFileName,
null,
laborSummaryEntryPath,
"SUCCESS",
null
));
generatedFileCount++;
}
for (ExportAttachment attachment : attachments) {
attemptedAttachmentCount++;
@ -123,6 +145,9 @@ public class MeetingMaterialExportService {
failedAttachmentCount++;
}
}
if (generatedFileCount > 0) {
moduleManifest.put("generatedFileCount", generatedFileCount);
}
moduleManifest.put("successAttachmentCount", successAttachmentCount);
moduleManifest.put("failedAttachmentCount", failedAttachmentCount);
manifestModules.add(moduleManifest);

View File

@ -17,8 +17,11 @@ import com.writeoff.module.meeting.dto.SubmitMeetingRequest;
import com.writeoff.module.meeting.dto.WithdrawMeetingRequest;
import com.writeoff.module.meeting.model.Meeting;
import com.writeoff.module.meeting.model.MeetingAuditStatus;
import com.writeoff.module.meeting.model.MeetingSubmissionVersion;
import com.writeoff.module.meeting.model.MeetingStatus;
import com.writeoff.module.meeting.repository.MeetingRepository;
import com.writeoff.module.notification.dto.DispatchNotificationRequest;
import com.writeoff.module.notification.service.NotificationDispatchService;
import com.writeoff.module.project.model.Project;
import com.writeoff.module.project.service.ProjectService;
import com.writeoff.module.scheduler.service.AsyncJobService;
@ -26,10 +29,15 @@ import com.writeoff.module.system.model.BizChangeLogInfo;
import com.writeoff.module.system.service.BizChangeLogService;
import com.writeoff.module.system.service.DataPermissionService;
import com.writeoff.security.AuthContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;
import java.util.HashSet;
import java.util.LinkedHashMap;
@ -38,12 +46,16 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import java.math.BigDecimal;
import java.math.RoundingMode;
@Service
public class MeetingService {
private static final Logger log = LoggerFactory.getLogger(MeetingService.class);
private static final DateTimeFormatter SQL_ISO_SECOND_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
private static final Set<String> LOCATION_OPTIONS = new HashSet<String>();
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@ -59,12 +71,17 @@ public class MeetingService {
private final AuditFlowConfigService auditFlowConfigService;
private final DataPermissionService dataPermissionService;
private final ExpertSnapshotService expertSnapshotService;
private final NotificationDispatchService notificationDispatchService;
private final BizChangeLogService bizChangeLogService;
private final MeetingMaterialService meetingMaterialService;
private final MeetingSubmissionVersionService meetingSubmissionVersionService;
private final MeetingIssueResponseService meetingIssueResponseService;
private final MeetingVersionChangeService meetingVersionChangeService;
private final Map<String, Long> submitIdempotency = new ConcurrentHashMap<>();
private final Map<String, Long> withdrawIdempotency = new ConcurrentHashMap<>();
@Autowired
public MeetingService(MeetingRepository meetingRepository, ProjectService projectService, AuditTaskRepository auditTaskRepository, AsyncJobService asyncJobService, AuditFlowConfigService auditFlowConfigService, DataPermissionService dataPermissionService, ExpertSnapshotService expertSnapshotService, BizChangeLogService bizChangeLogService) {
public MeetingService(MeetingRepository meetingRepository, ProjectService projectService, AuditTaskRepository auditTaskRepository, AsyncJobService asyncJobService, AuditFlowConfigService auditFlowConfigService, DataPermissionService dataPermissionService, ExpertSnapshotService expertSnapshotService, NotificationDispatchService notificationDispatchService, BizChangeLogService bizChangeLogService, @Lazy MeetingMaterialService meetingMaterialService, @Lazy MeetingSubmissionVersionService meetingSubmissionVersionService, @Lazy MeetingIssueResponseService meetingIssueResponseService, @Lazy MeetingVersionChangeService meetingVersionChangeService) {
this.meetingRepository = meetingRepository;
this.projectService = projectService;
this.auditTaskRepository = auditTaskRepository;
@ -72,11 +89,16 @@ public class MeetingService {
this.auditFlowConfigService = auditFlowConfigService;
this.dataPermissionService = dataPermissionService;
this.expertSnapshotService = expertSnapshotService;
this.notificationDispatchService = notificationDispatchService;
this.bizChangeLogService = bizChangeLogService;
this.meetingMaterialService = meetingMaterialService;
this.meetingSubmissionVersionService = meetingSubmissionVersionService;
this.meetingIssueResponseService = meetingIssueResponseService;
this.meetingVersionChangeService = meetingVersionChangeService;
}
public MeetingService(MeetingRepository meetingRepository, ProjectService projectService, AuditTaskRepository auditTaskRepository, AsyncJobService asyncJobService) {
this(meetingRepository, projectService, auditTaskRepository, asyncJobService, null, null, null, null);
this(meetingRepository, projectService, auditTaskRepository, asyncJobService, null, null, null, null, null, null, null, null, null);
}
public PageResult<Meeting> list(MeetingQueryRequest query) {
@ -100,7 +122,15 @@ public class MeetingService {
}
list.forEach(this::applyEffectiveStatus);
list = applyFilters(list, query);
return new PageResult<>(list, list.size(), 1, 20);
int safePageNo = normalizePageNo(query == null ? null : query.getPageNo());
int safePageSize = normalizePageSize(query == null ? null : query.getPageSize());
int total = list.size();
int from = (safePageNo - 1) * safePageSize;
if (from >= total) {
return new PageResult<>(Collections.emptyList(), total, safePageNo, safePageSize);
}
int to = Math.min(from + safePageSize, total);
return new PageResult<>(list.subList(from, to), total, safePageNo, safePageSize);
}
private List<Meeting> applyFilters(List<Meeting> source, MeetingQueryRequest query) {
@ -176,18 +206,36 @@ public class MeetingService {
return null;
}
private int normalizePageNo(Integer pageNo) {
return pageNo == null || pageNo < 1 ? 1 : pageNo;
}
private int normalizePageSize(Integer pageSize) {
if (pageSize == null || pageSize < 1) {
return 20;
}
return Math.min(pageSize, 200);
}
public Meeting create(CreateMeetingRequest request) {
Project project = projectService.getById(request.getProjectId());
validateProjectForMeetingCreate(project);
validateProjectForMeetingCreate(project, true);
validateMeetingTimeInProjectCycle(project, request);
int existingMeetingCount = countMeetingsByProjectId(project.getId());
if (existingMeetingCount >= project.getMeetingTotal()) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "项目可创建的会议数量已达上限");
}
validateLocation(request.getLocation());
double laborRatio = request.getLaborRatio() == null ? project.getLaborFeeRatio() : normalizeRatio(request.getLaborRatio(), "劳务占比");
double cateringRatio = request.getCateringRatio() == null ? project.getCateringFeeRatio() : normalizeRatio(request.getCateringRatio(), "餐费占比");
assertMeetingRatiosWithinProject(project, laborRatio, cateringRatio);
long defaultBudgetCent = calculateDefaultMeetingBudgetCent(project);
if (defaultBudgetCent <= 0L) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "默认会议预算必须大于 0");
long initialBudgetCent = request.getBudgetCent() != null ? request.getBudgetCent() : defaultBudgetCent;
if (!project.isAllowMeetingOverBudget() && initialBudgetCent > defaultBudgetCent) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "当前项目不允许会议预算超出默认分配额度");
}
if (initialBudgetCent <= 0L) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "会议预算必须大于 0");
}
String now = nowIsoSeconds();
Meeting meeting = new Meeting(
@ -199,9 +247,9 @@ public class MeetingService {
request.getLocation(),
request.getStartTime(),
request.getEndTime(),
defaultBudgetCent,
request.getLaborRatio() == null ? 0d : request.getLaborRatio(),
request.getCateringRatio() == null ? 0d : request.getCateringRatio(),
initialBudgetCent,
laborRatio,
cateringRatio,
MeetingStatus.NOT_STARTED,
MeetingAuditStatus.PENDING,
null,
@ -231,7 +279,7 @@ public class MeetingService {
return saved;
}
private void validateProjectForMeetingCreate(Project project) {
private void validateProjectForMeetingCreate(Project project, boolean forCreate) {
if (project.getMeetingTotal() <= 0) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "创建会议前请先配置项目会议场次");
}
@ -240,6 +288,9 @@ public class MeetingService {
if (startDate == null || endDate == null || endDate.isBefore(startDate)) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "创建会议前请先配置有效的项目起止日期");
}
if (forCreate && projectService.hasChildren(project.getId())) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "存在子项目的项目下不可创建会议");
}
}
private int countMeetingsByProjectId(Long projectId) {
@ -249,7 +300,34 @@ public class MeetingService {
.count();
}
private long calculateDefaultMeetingBudgetCent(Project project) {
private double normalizeRatio(Double value, String fieldName) {
double normalized = value == null ? 0d : value;
if (Double.isNaN(normalized) || Double.isInfinite(normalized) || normalized < 0d || normalized > 1d) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, fieldName + "只能在0~1之间");
}
return BigDecimal.valueOf(normalized).setScale(6, RoundingMode.HALF_UP).doubleValue();
}
private void assertMeetingRatiosWithinProject(Project project, double laborRatio, double cateringRatio) {
double projectLaborRatio = normalizeRatio(project.getLaborFeeRatio(), "项目劳务费用占比");
double projectCateringRatio = normalizeRatio(project.getCateringFeeRatio(), "项目餐费占比");
if (laborRatio > projectLaborRatio) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "会议劳务占比不能高于项目劳务费用占比");
}
if (cateringRatio > projectCateringRatio) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "会议餐费占比不能高于项目餐费占比");
}
}
public long getDefaultMeetingBudgetCent(Long projectId) {
Project project = projectService.getById(projectId);
if (project == null) {
throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "项目不存在");
}
return calculateDefaultMeetingBudgetCent(project);
}
public long calculateDefaultMeetingBudgetCent(Project project) {
long projectBudgetCent = Math.max(0L, project.getBudgetCent());
ProjectFeeSummary feeSummary = parseProjectFeeSummary(project.getProjectFeeJson());
long distributableBudgetCent = projectBudgetCent - feeSummary.managementFeeCent - feeSummary.taxFeeCent - feeSummary.customFeeTotalCent;
@ -321,9 +399,21 @@ public class MeetingService {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "编辑会议时不能修改所属项目");
}
Project project = projectService.getById(existing.getProjectId());
validateProjectForMeetingCreate(project);
validateProjectForMeetingCreate(project, false);
validateMeetingTimeInProjectCycle(project, request);
validateLocation(request.getLocation());
double laborRatio = request.getLaborRatio() == null ? existing.getLaborRatio() : normalizeRatio(request.getLaborRatio(), "劳务占比");
double cateringRatio = request.getCateringRatio() == null ? existing.getCateringRatio() : normalizeRatio(request.getCateringRatio(), "餐费占比");
assertMeetingRatiosWithinProject(project, laborRatio, cateringRatio);
long newBudgetCent = request.getBudgetCent() != null ? request.getBudgetCent() : existing.getBudgetCent();
if (!project.isAllowMeetingOverBudget() && newBudgetCent > existing.getBudgetCent()) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "当前项目不允许调高会议预算");
}
if (newBudgetCent <= 0L) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "会议预算必须大于 0");
}
Meeting updated = new Meeting(
existing.getId(),
existing.getProjectId(),
@ -333,9 +423,9 @@ public class MeetingService {
request.getLocation(),
request.getStartTime(),
request.getEndTime(),
request.getBudgetCent(),
request.getLaborRatio() == null ? 0d : request.getLaborRatio(),
request.getCateringRatio() == null ? 0d : request.getCateringRatio(),
newBudgetCent,
laborRatio,
cateringRatio,
existing.getStatus(),
existing.getAuditStatus(),
existing.getCurrentAuditNode(),
@ -397,6 +487,7 @@ public class MeetingService {
}
}
@Transactional
public Map<String, Object> submit(Long meetingId, SubmitMeetingRequest request) {
Meeting meeting = meetingRepository.findById(meetingId)
.orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在"));
@ -405,6 +496,14 @@ public class MeetingService {
throw new BusinessException(ErrorCodes.IDEMPOTENCY_CONFLICT, "请求重复,请勿重复提交");
}
submitIdempotency.put(request.getIdempotencyKey(), meetingId);
log.info(
"meeting submit review start, tenantId={}, meetingId={}, operatorUserId={}, remark={}, issueResponseCount={}",
tenantId(),
meetingId,
safeUserId(),
request == null ? null : request.getRemark(),
request == null || request.getIssueResponses() == null ? 0 : request.getIssueResponses().size()
);
MeetingStatus effectiveStatus = resolveEffectiveStatus(meeting);
if (effectiveStatus != MeetingStatus.COMPLETED) {
@ -417,9 +516,44 @@ public class MeetingService {
if (meeting.getAuditStatus() == MeetingAuditStatus.IN_REVIEW) {
throw new BusinessException(ErrorCodes.INVALID_STATE, "会议正在审核中");
}
if (meetingIssueResponseService != null) {
meetingIssueResponseService.validateResponsesRequired(meetingId, request.getIssueResponses());
}
if (expertSnapshotService != null) {
expertSnapshotService.snapshotOnMeetingSubmit(meetingId);
}
if (meetingMaterialService != null) {
meetingMaterialService.markAllMaterialsSubmitted(meetingId, request.getRemark());
}
Long previousSubmissionVersionId = meetingSubmissionVersionService == null
? null
: meetingSubmissionVersionService.findLatestVersionIdBeforeCreate(meetingId);
Integer previousVersionNo = meetingSubmissionVersionService == null
? null
: meetingSubmissionVersionService.findLatestVersionNo(meetingId);
MeetingSubmissionVersion submissionVersion = meetingSubmissionVersionService == null
? null
: meetingSubmissionVersionService.create(meetingId, request.getRemark());
if (submissionVersion != null && meetingVersionChangeService != null && meetingMaterialService != null) {
Map<String, String> currentModuleContentMap = new LinkedHashMap<>();
for (String moduleCode : Arrays.asList("BASIC_INFO", "WRITE_OFF_DOCS", "EXPERT_PROFILE", "EXPERT_LIST", "MEETING_INVOICE")) {
currentModuleContentMap.put(moduleCode, meetingMaterialService.currentContentJsonOrEmpty(meetingId, moduleCode));
}
meetingVersionChangeService.buildAndSaveChangeSet(
meetingId,
previousSubmissionVersionId,
previousVersionNo,
submissionVersion.getId(),
currentModuleContentMap
);
}
if (meetingIssueResponseService != null) {
meetingIssueResponseService.saveMeetingResponses(
meetingId,
submissionVersion == null ? null : submissionVersion.getId(),
request.getIssueResponses()
);
}
meeting.setAuditStatus(MeetingAuditStatus.IN_REVIEW);
meetingRepository.save(meeting);
@ -434,7 +568,8 @@ public class MeetingService {
if (bizChangeLogService != null) {
bizChangeLogService.logAction("MEETING", meetingId, "MEETING_SUBMIT", request.getRemark());
}
auditTaskRepository.save(new AuditTask(
AuditTask previousTask = auditTaskRepository.findLatestByMeetingId(meetingId).orElse(null);
AuditTask createdTask = auditTaskRepository.save(new AuditTask(
null,
meetingId,
firstNode,
@ -442,19 +577,65 @@ public class MeetingService {
AuditTaskStatus.PENDING,
request.getRemark()
));
createdTask.setSubmissionVersionId(submissionVersion == null ? null : submissionVersion.getId());
createdTask = auditTaskRepository.save(createdTask);
if (meetingMaterialService != null && previousTask != null && previousTask.getId() != null && previousTask.getId() > 0L) {
try {
meetingMaterialService.inheritApprovedItemReviews(
meetingId,
previousTask.getId(),
createdTask.getId(),
previousTask.getNode() == null ? firstNode.name() : previousTask.getNode().name(),
firstNode.name(),
previousTask.getSubmissionVersionId(),
createdTask.getSubmissionVersionId()
);
} catch (Exception ex) {
log.warn(
"inherit approved material reviews on meeting submit failed, tenantId={}, meetingId={}, previousTaskId={}, createdTaskId={}",
tenantId(),
meetingId,
previousTask.getId(),
createdTask.getId(),
ex
);
}
}
asyncJobService.enqueue(
"AUDIT_REMIND",
"meetingId=" + meetingId,
"job-audit-remind-" + meetingId + "-" + request.getIdempotencyKey()
);
triggerAuditTaskAssignedNotification(meeting, createdTask);
Map<String, Object> result = new LinkedHashMap<>();
result.put("meetingId", meetingId);
result.put("auditStatus", meeting.getAuditStatus().name());
result.put("currentNode", firstNode.name());
result.put("submissionVersionId", submissionVersion == null ? null : submissionVersion.getId());
log.info(
"meeting submit review success, tenantId={}, meetingId={}, operatorUserId={}, submissionVersionId={}, previousSubmissionVersionId={}, previousVersionNo={}, createdTaskId={}, firstNode={}, assigneeUserId={}",
tenantId(),
meetingId,
safeUserId(),
submissionVersion == null ? null : submissionVersion.getId(),
previousSubmissionVersionId,
previousVersionNo,
createdTask == null ? null : createdTask.getId(),
firstNode,
assigneeUserId
);
return result;
}
public List<Map<String, Object>> listPendingIssues(Long meetingId) {
getById(meetingId);
if (meetingMaterialService == null) {
return Collections.emptyList();
}
return meetingMaterialService.listMeetingPendingResubmitIssues(meetingId);
}
public Map<String, Object> withdraw(Long meetingId, WithdrawMeetingRequest request) {
Meeting meeting = meetingRepository.findById(meetingId)
.orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在"));
@ -492,7 +673,7 @@ public class MeetingService {
.orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在"));
MeetingStatus effectiveStatus = resolveEffectiveStatus(meeting);
// 婵炲濮撮幊搴g礊鐎n兘鍋撻崗澶婂闁绘濞婇幃鈺呮嚋绾版ê浜惧ù锝囨焿缁?PENDING闂佹寧绋戦悧鍡楃暤閸顩烽幖娣灪瀵捇鏌熺紒妯哄闄勫濠氬炊妞嬪海顦繛鎴炴尰濮婄懓锕鐘冲仏妞ゆ劑鍨归弸娆戠磽娴h灏版俊鐐插垮畷妤佹媴缁涘鏅犻梺鍛婂笧婵炩偓婵?
// 只有待审核且从未提交过的草稿会议才允许删除
if (meeting.getAuditStatus() != MeetingAuditStatus.PENDING) {
throw new BusinessException(ErrorCodes.INVALID_STATE, "只有待审核的草稿会议才可删除");
}
@ -577,8 +758,18 @@ public class MeetingService {
}
public void updateAuditStatus(Long meetingId, MeetingAuditStatus status) {
updateAuditStatus(meetingId, status, null);
}
public void updateAuditStatus(Long meetingId, MeetingAuditStatus status, String rejectReason) {
Meeting meeting = getById(meetingId);
meeting.setAuditStatus(status);
if (rejectReason != null && !rejectReason.isEmpty()) {
meeting.setLastRejectReason(rejectReason);
meeting.setRejectCount(meeting.getRejectCount() + 1);
} else if (status == MeetingAuditStatus.APPROVED) {
meeting.setLastRejectReason("");
}
meetingRepository.save(meeting);
}
@ -592,6 +783,49 @@ public class MeetingService {
meetingRepository.save(meeting);
}
public void triggerAuditTaskAssignedNotification(AuditTask task) {
if (task == null || task.getMeetingId() == null || task.getMeetingId() <= 0L) {
return;
}
Meeting meeting = meetingRepository.findById(task.getMeetingId()).orElse(null);
if (meeting == null) {
return;
}
triggerAuditTaskAssignedNotification(meeting, task);
}
private void triggerAuditTaskAssignedNotification(Meeting meeting, AuditTask task) {
if (notificationDispatchService == null || meeting == null || task == null) {
return;
}
Long assigneeUserId = task.getAssigneeUserId();
if (assigneeUserId == null || assigneeUserId <= 0L) {
return;
}
try {
Map<String, Object> vars = new LinkedHashMap<String, Object>();
vars.put("meetingId", meeting.getId());
vars.put("meetingTopic", meeting.getTopic() == null ? "" : meeting.getTopic());
vars.put("auditNode", task.getNode() == null ? "" : task.getNode().name());
vars.put("auditTaskId", task.getId() == null ? 0L : task.getId());
vars.put("assigneeUserId", assigneeUserId);
DispatchNotificationRequest dispatchRequest = new DispatchNotificationRequest();
dispatchRequest.setIdempotencyKey("audit-auto-notify-AUDIT_TASK_ASSIGNED-" + (task.getId() == null ? 0L : task.getId()));
dispatchRequest.setEventCode("AUDIT_TASK_ASSIGNED");
dispatchRequest.setBizType("MEETING");
dispatchRequest.setBizId("meeting-" + meeting.getId());
dispatchRequest.setVariablesJson(OBJECT_MAPPER.writeValueAsString(vars));
notificationDispatchService.dispatch(dispatchRequest);
} catch (BusinessException ex) {
if (ex.getCode() != ErrorCodes.RESOURCE_NOT_FOUND) {
log.warn("自动触发审核任务分配通知失败, taskId={}, code={}, msg={}", task.getId(), ex.getCode(), ex.getMessage());
}
} catch (Exception ex) {
log.warn("自动触发审核任务分配通知异常, taskId={}", task.getId(), ex);
}
}
public Meeting updateInvoiceConfig(Long meetingId, com.writeoff.module.meeting.dto.MeetingInvoiceConfigRequest request) {
Meeting meeting = getById(meetingId);
String beforeConfigJson = meeting.getInvoiceConfigJson();

View File

@ -0,0 +1,249 @@
package com.writeoff.module.meeting.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.writeoff.common.exception.BusinessException;
import com.writeoff.common.exception.ErrorCodes;
import com.writeoff.module.meeting.model.Meeting;
import com.writeoff.module.meeting.model.MeetingSubmissionVersion;
import com.writeoff.module.meeting.repository.MeetingRepository;
import com.writeoff.security.AuthContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Service
public class MeetingSubmissionVersionService {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final RowMapper<MeetingSubmissionVersion> ROW_MAPPER = (rs, n) -> new MeetingSubmissionVersion(
rs.getLong("id"),
rs.getLong("meeting_id"),
rs.getInt("version_no"),
rs.getString("remark"),
rs.getString("snapshot_json"),
rs.getObject("created_by") == null ? null : rs.getLong("created_by"),
rs.getString("created_by_name"),
rs.getString("created_at")
);
private final JdbcTemplate jdbcTemplate;
private final MeetingRepository meetingRepository;
public MeetingSubmissionVersionService(JdbcTemplate jdbcTemplate,
MeetingRepository meetingRepository) {
this.jdbcTemplate = jdbcTemplate;
this.meetingRepository = meetingRepository;
}
@Transactional
public MeetingSubmissionVersion create(Long meetingId, String remark) {
Meeting meeting = meetingRepository.findById(meetingId)
.orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在"));
int nextVersionNo = nextVersionNo(meetingId);
String snapshotJson = buildSnapshotJson(meetingId, meeting, remark, nextVersionNo);
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(
"INSERT INTO meeting_submission_version (tenant_id, meeting_id, version_no, remark, snapshot_json, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS
);
Long operator = safeUserId();
ps.setLong(1, tenantId());
ps.setLong(2, meetingId);
ps.setInt(3, nextVersionNo);
ps.setString(4, remark);
ps.setString(5, snapshotJson);
ps.setLong(6, operator);
ps.setLong(7, operator);
return ps;
}, keyHolder);
Number key = keyHolder.getKey();
Long id = key == null ? null : key.longValue();
return getById(id).orElseThrow(() -> new BusinessException(10001, "提交版本创建失败"));
}
public Optional<MeetingSubmissionVersion> getById(Long id) {
if (id == null || id <= 0L) {
return Optional.empty();
}
List<MeetingSubmissionVersion> rows = jdbcTemplate.query(
"SELECT msv.id, msv.meeting_id, msv.version_no, msv.remark, msv.snapshot_json, msv.created_by, su.user_name AS created_by_name, " +
"DATE_FORMAT(msv.created_at, '%Y-%m-%d %H:%i:%s') AS created_at " +
"FROM meeting_submission_version msv " +
"LEFT JOIN sys_user su ON su.tenant_id=msv.tenant_id AND su.id=msv.created_by AND su.is_deleted=0 " +
"WHERE msv.tenant_id=? AND msv.id=?",
ROW_MAPPER,
tenantId(),
id
);
return rows.stream().findFirst();
}
public Optional<MeetingSubmissionVersion> findByMeetingIdAndVersionNo(Long meetingId, Integer versionNo) {
if (meetingId == null || meetingId <= 0L || versionNo == null || versionNo <= 0) {
return Optional.empty();
}
List<MeetingSubmissionVersion> rows = jdbcTemplate.query(
"SELECT msv.id, msv.meeting_id, msv.version_no, msv.remark, msv.snapshot_json, msv.created_by, su.user_name AS created_by_name, " +
"DATE_FORMAT(msv.created_at, '%Y-%m-%d %H:%i:%s') AS created_at " +
"FROM meeting_submission_version msv " +
"LEFT JOIN sys_user su ON su.tenant_id=msv.tenant_id AND su.id=msv.created_by AND su.is_deleted=0 " +
"WHERE msv.tenant_id=? AND msv.meeting_id=? AND msv.version_no=? LIMIT 1",
ROW_MAPPER,
tenantId(),
meetingId,
versionNo
);
return rows.stream().findFirst();
}
public List<MeetingSubmissionVersion> listByMeetingId(Long meetingId) {
if (meetingId == null || meetingId <= 0L) {
return new java.util.ArrayList<>();
}
return jdbcTemplate.query(
"SELECT msv.id, msv.meeting_id, msv.version_no, msv.remark, msv.snapshot_json, msv.created_by, su.user_name AS created_by_name, " +
"DATE_FORMAT(msv.created_at, '%Y-%m-%d %H:%i:%s') AS created_at " +
"FROM meeting_submission_version msv " +
"LEFT JOIN sys_user su ON su.tenant_id=msv.tenant_id AND su.id=msv.created_by AND su.is_deleted=0 " +
"WHERE msv.tenant_id=? AND msv.meeting_id=? " +
"ORDER BY msv.version_no ASC, msv.id ASC",
ROW_MAPPER,
tenantId(),
meetingId
);
}
public Long findLatestVersionIdBeforeCreate(Long meetingId) {
if (meetingId == null || meetingId <= 0L) {
return null;
}
List<Long> rows = jdbcTemplate.query(
"SELECT id FROM meeting_submission_version WHERE tenant_id=? AND meeting_id=? ORDER BY version_no DESC LIMIT 1",
(rs, n) -> rs.getLong("id"),
tenantId(),
meetingId
);
return rows.isEmpty() ? null : rows.get(0);
}
public Integer findLatestVersionNo(Long meetingId) {
if (meetingId == null || meetingId <= 0L) {
return null;
}
List<Integer> rows = jdbcTemplate.query(
"SELECT version_no FROM meeting_submission_version WHERE tenant_id=? AND meeting_id=? ORDER BY version_no DESC LIMIT 1",
(rs, n) -> rs.getInt("version_no"),
tenantId(),
meetingId
);
return rows.isEmpty() ? null : rows.get(0);
}
private int nextVersionNo(Long meetingId) {
Integer latest = jdbcTemplate.query(
"SELECT MAX(version_no) FROM meeting_submission_version WHERE tenant_id=? AND meeting_id=?",
rs -> rs.next() ? rs.getInt(1) : 0,
tenantId(),
meetingId
);
return (latest == null ? 0 : latest) + 1;
}
private String buildSnapshotJson(Long meetingId, Meeting meeting, String remark, int versionNo) {
Map<String, Object> root = new LinkedHashMap<>();
root.put("meetingId", meetingId);
root.put("submissionVersionNo", versionNo);
root.put("remark", remark == null ? "" : remark);
Map<String, Object> meetingInfo = new LinkedHashMap<>();
meetingInfo.put("projectId", meeting.getProjectId());
meetingInfo.put("projectName", meeting.getProjectName());
meetingInfo.put("topic", meeting.getTopic());
meetingInfo.put("meetingCategory", meeting.getMeetingCategory());
meetingInfo.put("meetingForm", meeting.getMeetingForm());
meetingInfo.put("location", meeting.getLocation());
meetingInfo.put("startTime", meeting.getStartTime());
meetingInfo.put("endTime", meeting.getEndTime());
meetingInfo.put("budgetCent", meeting.getBudgetCent());
meetingInfo.put("laborRatio", meeting.getLaborRatio());
meetingInfo.put("cateringRatio", meeting.getCateringRatio());
root.put("meeting", meetingInfo);
Map<String, Object> materials = new LinkedHashMap<>();
for (String moduleCode : new String[] {"BASIC_INFO", "WRITE_OFF_DOCS", "EXPERT_LIST", "MEETING_INVOICE"}) {
materials.put(moduleCode, safeParseJson(loadCurrentMaterialContentJson(meetingId, moduleCode)));
}
materials.put("EXPERT_PROFILE", safeParseJson(extractExpertProfileJson(loadCurrentMaterialContentJson(meetingId, "WRITE_OFF_DOCS"))));
root.put("materials", materials);
try {
return OBJECT_MAPPER.writeValueAsString(root);
} catch (JsonProcessingException e) {
throw new BusinessException(10001, "提交版本快照序列化失败");
}
}
private Object safeParseJson(String json) {
if (json == null || json.trim().isEmpty()) {
return null;
}
try {
return OBJECT_MAPPER.readValue(json, Object.class);
} catch (Exception ex) {
return json;
}
}
private String extractExpertProfileJson(String contentJson) {
if (contentJson == null || contentJson.trim().isEmpty()) {
return contentJson;
}
try {
Object parsed = OBJECT_MAPPER.readValue(contentJson, Object.class);
if (!(parsed instanceof Map)) {
return contentJson;
}
Map<?, ?> root = (Map<?, ?>) parsed;
Object profileFile = root.get("profileFile");
if (profileFile == null && root.containsKey("ossKey")) {
return contentJson;
}
return OBJECT_MAPPER.writeValueAsString(profileFile == null ? root : profileFile);
} catch (Exception ex) {
return contentJson;
}
}
private String loadCurrentMaterialContentJson(Long meetingId, String moduleCode) {
List<String> rows = jdbcTemplate.query(
"SELECT COALESCE(NULLIF(draft_content_json, ''), content_json) AS effective_content_json " +
"FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND module_code=? AND is_deleted=0 LIMIT 1",
(rs, n) -> rs.getString("effective_content_json"),
tenantId(),
meetingId,
moduleCode
);
return rows.isEmpty() ? "" : String.valueOf(rows.get(0) == null ? "" : rows.get(0));
}
private Long tenantId() {
return AuthContext.requireTenantId();
}
private Long safeUserId() {
Long userId = AuthContext.userId();
return userId == null ? 0L : userId;
}
}

View File

@ -144,8 +144,8 @@ public class MeetingSummaryExportService {
projectName,
queryTenantName(tenantId)
);
String meetingCategory = stringValue(meeting.get("meeting_category"));
String location = stringValue(meeting.get("location"));
String meetingCategory = resolveDictName("MEETING_CATEGORY", stringValue(meeting.get("meeting_category")));
String location = resolveDictName("MEETING_LOCATION", stringValue(meeting.get("location")));
String startTime = stringValue(meeting.get("start_time"));
String endTime = stringValue(meeting.get("end_time"));
String guestCountText = formatNumber(basicInfo.get("guestCount"));
@ -240,6 +240,26 @@ public class MeetingSummaryExportService {
}
}
private String resolveDictName(String dictType, String dictCode) {
if (dictCode == null || dictCode.trim().isEmpty()) {
return dictCode;
}
try {
List<String> names = jdbcTemplate.queryForList(
"SELECT dict_name FROM platform_dictionary_item WHERE dict_type=? AND dict_code=? AND is_deleted=0 LIMIT 1",
String.class,
dictType,
dictCode
);
if (!names.isEmpty() && names.get(0) != null) {
return names.get(0);
}
} catch (Exception ex) {
// ignore
}
return dictCode;
}
private List<Long> parseIdList(Object value) {
if (!(value instanceof List)) {
return Collections.emptyList();

View File

@ -0,0 +1,397 @@
package com.writeoff.module.meeting.service;
import com.writeoff.module.meeting.model.MeetingSubmissionVersion;
import com.writeoff.security.AuthContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Service
public class MeetingVersionChangeService {
private static final Logger log = LoggerFactory.getLogger(MeetingVersionChangeService.class);
private final JdbcTemplate jdbcTemplate;
private final MeetingMaterialService meetingMaterialService;
private final MeetingSubmissionVersionService meetingSubmissionVersionService;
public MeetingVersionChangeService(JdbcTemplate jdbcTemplate,
MeetingMaterialService meetingMaterialService,
MeetingSubmissionVersionService meetingSubmissionVersionService) {
this.jdbcTemplate = jdbcTemplate;
this.meetingMaterialService = meetingMaterialService;
this.meetingSubmissionVersionService = meetingSubmissionVersionService;
}
@Transactional
public Map<String, Object> buildAndSaveChangeSet(Long meetingId,
Long fromSubmissionVersionId,
Integer fromVersionNo,
Long toSubmissionVersionId,
Map<String, String> currentModuleContentMap) {
if (meetingId == null || meetingId <= 0L || toSubmissionVersionId == null || toSubmissionVersionId <= 0L) {
return Collections.emptyMap();
}
List<Map<String, Object>> allChanges = new ArrayList<>();
int changedModuleCount = 0;
int issueRelatedCount = 0;
int extraChangeCount = 0;
MeetingSubmissionVersion previousVersion = fromSubmissionVersionId == null || fromSubmissionVersionId <= 0L || meetingSubmissionVersionService == null
? null
: meetingSubmissionVersionService.getById(fromSubmissionVersionId).orElse(null);
Map<String, String> previousModuleContentMap = extractModuleContentMapFromVersion(previousVersion);
for (String moduleCode : supportedModules()) {
String currentContentJson = stringValue(currentModuleContentMap.get(moduleCode));
String previousContentJson = stringValue(previousModuleContentMap.get(moduleCode));
Map<String, Object> summary = meetingMaterialService.buildResubmitSummaryFromPreviousContent(
meetingId,
moduleCode,
currentContentJson,
resolveCurrentVersionNo(fromVersionNo),
previousContentJson,
fromVersionNo,
previousVersion == null ? "" : previousVersion.getCreatedAt(),
previousVersion == null ? "" : previousVersion.getRemark()
);
List<Map<String, Object>> changes = castRows(summary.get("changes"));
if (!changes.isEmpty()) {
changedModuleCount++;
}
for (Map<String, Object> change : changes) {
Map<String, Object> row = new LinkedHashMap<>(change);
row.put("moduleCode", moduleCode);
if (Boolean.TRUE.equals(change.get("relatedIssue"))) {
issueRelatedCount++;
} else {
extraChangeCount++;
}
allChanges.add(row);
}
}
final int finalChangedModuleCount = changedModuleCount;
final int finalChangedItemCount = allChanges.size();
final int finalIssueRelatedCount = issueRelatedCount;
final int finalExtraChangeCount = extraChangeCount;
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(
"INSERT INTO version_change_set (tenant_id, meeting_id, from_submission_version_id, to_submission_version_id, changed_module_count, changed_item_count, issue_related_count, extra_change_count, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS
);
ps.setLong(1, tenantId());
ps.setLong(2, meetingId);
if (fromSubmissionVersionId == null || fromSubmissionVersionId <= 0L) {
ps.setObject(3, null);
} else {
ps.setLong(3, fromSubmissionVersionId);
}
ps.setLong(4, toSubmissionVersionId);
ps.setInt(5, finalChangedModuleCount);
ps.setInt(6, finalChangedItemCount);
ps.setInt(7, finalIssueRelatedCount);
ps.setInt(8, finalExtraChangeCount);
ps.setLong(9, safeUserId());
ps.setLong(10, safeUserId());
return ps;
}, keyHolder);
Number key = keyHolder.getKey();
Long changeSetId = key == null ? null : key.longValue();
if (changeSetId != null && changeSetId > 0L) {
saveChangeItems(changeSetId, meetingId, allChanges);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("changeSetId", changeSetId);
result.put("changedModuleCount", changedModuleCount);
result.put("changedItemCount", allChanges.size());
result.put("issueRelatedCount", issueRelatedCount);
result.put("extraChangeCount", extraChangeCount);
result.put("changes", allChanges);
log.info(
"meeting resubmit changeSet built, tenantId={}, meetingId={}, fromSubmissionVersionId={}, fromVersionNo={}, toSubmissionVersionId={}, changeSetId={}, changedModuleCount={}, changedItemCount={}, issueRelatedCount={}, extraChangeCount={}",
tenantId(),
meetingId,
fromSubmissionVersionId,
fromVersionNo,
toSubmissionVersionId,
changeSetId,
changedModuleCount,
allChanges.size(),
issueRelatedCount,
extraChangeCount
);
return result;
}
public Map<String, Object> findLatestBySubmissionVersionId(Long submissionVersionId) {
return findLatestBySubmissionVersionId(submissionVersionId, false);
}
@Transactional
public Map<String, Object> findLatestBySubmissionVersionId(Long submissionVersionId, boolean autoBackfill) {
if (submissionVersionId == null || submissionVersionId <= 0L) {
return Collections.emptyMap();
}
List<Map<String, Object>> rows = queryLatestChangeSetRows(submissionVersionId);
if (rows.isEmpty() && autoBackfill) {
Optional<MeetingSubmissionVersion> currentVersionOptional = meetingSubmissionVersionService.getById(submissionVersionId);
if (currentVersionOptional.isPresent()) {
MeetingSubmissionVersion currentVersion = currentVersionOptional.get();
MeetingSubmissionVersion previousVersion = resolvePreviousSubmissionVersion(
currentVersion.getMeetingId(),
currentVersion.getVersionNo()
);
if (currentVersion.getMeetingId() != null && currentVersion.getMeetingId() > 0L && currentVersion.getVersionNo() != null && currentVersion.getVersionNo() > 1) {
buildAndSaveChangeSetFromVersions(
currentVersion.getMeetingId(),
previousVersion == null ? null : previousVersion.getId(),
previousVersion == null ? null : previousVersion.getVersionNo(),
currentVersion
);
rows = queryLatestChangeSetRows(submissionVersionId);
}
}
}
if (rows.isEmpty()) {
return Collections.emptyMap();
}
Map<String, Object> set = new LinkedHashMap<>(rows.get(0));
Number changeSetIdNum = (Number) set.get("id");
Long changeSetId = changeSetIdNum == null ? null : changeSetIdNum.longValue();
if (changeSetId == null || changeSetId <= 0L) {
set.put("items", Collections.emptyList());
return set;
}
List<Map<String, Object>> items = jdbcTemplate.queryForList(
"SELECT id, module_code, target_path, target_label, target_kind, target_row_key, attachment_identity, attachment_hash, change_type, old_value, new_value, related_issue_id, is_extra_change, " +
"DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " +
"FROM version_change_item WHERE tenant_id=? AND change_set_id=? ORDER BY id ASC",
tenantId(),
changeSetId
);
for (Map<String, Object> item : items) {
item.put("itemKey", stringValue(item.get("target_path")));
item.put("itemLabel", stringValue(item.get("target_label")));
item.put("targetKind", stringValue(item.get("target_kind")));
item.put("targetRowKey", stringValue(item.get("target_row_key")));
item.put("attachmentIdentity", stringValue(item.get("attachment_identity")));
item.put("attachmentHash", stringValue(item.get("attachment_hash")));
}
set.put("items", items);
return set;
}
@Transactional
public Map<String, Object> buildAndSaveChangeSetFromVersions(Long meetingId,
Long fromSubmissionVersionId,
Integer fromVersionNo,
MeetingSubmissionVersion toVersion) {
if (meetingId == null || meetingId <= 0L || toVersion == null || toVersion.getId() == null || toVersion.getId() <= 0L) {
return Collections.emptyMap();
}
List<Map<String, Object>> existing = queryLatestChangeSetRows(toVersion.getId());
if (!existing.isEmpty()) {
return findLatestBySubmissionVersionId(toVersion.getId(), false);
}
Map<String, String> currentModuleContentMap = extractModuleContentMapFromVersion(toVersion);
return buildAndSaveChangeSet(
meetingId,
fromSubmissionVersionId,
fromVersionNo,
toVersion.getId(),
currentModuleContentMap
);
}
public Map<String, Object> backfillMissingChangeSetsByMeetingId(Long meetingId) {
List<MeetingSubmissionVersion> versions = meetingSubmissionVersionService.listByMeetingId(meetingId);
Map<Integer, MeetingSubmissionVersion> versionNoMap = new HashMap<>();
for (MeetingSubmissionVersion version : versions) {
if (version != null && version.getVersionNo() != null) {
versionNoMap.put(version.getVersionNo(), version);
}
}
int createdCount = 0;
int skippedCount = 0;
List<Long> createdVersionIds = new ArrayList<>();
for (MeetingSubmissionVersion version : versions) {
if (version == null || version.getId() == null || version.getId() <= 0L) {
continue;
}
if (!queryLatestChangeSetRows(version.getId()).isEmpty()) {
skippedCount++;
continue;
}
Integer currentVersionNo = version.getVersionNo();
if (currentVersionNo == null || currentVersionNo <= 1) {
skippedCount++;
continue;
}
MeetingSubmissionVersion previousVersion = versionNoMap.get(currentVersionNo - 1);
buildAndSaveChangeSetFromVersions(
meetingId,
previousVersion == null ? null : previousVersion.getId(),
previousVersion == null ? null : previousVersion.getVersionNo(),
version
);
createdCount++;
createdVersionIds.add(version.getId());
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("meetingId", meetingId);
result.put("createdCount", createdCount);
result.put("skippedCount", skippedCount);
result.put("createdVersionIds", createdVersionIds);
return result;
}
private List<Map<String, Object>> queryLatestChangeSetRows(Long submissionVersionId) {
return jdbcTemplate.queryForList(
"SELECT id, meeting_id, from_submission_version_id, to_submission_version_id, changed_module_count, changed_item_count, issue_related_count, extra_change_count, " +
"DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " +
"FROM version_change_set WHERE tenant_id=? AND to_submission_version_id=? ORDER BY id DESC LIMIT 1",
tenantId(),
submissionVersionId
);
}
private MeetingSubmissionVersion resolvePreviousSubmissionVersion(Long meetingId, Integer currentVersionNo) {
if (meetingId == null || meetingId <= 0L || currentVersionNo == null || currentVersionNo <= 1) {
return null;
}
return meetingSubmissionVersionService.findByMeetingIdAndVersionNo(meetingId, currentVersionNo - 1).orElse(null);
}
@SuppressWarnings("unchecked")
private Map<String, String> extractModuleContentMapFromVersion(MeetingSubmissionVersion version) {
Map<String, String> moduleContentMap = new LinkedHashMap<>();
for (String moduleCode : supportedModules()) {
moduleContentMap.put(moduleCode, "");
}
if (version == null) {
return moduleContentMap;
}
try {
Map<String, Object> root = meetingMaterialService.parseObjectMap(version.getSnapshotJson());
Object materialsObj = root.get("materials");
if (!(materialsObj instanceof Map)) {
return moduleContentMap;
}
Map<String, Object> materials = (Map<String, Object>) materialsObj;
for (String moduleCode : supportedModules()) {
Object moduleValue = materials.get(moduleCode);
moduleContentMap.put(moduleCode, meetingMaterialService.writeJson(moduleValue));
}
return moduleContentMap;
} catch (Exception ex) {
return moduleContentMap;
}
}
private void saveChangeItems(Long changeSetId, Long meetingId, List<Map<String, Object>> changes) {
if (changeSetId == null || changeSetId <= 0L || changes == null || changes.isEmpty()) {
return;
}
for (Map<String, Object> change : changes) {
jdbcTemplate.update(
"INSERT INTO version_change_item (tenant_id, change_set_id, meeting_id, module_code, target_path, target_label, target_kind, target_row_key, attachment_identity, attachment_hash, change_type, old_value, new_value, related_issue_id, is_extra_change, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
tenantId(),
changeSetId,
meetingId,
stringValue(change.get("moduleCode")),
stringValue(change.get("itemKey")),
stringValue(change.get("itemLabel")),
stringValue(change.get("targetKind")),
nullableString(change.get("targetRowKey")),
nullableString(change.get("attachmentIdentity")),
nullableString(change.get("attachmentHash")),
stringValue(change.get("changeType")),
nullableString(change.get("previousValue")),
nullableString(change.get("currentValue")),
toLong(change.get("relatedIssueId")),
Boolean.TRUE.equals(change.get("isExtraChange")) ? 1 : 0,
safeUserId(),
safeUserId()
);
}
}
private List<Map<String, Object>> castRows(Object raw) {
if (!(raw instanceof List)) {
return Collections.emptyList();
}
List<Map<String, Object>> result = new ArrayList<>();
for (Object row : (List<?>) raw) {
if (row instanceof Map) {
result.add(new LinkedHashMap<>((Map<String, Object>) row));
}
}
return result;
}
private List<String> supportedModules() {
List<String> list = new ArrayList<>();
list.add("BASIC_INFO");
list.add("WRITE_OFF_DOCS");
list.add("EXPERT_PROFILE");
list.add("EXPERT_LIST");
list.add("MEETING_INVOICE");
return list;
}
private Integer resolveCurrentVersionNo(Integer fromVersionNo) {
int base = fromVersionNo == null ? 0 : fromVersionNo;
return base + 1;
}
private Long toLong(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number) {
long longValue = ((Number) value).longValue();
return longValue <= 0L ? null : longValue;
}
try {
String text = String.valueOf(value).trim();
if (text.isEmpty()) {
return null;
}
long parsed = Long.parseLong(text);
return parsed <= 0L ? null : parsed;
} catch (Exception ex) {
return null;
}
}
private String stringValue(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}
private String nullableString(Object value) {
String text = stringValue(value);
return text.isEmpty() ? null : text;
}
private Long tenantId() {
return AuthContext.requireTenantId();
}
private Long safeUserId() {
Long userId = AuthContext.userId();
return userId == null ? 0L : userId;
}
}

View File

@ -22,8 +22,17 @@ public class InAppNotificationController {
@GetMapping
@RequirePermission(value = "notification.inapp.read", dataScope = DataScopeType.TENANT, auditAction = "IN_APP_NOTIFICATION_LIST")
public ApiResponse<PageResult<InAppNotificationInfo>> listMine() {
return ApiResponse.success(inAppNotificationService.listMine());
public ApiResponse<PageResult<InAppNotificationInfo>> listMine(
@RequestParam(value = "pageNo", defaultValue = "1") int pageNo,
@RequestParam(value = "pageSize", defaultValue = "200") int pageSize,
@RequestParam(value = "onlyUnread", defaultValue = "false") boolean onlyUnread) {
return ApiResponse.success(inAppNotificationService.listMine(pageNo, pageSize, onlyUnread));
}
@GetMapping("/summary")
@RequirePermission(value = "notification.inapp.read", dataScope = DataScopeType.TENANT, auditAction = "IN_APP_NOTIFICATION_SUMMARY")
public ApiResponse<Map<String, Object>> summaryMine() {
return ApiResponse.success(inAppNotificationService.summaryMine());
}
@PostMapping("/{id}/read")

View File

@ -7,7 +7,6 @@ import com.writeoff.module.notification.model.PlatformNotifyGatewayInfo;
import com.writeoff.module.notification.service.PlatformNotifyGatewayService;
import com.writeoff.module.notification.service.PlatformNotifyGatewayTestService;
import com.writeoff.security.DataScopeType;
import com.writeoff.security.PermissionDomain;
import com.writeoff.security.RequirePermission;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@ -22,7 +21,7 @@ import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/platform/notify-gateways")
@RequestMapping("/api/notify-gateways")
public class PlatformNotifyGatewayController {
private final PlatformNotifyGatewayService gatewayService;
private final PlatformNotifyGatewayTestService testService;
@ -34,20 +33,20 @@ public class PlatformNotifyGatewayController {
}
@GetMapping
@RequirePermission(value = "platform.notify-gateway.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_NOTIFY_GATEWAY_LIST")
@RequirePermission(value = "notification.notify-gateway.read", dataScope = DataScopeType.TENANT, auditAction = "NOTIFY_GATEWAY_LIST")
public ApiResponse<List<PlatformNotifyGatewayInfo>> list() {
return ApiResponse.success(gatewayService.list());
}
@PutMapping("/{channelCode}")
@RequirePermission(value = "platform.notify-gateway.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_NOTIFY_GATEWAY_SAVE")
@RequirePermission(value = "notification.notify-gateway.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFY_GATEWAY_SAVE")
public ApiResponse<PlatformNotifyGatewayInfo> save(@PathVariable("channelCode") String channelCode,
@RequestBody SavePlatformNotifyGatewayRequest request) {
return ApiResponse.success(gatewayService.save(channelCode, request));
}
@PostMapping("/{channelCode}/test")
@RequirePermission(value = "platform.notify-gateway.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_NOTIFY_GATEWAY_TEST")
@RequirePermission(value = "notification.notify-gateway.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFY_GATEWAY_TEST")
public ApiResponse<Map<String, Object>> test(@PathVariable("channelCode") String channelCode,
@RequestBody @Valid TestPlatformNotifyGatewayRequest request) {
return ApiResponse.success(testService.test(channelCode, request));

View File

@ -14,6 +14,7 @@ public class CreateNotificationPolicyRequest {
private String receiverType;
@NotNull(message = "文案模板ID不能为空")
private Long templateId;
private String smsTemplateCode;
private String variablesJson;
private String status;
@ -57,6 +58,14 @@ public class CreateNotificationPolicyRequest {
this.templateId = templateId;
}
public String getSmsTemplateCode() {
return smsTemplateCode;
}
public void setSmsTemplateCode(String smsTemplateCode) {
this.smsTemplateCode = smsTemplateCode;
}
public String getVariablesJson() {
return variablesJson;
}

View File

@ -7,6 +7,7 @@ public class TestPlatformNotifyGatewayRequest {
private String receiverRef;
private String subject;
private String content;
private String smsTemplateCode;
public String getReceiverRef() {
return receiverRef;
@ -31,4 +32,12 @@ public class TestPlatformNotifyGatewayRequest {
public void setContent(String content) {
this.content = content;
}
public String getSmsTemplateCode() {
return smsTemplateCode;
}
public void setSmsTemplateCode(String smsTemplateCode) {
this.smsTemplateCode = smsTemplateCode;
}
}

View File

@ -7,16 +7,18 @@ public class NotificationPolicyInfo {
private String channel;
private String receiverType;
private Long templateId;
private String smsTemplateCode;
private String variablesJson;
private String status;
public NotificationPolicyInfo(Long id, String policyName, String eventCode, String channel, String receiverType, Long templateId, String variablesJson, String status) {
public NotificationPolicyInfo(Long id, String policyName, String eventCode, String channel, String receiverType, Long templateId, String smsTemplateCode, String variablesJson, String status) {
this.id = id;
this.policyName = policyName;
this.eventCode = eventCode;
this.channel = channel;
this.receiverType = receiverType;
this.templateId = templateId;
this.smsTemplateCode = smsTemplateCode;
this.variablesJson = variablesJson;
this.status = status;
}
@ -45,6 +47,10 @@ public class NotificationPolicyInfo {
return templateId;
}
public String getSmsTemplateCode() {
return smsTemplateCode;
}
public String getVariablesJson() {
return variablesJson;
}

View File

@ -4,6 +4,7 @@ import java.util.Map;
public class PlatformNotifyGatewayInfo {
private final Long id;
private final Long tenantId;
private final String channelCode;
private final String gatewayName;
private final String providerCode;
@ -14,6 +15,7 @@ public class PlatformNotifyGatewayInfo {
private final Map<String, Object> config;
public PlatformNotifyGatewayInfo(Long id,
Long tenantId,
String channelCode,
String gatewayName,
String providerCode,
@ -23,6 +25,7 @@ public class PlatformNotifyGatewayInfo {
String updatedAt,
Map<String, Object> config) {
this.id = id;
this.tenantId = tenantId;
this.channelCode = channelCode;
this.gatewayName = gatewayName;
this.providerCode = providerCode;
@ -37,6 +40,10 @@ public class PlatformNotifyGatewayInfo {
return id;
}
public Long getTenantId() {
return tenantId;
}
public String getChannelCode() {
return channelCode;
}

View File

@ -4,6 +4,7 @@ import java.util.Map;
public class PlatformNotifyGatewayResolvedConfig {
private final Long id;
private final Long tenantId;
private final String channelCode;
private final String gatewayName;
private final String providerCode;
@ -12,6 +13,7 @@ public class PlatformNotifyGatewayResolvedConfig {
private final Map<String, Object> config;
public PlatformNotifyGatewayResolvedConfig(Long id,
Long tenantId,
String channelCode,
String gatewayName,
String providerCode,
@ -19,6 +21,7 @@ public class PlatformNotifyGatewayResolvedConfig {
String remark,
Map<String, Object> config) {
this.id = id;
this.tenantId = tenantId;
this.channelCode = channelCode;
this.gatewayName = gatewayName;
this.providerCode = providerCode;
@ -31,6 +34,10 @@ public class PlatformNotifyGatewayResolvedConfig {
return id;
}
public Long getTenantId() {
return tenantId;
}
public String getChannelCode() {
return channelCode;
}

View File

@ -4,27 +4,33 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig;
import com.writeoff.module.notification.service.PlatformNotifyGatewayService;
import com.writeoff.security.AuthContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Properties;
@Component
public class EmailNotificationProvider implements NotificationChannelProvider {
private static final Logger log = LoggerFactory.getLogger(EmailNotificationProvider.class);
private static final String CONTEXT_TENANT_ID = "tenantId";
private final PlatformNotifyGatewayService gatewayService;
private final ObjectMapper objectMapper = new ObjectMapper();
private final String defaultSubject;
public EmailNotificationProvider(PlatformNotifyGatewayService gatewayService,
@Value("${app.notification.mail.default-subject:绯荤粺閫氱煡}") String defaultSubject) {
@Value("${app.notification.mail.default-subject:系统通知}") String defaultSubject) {
this.gatewayService = gatewayService;
this.defaultSubject = defaultSubject == null ? "绯荤粺閫氱煡" : defaultSubject.trim();
this.defaultSubject = defaultSubject == null ? "系统通知" : defaultSubject.trim();
}
@Override
@ -35,11 +41,12 @@ public class EmailNotificationProvider implements NotificationChannelProvider {
@Override
public NotificationSendResult send(String receiverRef, String payloadJson, Map<String, Object> context) {
if (receiverRef == null || receiverRef.trim().isEmpty()) {
return new NotificationSendResult(false, null, "INVALID_RECEIVER", "閭鍦板潃涓嶈兘涓虹┖");
return new NotificationSendResult(false, null, "INVALID_RECEIVER", "邮件接收人不能为空");
}
long start = System.currentTimeMillis();
try {
PlatformNotifyGatewayResolvedConfig gatewayConfig = gatewayService.resolveChannelConfig("EMAIL", true);
Long tenantId = resolveTenantId(context);
PlatformNotifyGatewayResolvedConfig gatewayConfig = gatewayService.resolveChannelConfig(tenantId, "EMAIL", true);
JavaMailSenderImpl sender = resolveMailSender(gatewayConfig);
Map<String, Object> runtimeConfig = gatewayConfig.getConfig();
String subject = textOr(runtimeConfig.get("defaultSubject"), defaultSubject);
@ -55,19 +62,19 @@ public class EmailNotificationProvider implements NotificationChannelProvider {
content = String.valueOf(body);
}
}
SimpleMailMessage message = new SimpleMailMessage();
String runtimeFrom = text(runtimeConfig.get("fromAddress"));
if (!runtimeFrom.isEmpty()) {
message.setFrom(runtimeFrom);
}
message.setTo(receiverRef.trim());
message.setSubject(subject);
message.setText(content == null ? "" : content);
log.info("email sending start, to={}, subject={}", receiverRef.trim(), subject);
String runtimeFromName = text(runtimeConfig.get("fromName"));
MimeMessage message = sender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, false, StandardCharsets.UTF_8.name());
applyFrom(helper, runtimeFrom, runtimeFromName);
helper.setTo(receiverRef.trim());
helper.setSubject(subject);
helper.setText(content == null ? "" : content, false);
//log.info("email sending start, tenantId={}, to={}, subject={}", tenantId, receiverRef.trim(), subject);
sender.send(message);
String id = "EMAIL-" + System.currentTimeMillis();
log.info("email sending success, to={}, messageId={}, elapsedMs={}", receiverRef.trim(), id, System.currentTimeMillis() - start);
return new NotificationSendResult(true, id, "SENT", "閭欢鍙戦€佹垚鍔?");
//log.info("email sending success, tenantId={}, to={}, messageId={}, elapsedMs={}", tenantId, receiverRef.trim(), id, System.currentTimeMillis() - start);
return new NotificationSendResult(true, id, "SENT", "邮件发送成功");
} catch (Exception ex) {
log.error("email sending failed, to={}, elapsedMs={}, reason={}", receiverRef.trim(), System.currentTimeMillis() - start, ex.getMessage(), ex);
return new NotificationSendResult(false, null, "SEND_FAILED", ex.getMessage());
@ -102,6 +109,36 @@ public class EmailNotificationProvider implements NotificationChannelProvider {
return sender;
}
private void applyFrom(MimeMessageHelper helper, String fromAddress, String fromName) throws Exception {
if (fromName.isEmpty()) {
helper.setFrom(fromAddress);
return;
}
helper.setFrom(new InternetAddress(fromAddress, fromName, StandardCharsets.UTF_8.name()));
}
private Long resolveTenantId(Map<String, Object> context) {
Long tenantId = toLong(context == null ? null : context.get(CONTEXT_TENANT_ID));
if (tenantId != null && tenantId > 0) {
return tenantId;
}
return AuthContext.requireTenantId();
}
private Long toLong(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number) {
return ((Number) value).longValue();
}
try {
return Long.valueOf(String.valueOf(value).trim());
} catch (Exception ex) {
return null;
}
}
private String text(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}

View File

@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig;
import com.writeoff.module.notification.service.PlatformNotifyGatewayService;
import com.writeoff.security.AuthContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@ -31,6 +32,8 @@ import java.util.UUID;
@Component
public class SmsNotificationProvider implements NotificationChannelProvider {
private static final Logger log = LoggerFactory.getLogger(SmsNotificationProvider.class);
private static final String CONTEXT_TENANT_ID = "tenantId";
private final PlatformNotifyGatewayService gatewayService;
private final ObjectMapper objectMapper = new ObjectMapper();
@ -48,10 +51,11 @@ public class SmsNotificationProvider implements NotificationChannelProvider {
if (receiverRef == null || receiverRef.trim().isEmpty()) {
return new NotificationSendResult(false, null, "INVALID_RECEIVER", "短信接收人不能为空");
}
PlatformNotifyGatewayResolvedConfig gatewayConfig = gatewayService.resolveChannelConfig("SMS", true);
Long tenantId = resolveTenantId(context);
PlatformNotifyGatewayResolvedConfig gatewayConfig = gatewayService.resolveChannelConfig(tenantId, "SMS", true);
if (gatewayConfig == null) {
String legacyId = "SMS-" + System.currentTimeMillis();
log.info("sms gateway config not enabled, fallback to legacy mock accept, receiver={}", receiverRef.trim());
log.info("sms gateway config not enabled, fallback to legacy mock accept, tenantId={}, receiver={}", tenantId, receiverRef.trim());
return new NotificationSendResult(true, legacyId, "LEGACY_ACCEPTED", "短信通道已按兼容模式受理");
}
if ("ALIYUN_SMS".equalsIgnoreCase(gatewayConfig.getProviderCode())) {
@ -60,7 +64,7 @@ public class SmsNotificationProvider implements NotificationChannelProvider {
boolean mockEnabled = boolValue(gatewayConfig.getConfig().get("mockEnabled"), "MOCK".equalsIgnoreCase(gatewayConfig.getProviderCode()));
String providerCode = gatewayConfig.getProviderCode() == null ? "SMS" : gatewayConfig.getProviderCode().trim().toUpperCase();
String id = providerCode + "-" + System.currentTimeMillis();
log.info("sms sending accepted, provider={}, receiver={}, mockEnabled={}", providerCode, receiverRef.trim(), mockEnabled);
log.info("sms sending accepted, tenantId={}, provider={}, receiver={}, mockEnabled={}", tenantId, providerCode, receiverRef.trim(), mockEnabled);
return new NotificationSendResult(true, id, mockEnabled ? "MOCK_ACCEPTED" : "ACCEPTED", mockEnabled ? "短信网关已模拟受理" : "短信网关已受理");
}
@ -83,7 +87,7 @@ public class SmsNotificationProvider implements NotificationChannelProvider {
String accessKeyId = text(config.get("accessKeyId"));
String accessKeySecret = text(config.get("accessKeySecret"));
String signName = text(config.get("signName"));
String templateCode = text(config.get("templateCode"));
String templateCode = resolveTemplateCode(config, payloadJson, context);
String regionId = textOr(config.get("regionId"), "cn-hangzhou");
if (accessKeyId.isEmpty() || accessKeySecret.isEmpty()) {
return new NotificationSendResult(false, null, "CONFIG_ERROR", "阿里云短信 AccessKey 配置不完整");
@ -129,6 +133,25 @@ public class SmsNotificationProvider implements NotificationChannelProvider {
}
}
private String resolveTemplateCode(Map<String, Object> config, String payloadJson, Map<String, Object> context) {
String templateCode = text(config.get("templateCode"));
try {
Map<String, Object> payload = parsePayload(payloadJson);
String payloadTemplateCode = text(payload.get("smsTemplateCode"));
if (!payloadTemplateCode.isEmpty()) {
return payloadTemplateCode;
}
} catch (Exception ignored) {
}
if (context != null) {
String contextTemplateCode = text(context.get("smsTemplateCode"));
if (!contextTemplateCode.isEmpty()) {
return contextTemplateCode;
}
}
return templateCode;
}
private Map<String, Object> parsePayload(String payloadJson) throws Exception {
if (payloadJson == null || payloadJson.trim().isEmpty() || !payloadJson.trim().startsWith("{")) {
return new LinkedHashMap<String, Object>();
@ -138,6 +161,18 @@ public class SmsNotificationProvider implements NotificationChannelProvider {
}
private Map<String, Object> buildAliyunTemplateParams(Map<String, Object> payload, Map<String, Object> context) {
Object explicitParams = payload == null ? null : payload.get("smsTemplateParams");
if (explicitParams instanceof Map) {
Map<String, Object> params = new LinkedHashMap<String, Object>();
params.putAll((Map<String, Object>) explicitParams);
if (context != null) {
Object taskId = context.get("taskId");
if (taskId != null && !params.containsKey("taskId")) {
params.put("taskId", String.valueOf(taskId));
}
}
return params;
}
Map<String, Object> params = new LinkedHashMap<String, Object>();
if (payload != null) {
params.putAll(payload);
@ -239,6 +274,28 @@ public class SmsNotificationProvider implements NotificationChannelProvider {
.replace("%7E", "~");
}
private Long resolveTenantId(Map<String, Object> context) {
Long tenantId = toLong(context == null ? null : context.get(CONTEXT_TENANT_ID));
if (tenantId != null && tenantId > 0) {
return tenantId;
}
return AuthContext.requireTenantId();
}
private Long toLong(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number) {
return ((Number) value).longValue();
}
try {
return Long.valueOf(String.valueOf(value).trim());
} catch (Exception ex) {
return null;
}
}
private boolean boolValue(Object value, boolean fallback) {
if (value == null) {
return fallback;

View File

@ -9,7 +9,9 @@ import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
public class InAppNotificationService {
@ -28,22 +30,61 @@ public class InAppNotificationService {
this.jdbcTemplate = jdbcTemplate;
}
public PageResult<InAppNotificationInfo> listMine() {
public PageResult<InAppNotificationInfo> listMine(int pageNo, int pageSize, boolean onlyUnread) {
Long userId = AuthContext.userId();
String userRef = userId == null ? "" : ("user-" + userId);
List<InAppNotificationInfo> list = jdbcTemplate.query(
"SELECT id, title, content, status, " +
"DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at, " +
"DATE_FORMAT(read_at, '%Y-%m-%d %H:%i:%s') AS read_at " +
"FROM in_app_notification " +
"WHERE tenant_id=? AND is_deleted=0 AND (receiver_ref='ALL' OR receiver_ref=? OR receiver_user_id=?) " +
"ORDER BY id DESC LIMIT 200",
ROW_MAPPER,
int safePage = Math.max(pageNo, 1);
int safeSize = Math.min(Math.max(pageSize, 1), 200);
int offset = (safePage - 1) * safeSize;
String unreadClause = onlyUnread ? " AND status='UNREAD'" : "";
String baseSql =
" FROM in_app_notification " +
"WHERE tenant_id=? AND is_deleted=0 AND (receiver_ref='ALL' OR receiver_ref=? OR receiver_user_id=?)" +
unreadClause;
Integer total = jdbcTemplate.queryForObject(
"SELECT COUNT(1)" + baseSql,
Integer.class,
tenantId(),
userRef,
userId
);
return new PageResult<InAppNotificationInfo>(list, list.size(), 1, 200);
long totalCount = total == null ? 0 : total;
List<InAppNotificationInfo> list = jdbcTemplate.query(
"SELECT id, title, content, status, " +
"DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at, " +
"DATE_FORMAT(read_at, '%Y-%m-%d %H:%i:%s') AS read_at " +
baseSql +
" ORDER BY id DESC LIMIT ? OFFSET ?",
ROW_MAPPER,
tenantId(),
userRef,
userId,
safeSize,
offset
);
return new PageResult<InAppNotificationInfo>(list, totalCount, safePage, safeSize);
}
public Map<String, Object> summaryMine() {
Long userId = AuthContext.userId();
String userRef = userId == null ? "" : ("user-" + userId);
Object[] args = new Object[]{tenantId(), userRef, userId};
Integer total = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM in_app_notification " +
"WHERE tenant_id=? AND is_deleted=0 AND (receiver_ref='ALL' OR receiver_ref=? OR receiver_user_id=?)",
Integer.class,
args
);
Integer unread = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM in_app_notification " +
"WHERE tenant_id=? AND is_deleted=0 AND status='UNREAD' AND (receiver_ref='ALL' OR receiver_ref=? OR receiver_user_id=?)",
Integer.class,
args
);
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("total", total == null ? 0 : total);
data.put("unread", unread == null ? 0 : unread);
return data;
}
@Transactional(rollbackFor = Exception.class)

View File

@ -1,6 +1,7 @@
package com.writeoff.module.notification.service;
import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig;
import com.writeoff.security.AuthContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@ -24,18 +25,23 @@ public class NotificationDeliveryProtectionService {
}
public GuardDecision checkBeforeSend(String channelCode, String receiverRef) {
return checkBeforeSend(tenantId(), channelCode, receiverRef);
}
public GuardDecision checkBeforeSend(Long tenantId, String channelCode, String receiverRef) {
Long resolvedTenantId = requireTenantId(tenantId);
String channel = normalizeChannel(channelCode);
if (!isProtectedChannel(channel)) {
return GuardDecision.allow();
}
BreakerState breakerState = loadBreakerState(channel);
BreakerState breakerState = loadBreakerState(resolvedTenantId, channel);
if (breakerState.breakerUntil != null && LocalDateTime.now().isBefore(breakerState.breakerUntil)) {
String untilText = breakerState.breakerUntil.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return GuardDecision.block("BREAKER_OPEN", "渠道熔断中,请在 " + untilText + " 后重试");
}
if ("SMS".equals(channel)) {
SmsGuardConfig config = resolveSmsConfig(channel);
GuardRecord record = loadGuardRecord(channel, normalizeReceiver(receiverRef));
SmsGuardConfig config = resolveSmsConfig(resolvedTenantId, channel);
GuardRecord record = loadGuardRecord(resolvedTenantId, channel, normalizeReceiver(receiverRef));
LocalDateTime now = LocalDateTime.now();
if (record.lastSentAt != null && config.quietPeriodSeconds > 0 && now.isBefore(record.lastSentAt.plusSeconds(config.quietPeriodSeconds))) {
return GuardDecision.block("SMS_RATE_LIMIT_WINDOW", "同一手机号发送过于频繁,请稍后再试");
@ -48,6 +54,11 @@ public class NotificationDeliveryProtectionService {
}
public void recordSuccess(String channelCode, String receiverRef) {
recordSuccess(tenantId(), channelCode, receiverRef);
}
public void recordSuccess(Long tenantId, String channelCode, String receiverRef) {
Long resolvedTenantId = requireTenantId(tenantId);
String channel = normalizeChannel(channelCode);
if (!isProtectedChannel(channel)) {
return;
@ -55,43 +66,54 @@ public class NotificationDeliveryProtectionService {
if ("SMS".equals(channel)) {
String normalizedReceiver = normalizeReceiver(receiverRef);
jdbcTemplate.update(
"INSERT INTO platform_notify_delivery_guard (channel_code, receiver_ref, stat_date, daily_count, last_sent_at) " +
"VALUES (?, ?, CURRENT_DATE(), 1, CURRENT_TIMESTAMP) " +
"INSERT INTO tenant_notify_delivery_guard (tenant_id, channel_code, receiver_ref, stat_date, daily_count, last_sent_at) " +
"VALUES (?, ?, ?, CURRENT_DATE(), 1, CURRENT_TIMESTAMP) " +
"ON DUPLICATE KEY UPDATE daily_count=daily_count+1, last_sent_at=VALUES(last_sent_at), updated_at=CURRENT_TIMESTAMP",
resolvedTenantId,
channel,
normalizedReceiver
);
}
jdbcTemplate.update(
"UPDATE platform_notify_circuit_breaker SET consecutive_failures=0, breaker_until=NULL, last_failure_message=NULL, updated_at=CURRENT_TIMESTAMP WHERE channel_code=?",
"UPDATE tenant_notify_circuit_breaker SET consecutive_failures=0, breaker_until=NULL, last_failure_message=NULL, updated_at=CURRENT_TIMESTAMP " +
"WHERE tenant_id=? AND channel_code=?",
resolvedTenantId,
channel
);
}
public void recordFailure(String channelCode, String failureMessage) {
recordFailure(tenantId(), channelCode, failureMessage);
}
public void recordFailure(Long tenantId, String channelCode, String failureMessage) {
Long resolvedTenantId = requireTenantId(tenantId);
String channel = normalizeChannel(channelCode);
if (!isProtectedChannel(channel)) {
return;
}
BreakerState state = loadBreakerState(channel);
int threshold = resolveFailureThreshold(channel);
int cooldownSeconds = resolveBreakerCooldownSeconds(channel);
BreakerState state = loadBreakerState(resolvedTenantId, channel);
int threshold = resolveFailureThreshold(resolvedTenantId, channel);
int cooldownSeconds = resolveBreakerCooldownSeconds(resolvedTenantId, channel);
int nextFailures = state.consecutiveFailures + 1;
LocalDateTime breakerUntil = nextFailures >= threshold
? LocalDateTime.now().plusSeconds(Math.max(cooldownSeconds, 1))
: null;
if (state.exists) {
jdbcTemplate.update(
"UPDATE platform_notify_circuit_breaker SET consecutive_failures=?, breaker_until=?, last_failure_message=?, updated_at=CURRENT_TIMESTAMP WHERE channel_code=?",
"UPDATE tenant_notify_circuit_breaker SET consecutive_failures=?, breaker_until=?, last_failure_message=?, updated_at=CURRENT_TIMESTAMP " +
"WHERE tenant_id=? AND channel_code=?",
nextFailures,
toTimestamp(breakerUntil),
trimMessage(failureMessage),
resolvedTenantId,
channel
);
return;
}
jdbcTemplate.update(
"INSERT INTO platform_notify_circuit_breaker (channel_code, consecutive_failures, breaker_until, last_failure_message) VALUES (?, ?, ?, ?)",
"INSERT INTO tenant_notify_circuit_breaker (tenant_id, channel_code, consecutive_failures, breaker_until, last_failure_message) VALUES (?, ?, ?, ?, ?)",
resolvedTenantId,
channel,
nextFailures,
toTimestamp(breakerUntil),
@ -99,9 +121,10 @@ public class NotificationDeliveryProtectionService {
);
}
private GuardRecord loadGuardRecord(String channelCode, String receiverRef) {
private GuardRecord loadGuardRecord(Long tenantId, String channelCode, String receiverRef) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT daily_count, last_sent_at FROM platform_notify_delivery_guard WHERE channel_code=? AND receiver_ref=? AND stat_date=CURRENT_DATE() LIMIT 1",
"SELECT daily_count, last_sent_at FROM tenant_notify_delivery_guard WHERE tenant_id=? AND channel_code=? AND receiver_ref=? AND stat_date=CURRENT_DATE() LIMIT 1",
tenantId,
channelCode,
receiverRef
);
@ -112,9 +135,10 @@ public class NotificationDeliveryProtectionService {
return new GuardRecord(intValue(row.get("daily_count"), 0), toLocalDateTime(row.get("last_sent_at")));
}
private BreakerState loadBreakerState(String channelCode) {
private BreakerState loadBreakerState(Long tenantId, String channelCode) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT consecutive_failures, breaker_until FROM platform_notify_circuit_breaker WHERE channel_code=? LIMIT 1",
"SELECT consecutive_failures, breaker_until FROM tenant_notify_circuit_breaker WHERE tenant_id=? AND channel_code=? LIMIT 1",
tenantId,
channelCode
);
if (rows.isEmpty()) {
@ -124,8 +148,8 @@ public class NotificationDeliveryProtectionService {
return new BreakerState(true, intValue(row.get("consecutive_failures"), 0), toLocalDateTime(row.get("breaker_until")));
}
private SmsGuardConfig resolveSmsConfig(String channelCode) {
PlatformNotifyGatewayResolvedConfig resolved = gatewayService.resolveChannelConfig(channelCode, false);
private SmsGuardConfig resolveSmsConfig(Long tenantId, String channelCode) {
PlatformNotifyGatewayResolvedConfig resolved = gatewayService.resolveChannelConfig(tenantId, channelCode, false);
Map<String, Object> config = resolved == null ? null : resolved.getConfig();
return new SmsGuardConfig(
intValue(config == null ? null : config.get("quietPeriodSeconds"), 30),
@ -133,14 +157,14 @@ public class NotificationDeliveryProtectionService {
);
}
private int resolveFailureThreshold(String channelCode) {
PlatformNotifyGatewayResolvedConfig resolved = gatewayService.resolveChannelConfig(channelCode, false);
private int resolveFailureThreshold(Long tenantId, String channelCode) {
PlatformNotifyGatewayResolvedConfig resolved = gatewayService.resolveChannelConfig(tenantId, channelCode, false);
Map<String, Object> config = resolved == null ? null : resolved.getConfig();
return Math.max(intValue(config == null ? null : config.get("failureThreshold"), 3), 1);
}
private int resolveBreakerCooldownSeconds(String channelCode) {
PlatformNotifyGatewayResolvedConfig resolved = gatewayService.resolveChannelConfig(channelCode, false);
private int resolveBreakerCooldownSeconds(Long tenantId, String channelCode) {
PlatformNotifyGatewayResolvedConfig resolved = gatewayService.resolveChannelConfig(tenantId, channelCode, false);
Map<String, Object> config = resolved == null ? null : resolved.getConfig();
return Math.max(intValue(config == null ? null : config.get("breakerCooldownSeconds"), 300), 1);
}
@ -200,6 +224,17 @@ public class NotificationDeliveryProtectionService {
}
}
private Long tenantId() {
return AuthContext.requireTenantId();
}
private Long requireTenantId(Long tenantId) {
if (tenantId == null || tenantId <= 0) {
throw new IllegalStateException("tenant context required");
}
return tenantId;
}
public static final class GuardDecision {
private final boolean allowed;
private final String code;

View File

@ -113,6 +113,7 @@ public class NotificationDispatchService {
if (filterPolicyId != null && filterPolicyId > 0) {
policies = jdbcTemplate.queryForList(
"SELECT p.id, p.channel, p.receiver_type, p.template_id, p.variables_json, p.policy_name, " +
"p.sms_template_code, " +
"tt.template_name, tt.subject_template, tt.title_template, tt.content_template " +
"FROM notification_policy p " +
"JOIN notification_text_template tt ON tt.tenant_id=p.tenant_id AND tt.id=p.template_id AND tt.is_deleted=0 AND tt.status='ENABLED' " +
@ -124,6 +125,7 @@ public class NotificationDispatchService {
} else {
policies = jdbcTemplate.queryForList(
"SELECT p.id, p.channel, p.receiver_type, p.template_id, p.variables_json, p.policy_name, " +
"p.sms_template_code, " +
"tt.template_name, tt.subject_template, tt.title_template, tt.content_template " +
"FROM notification_policy p " +
"JOIN notification_text_template tt ON tt.tenant_id=p.tenant_id AND tt.id=p.template_id AND tt.is_deleted=0 AND tt.status='ENABLED' " +
@ -475,6 +477,7 @@ public class NotificationDispatchService {
merged.put("policyId", policy.get("id"));
merged.put("policyName", policy.get("policy_name"));
merged.put("templateId", policy.get("template_id"));
merged.put("smsTemplateCode", policy.get("sms_template_code"));
merged.put("templateName", policy.get("template_name"));
String policyName = policy.get("policy_name") == null ? "系统通知" : String.valueOf(policy.get("policy_name"));
@ -512,6 +515,10 @@ public class NotificationDispatchService {
payload.put("title", finalTitle);
payload.put("content", finalContent);
payload.put("message", finalContent);
Map<String, Object> smsTemplateParams = resolveSmsTemplateParams(policyVars.get("smsTemplateParams"), payload);
if (!smsTemplateParams.isEmpty()) {
payload.put("smsTemplateParams", smsTemplateParams);
}
try {
return objectMapper.writeValueAsString(payload);
} catch (Exception ex) {
@ -519,6 +526,29 @@ public class NotificationDispatchService {
}
}
private Map<String, Object> resolveSmsTemplateParams(Object rawConfig, Map<String, Object> vars) {
if (!(rawConfig instanceof Map)) {
return new LinkedHashMap<String, Object>();
}
Map<?, ?> rawMap = (Map<?, ?>) rawConfig;
Map<String, Object> resolved = new LinkedHashMap<String, Object>();
for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
String key = entry.getKey() == null ? "" : String.valueOf(entry.getKey()).trim();
if (key.isEmpty()) {
continue;
}
resolved.put(key, resolveSmsTemplateParamValue(entry.getValue(), vars));
}
return resolved;
}
private Object resolveSmsTemplateParamValue(Object value, Map<String, Object> vars) {
if (value instanceof String) {
return resolvePlaceholders((String) value, vars);
}
return value;
}
private Map<String, Object> parseVariables(String variablesJson) {
if (variablesJson == null || variablesJson.trim().isEmpty()) {
return new LinkedHashMap<String, Object>();

View File

@ -24,6 +24,7 @@ public class NotificationPolicyService {
rs.getString("channel"),
rs.getString("receiver_type"),
rs.getLong("template_id"),
rs.getString("sms_template_code"),
rs.getString("variables_json"),
rs.getString("status")
);
@ -58,15 +59,17 @@ public class NotificationPolicyService {
public NotificationPolicyInfo create(CreateNotificationPolicyRequest request) {
validateTextTemplateExists(request.getTemplateId());
String status = normalizeStatus(request.getStatus());
String smsTemplateCode = normalizeSmsTemplateCode(request.getChannel(), request.getSmsTemplateCode());
jdbcTemplate.update(
"INSERT INTO notification_policy (tenant_id, policy_name, event_code, channel, receiver_type, template_id, variables_json, status, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO notification_policy (tenant_id, policy_name, event_code, channel, receiver_type, template_id, sms_template_code, variables_json, status, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
tenantId(),
request.getPolicyName(),
request.getEventCode(),
request.getChannel(),
request.getReceiverType(),
request.getTemplateId(),
smsTemplateCode,
request.getVariablesJson(),
status,
safeUserId(),
@ -83,14 +86,16 @@ public class NotificationPolicyService {
assertExists(id);
validateTextTemplateExists(request.getTemplateId());
String status = normalizeStatus(request.getStatus());
String smsTemplateCode = normalizeSmsTemplateCode(request.getChannel(), request.getSmsTemplateCode());
jdbcTemplate.update(
"UPDATE notification_policy SET policy_name=?, event_code=?, channel=?, receiver_type=?, template_id=?, variables_json=?, status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? " +
"UPDATE notification_policy SET policy_name=?, event_code=?, channel=?, receiver_type=?, template_id=?, sms_template_code=?, variables_json=?, status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? " +
"WHERE tenant_id=? AND id=?",
request.getPolicyName(),
request.getEventCode(),
request.getChannel(),
request.getReceiverType(),
request.getTemplateId(),
smsTemplateCode,
request.getVariablesJson(),
status,
safeUserId(),
@ -191,6 +196,17 @@ public class NotificationPolicyService {
return val;
}
private String normalizeSmsTemplateCode(String channel, String smsTemplateCode) {
if (!"SMS".equalsIgnoreCase(channel == null ? "" : channel.trim())) {
return null;
}
String normalized = smsTemplateCode == null ? "" : smsTemplateCode.trim();
if (normalized.isEmpty()) {
throw new BusinessException(10001, "短信模板编码不能为空");
}
return normalized;
}
private void validateTextTemplateExists(Long templateId) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM notification_text_template WHERE tenant_id=? AND id=? AND is_deleted=0",

View File

@ -28,10 +28,16 @@ public class PlatformNotifyGatewayService {
}
public List<PlatformNotifyGatewayInfo> list() {
return list(tenantId());
}
public List<PlatformNotifyGatewayInfo> list(Long tenantId) {
Long resolvedTenantId = requireTenantId(tenantId);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, channel_code, gateway_name, provider_code, status, config_json, secret_config_cipher, remark, " +
"SELECT id, tenant_id, channel_code, gateway_name, provider_code, status, config_json, secret_config_cipher, remark, " +
"DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " +
"FROM platform_notify_gateway WHERE is_deleted=0 ORDER BY id ASC"
"FROM tenant_notify_gateway WHERE tenant_id=? AND is_deleted=0 ORDER BY id ASC",
resolvedTenantId
);
List<PlatformNotifyGatewayInfo> list = new ArrayList<PlatformNotifyGatewayInfo>();
for (Map<String, Object> row : rows) {
@ -41,8 +47,13 @@ public class PlatformNotifyGatewayService {
}
public PlatformNotifyGatewayInfo save(String channelCode, SavePlatformNotifyGatewayRequest request) {
return save(tenantId(), channelCode, request);
}
public PlatformNotifyGatewayInfo save(Long tenantId, String channelCode, SavePlatformNotifyGatewayRequest request) {
Long resolvedTenantId = requireTenantId(tenantId);
String normalizedChannel = normalizeChannel(channelCode);
Map<String, Object> existing = findRow(normalizedChannel);
Map<String, Object> existing = findRow(resolvedTenantId, normalizedChannel);
if (existing.isEmpty()) {
throw new BusinessException(10003, "通知网关不存在");
}
@ -54,12 +65,12 @@ public class PlatformNotifyGatewayService {
);
String gatewayName = normalizeText(
request == null ? null : request.getGatewayName(),
normalizedChannel.equals("EMAIL") ? "邮件网关" : "短信网关"
"EMAIL".equals(normalizedChannel) ? "邮件网关" : "短信网关"
);
String remark = request == null ? null : normalizeNullableText(request.getRemark());
jdbcTemplate.update(
"UPDATE platform_notify_gateway SET gateway_name=?, provider_code=?, status=?, config_json=?, secret_config_cipher=?, remark=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " +
"WHERE channel_code=? AND is_deleted=0",
"UPDATE tenant_notify_gateway SET gateway_name=?, provider_code=?, status=?, config_json=?, secret_config_cipher=?, remark=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " +
"WHERE tenant_id=? AND channel_code=? AND is_deleted=0",
gatewayName,
bundle.providerCode,
bundle.status,
@ -67,14 +78,20 @@ public class PlatformNotifyGatewayService {
cryptoService.encrypt(toJson(bundle.secretConfig)),
remark,
safeUserId(),
resolvedTenantId,
normalizedChannel
);
return toInfo(findRow(normalizedChannel));
return toInfo(findRow(resolvedTenantId, normalizedChannel));
}
public PlatformNotifyGatewayResolvedConfig resolveChannelConfig(String channelCode, boolean requireEnabled) {
return resolveChannelConfig(tenantId(), channelCode, requireEnabled);
}
public PlatformNotifyGatewayResolvedConfig resolveChannelConfig(Long tenantId, String channelCode, boolean requireEnabled) {
Long resolvedTenantId = requireTenantId(tenantId);
String normalizedChannel = normalizeChannel(channelCode);
Map<String, Object> row = findRow(normalizedChannel);
Map<String, Object> row = findRow(resolvedTenantId, normalizedChannel);
if (row.isEmpty()) {
return null;
}
@ -85,6 +102,7 @@ public class PlatformNotifyGatewayService {
Map<String, Object> mergedConfig = mergeConfig(row);
return new PlatformNotifyGatewayResolvedConfig(
toLong(row.get("id")),
toLong(row.get("tenant_id")),
normalizedChannel,
String.valueOf(row.get("gateway_name")),
String.valueOf(row.get("provider_code")),
@ -94,10 +112,40 @@ public class PlatformNotifyGatewayService {
);
}
public void ensureTenantDefaults(Long tenantId) {
Long resolvedTenantId = requireTenantId(tenantId);
ensureTenantDefaultGateway(resolvedTenantId, "EMAIL", "邮件网关", "SMTP", "当前租户邮件网关配置");
ensureTenantDefaultGateway(resolvedTenantId, "SMS", "短信网关", "MOCK", "当前租户短信网关配置");
}
private void ensureTenantDefaultGateway(Long tenantId, String channelCode, String gatewayName, String providerCode, String remark) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM tenant_notify_gateway WHERE tenant_id=? AND channel_code=? AND is_deleted=0",
Integer.class,
tenantId,
channelCode
);
if (count != null && count > 0) {
return;
}
jdbcTemplate.update(
"INSERT INTO tenant_notify_gateway (tenant_id, channel_code, gateway_name, provider_code, status, config_json, secret_config_cipher, remark, is_deleted, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, 'DISABLED', '{}', '', ?, 0, ?, ?)",
tenantId,
channelCode,
gatewayName,
providerCode,
remark,
safeUserId(),
safeUserId()
);
}
private PlatformNotifyGatewayInfo toInfo(Map<String, Object> row) {
Map<String, Object> mergedConfig = mergeConfig(row);
return new PlatformNotifyGatewayInfo(
toLong(row.get("id")),
toLong(row.get("tenant_id")),
String.valueOf(row.get("channel_code")),
String.valueOf(row.get("gateway_name")),
String.valueOf(row.get("provider_code")),
@ -120,6 +168,7 @@ public class PlatformNotifyGatewayService {
publicConfig.put("port", normalizeInt(safeInput.get("port"), 587));
publicConfig.put("protocol", normalizeText(safeInput.get("protocol"), "smtp"));
publicConfig.put("fromAddress", normalizeNullableText(safeInput.get("fromAddress")));
publicConfig.put("fromName", normalizeNullableText(safeInput.get("fromName")));
publicConfig.put("defaultSubject", normalizeText(safeInput.get("defaultSubject"), "系统通知"));
publicConfig.put("smtpAuth", normalizeBoolean(safeInput.get("smtpAuth"), true));
publicConfig.put("starttlsEnable", normalizeBoolean(safeInput.get("starttlsEnable"), true));
@ -150,7 +199,6 @@ public class PlatformNotifyGatewayService {
boolean mockEnabled = normalizeBoolean(safeInput.get("mockEnabled"), "MOCK".equals(providerCode));
publicConfig.put("endpoint", normalizeNullableText(safeInput.get("endpoint")));
publicConfig.put("signName", normalizeNullableText(safeInput.get("signName")));
publicConfig.put("templateCode", normalizeNullableText(safeInput.get("templateCode")));
publicConfig.put("regionId", normalizeText(safeInput.get("regionId"), "cn-hangzhou"));
publicConfig.put("mockEnabled", mockEnabled);
publicConfig.put("quietPeriodSeconds", normalizeInt(safeInput.get("quietPeriodSeconds"), 30));
@ -179,11 +227,12 @@ public class PlatformNotifyGatewayService {
return merged;
}
private Map<String, Object> findRow(String channelCode) {
private Map<String, Object> findRow(Long tenantId, String channelCode) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, channel_code, gateway_name, provider_code, status, config_json, secret_config_cipher, remark, " +
"SELECT id, tenant_id, channel_code, gateway_name, provider_code, status, config_json, secret_config_cipher, remark, " +
"DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " +
"FROM platform_notify_gateway WHERE channel_code=? AND is_deleted=0 LIMIT 1",
"FROM tenant_notify_gateway WHERE tenant_id=? AND channel_code=? AND is_deleted=0 LIMIT 1",
tenantId,
channelCode
);
return rows.isEmpty() ? new LinkedHashMap<String, Object>() : rows.get(0);
@ -298,6 +347,17 @@ public class PlatformNotifyGatewayService {
return userId == null ? 0L : userId;
}
private Long tenantId() {
return AuthContext.requireTenantId();
}
private Long requireTenantId(Long tenantId) {
if (tenantId == null || tenantId <= 0) {
throw new IllegalStateException("tenant context required");
}
return tenantId;
}
private static final class SaveConfigBundle {
private final String providerCode;
private final String status;

View File

@ -4,12 +4,15 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.writeoff.common.exception.BusinessException;
import com.writeoff.module.notification.dto.TestPlatformNotifyGatewayRequest;
import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig;
import com.writeoff.module.notification.provider.SmsNotificationProvider;
import com.writeoff.module.notification.provider.NotificationSendResult;
import org.springframework.mail.SimpleMailMessage;
import com.writeoff.module.notification.provider.SmsNotificationProvider;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Properties;
@ -44,6 +47,7 @@ public class PlatformNotifyGatewayTestService {
Map<String, Object> config = gateway.getConfig();
String host = text(config.get("host"));
String fromAddress = text(config.get("fromAddress"));
String fromName = text(config.get("fromName"));
boolean smtpAuth = boolValue(config.get("smtpAuth"), true);
String username = text(config.get("username"));
String password = text(config.get("password"));
@ -77,14 +81,16 @@ public class PlatformNotifyGatewayTestService {
props.put("mail.smtp.timeout", String.valueOf(intValue(config.get("timeoutMs"), 5000)));
props.put("mail.smtp.writetimeout", String.valueOf(intValue(config.get("writeTimeoutMs"), 5000)));
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(fromAddress);
message.setTo(request.getReceiverRef().trim());
message.setSubject(textOr(request.getSubject(), "通知网关测试"));
message.setText(textOr(request.getContent(), "这是一封来自平台通知网关配置中心的测试邮件。"));
MimeMessage message = sender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, false, StandardCharsets.UTF_8.name());
applyFrom(helper, fromAddress, fromName);
helper.setTo(request.getReceiverRef().trim());
helper.setSubject(textOr(request.getSubject(), "通知网关测试"));
helper.setText(textOr(request.getContent(), "这是一封来自当前租户通知网关配置中心的测试邮件。"), false);
sender.send(message);
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("tenantId", gateway.getTenantId());
data.put("channelCode", gateway.getChannelCode());
data.put("providerCode", gateway.getProviderCode());
data.put("receiverRef", request.getReceiverRef().trim());
@ -112,18 +118,22 @@ public class PlatformNotifyGatewayTestService {
if (text(config.get("accessKeySecret")).isEmpty()) {
throw new BusinessException(10001, "短信 AccessKeySecret 不能为空");
}
if ("ALIYUN_SMS".equalsIgnoreCase(gateway.getProviderCode()) && text(request.getSmsTemplateCode()).isEmpty()) {
throw new BusinessException(10001, "测试短信模板编码不能为空");
}
}
if (!isPhoneLike(request.getReceiverRef())) {
throw new BusinessException(10001, "短信测试接收目标必须为手机号");
}
Map<String, Object> payload = new LinkedHashMap<String, Object>();
payload.put("subject", textOr(request.getSubject(), "通知网关测试"));
payload.put("content", textOr(request.getContent(), "这是一条来自平台通知网关配置中心的测试短信。"));
payload.put("content", textOr(request.getContent(), "这是一条来自当前租户通知网关配置中心的测试短信。"));
payload.put("signName", text(config.get("signName")));
payload.put("templateCode", text(config.get("templateCode")));
payload.put("smsTemplateCode", textOr(request.getSmsTemplateCode(), text(config.get("templateCode"))));
try {
if ("ALIYUN_SMS".equalsIgnoreCase(gateway.getProviderCode()) && !mockEnabled) {
Map<String, Object> sendContext = new LinkedHashMap<String, Object>();
sendContext.put("tenantId", gateway.getTenantId());
sendContext.put("outId", "test-" + System.currentTimeMillis());
NotificationSendResult result = smsNotificationProvider.send(
request.getReceiverRef().trim(),
@ -134,6 +144,7 @@ public class PlatformNotifyGatewayTestService {
throw new BusinessException(10001, result == null ? "测试短信发送失败" : result.getProviderMessage());
}
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("tenantId", gateway.getTenantId());
data.put("channelCode", gateway.getChannelCode());
data.put("providerCode", gateway.getProviderCode());
data.put("receiverRef", request.getReceiverRef().trim());
@ -144,6 +155,7 @@ public class PlatformNotifyGatewayTestService {
return data;
}
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("tenantId", gateway.getTenantId());
data.put("channelCode", gateway.getChannelCode());
data.put("providerCode", gateway.getProviderCode());
data.put("receiverRef", request.getReceiverRef().trim());
@ -152,6 +164,8 @@ public class PlatformNotifyGatewayTestService {
data.put("payloadJson", objectMapper.writeValueAsString(payload));
data.put("message", mockEnabled ? "测试短信已模拟受理" : "测试短信参数校验通过,当前版本按模拟模式返回受理");
return data;
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
throw new BusinessException(10001, "测试短信构造失败");
}
@ -173,6 +187,14 @@ public class PlatformNotifyGatewayTestService {
return true;
}
private void applyFrom(MimeMessageHelper helper, String fromAddress, String fromName) throws Exception {
if (fromName.isEmpty()) {
helper.setFrom(fromAddress);
return;
}
helper.setFrom(new InternetAddress(fromAddress, fromName, StandardCharsets.UTF_8.name()));
}
private String text(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}

View File

@ -30,8 +30,11 @@ public class ProjectController {
@GetMapping
public ApiResponse<PageResult<Project>> list(@RequestParam(value = "parentOnly", required = false) Boolean parentOnly,
@RequestParam(value = "includeDeleted", required = false) Boolean includeDeleted) {
return ApiResponse.success(projectService.list(parentOnly, includeDeleted));
@RequestParam(value = "includeDeleted", required = false) Boolean includeDeleted,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "pageNo", required = false) Integer pageNo,
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
return ApiResponse.success(projectService.list(parentOnly, includeDeleted, keyword, pageNo, pageSize));
}
@GetMapping("/{id}/children")

View File

@ -3,6 +3,7 @@ package com.writeoff.module.project.dto;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Max;
import java.time.LocalDate;
public class CreateProjectRequest {
@ -23,6 +24,10 @@ public class CreateProjectRequest {
private String overBudgetApprovalChainJson;
private Double laborFeeRatio;
private Double cateringFeeRatio;
@Min(value = 1, message = "劳务费协议签署类型不合法")
@Max(value = 2, message = "劳务费协议签署类型不合法")
private Integer laborAgreementSignType;
private Boolean allowProjectOverBudget;
private String invoiceInfo;
private String expenseRatioJson;
@ -118,6 +123,22 @@ public class CreateProjectRequest {
this.laborFeeRatio = laborFeeRatio;
}
public Double getCateringFeeRatio() {
return cateringFeeRatio;
}
public void setCateringFeeRatio(Double cateringFeeRatio) {
this.cateringFeeRatio = cateringFeeRatio;
}
public Integer getLaborAgreementSignType() {
return laborAgreementSignType;
}
public void setLaborAgreementSignType(Integer laborAgreementSignType) {
this.laborAgreementSignType = laborAgreementSignType;
}
public Boolean getAllowProjectOverBudget() {
return allowProjectOverBudget;
}

View File

@ -61,6 +61,8 @@ public class Project {
private int writeOffCompletedCount;
/** 劳务费用占比 */
private double laborFeeRatio;
private double cateringFeeRatio;
private int laborAgreementSignType = 1;
/** 是否允许超过项目总费用 */
private boolean allowProjectOverBudget;
/** 发票信息快照(便于一键复制) */
@ -262,6 +264,10 @@ public class Project {
return budgetCent;
}
public void setBudgetCent(long budgetCent) {
this.budgetCent = budgetCent;
}
public int getMeetingTotal() {
return meetingTotal;
}
@ -308,6 +314,14 @@ public class Project {
return laborFeeRatio;
}
public double getCateringFeeRatio() {
return cateringFeeRatio;
}
public int getLaborAgreementSignType() {
return laborAgreementSignType;
}
public boolean isAllowProjectOverBudget() {
return allowProjectOverBudget;
}
@ -424,6 +438,14 @@ public class Project {
this.projectFeeJson = projectFeeJson;
}
public void setCateringFeeRatio(double cateringFeeRatio) {
this.cateringFeeRatio = cateringFeeRatio;
}
public void setLaborAgreementSignType(int laborAgreementSignType) {
this.laborAgreementSignType = laborAgreementSignType;
}
public boolean isDeleted() {
return deleted;
}

View File

@ -67,6 +67,8 @@ public class InMemoryProjectRepository implements ProjectRepository {
newProject.setHostExecutorUsers(project.getHostExecutorUsers());
newProject.setPartnerOwnerUsers(project.getPartnerOwnerUsers());
newProject.setPartnerExecutorUsers(project.getPartnerExecutorUsers());
newProject.setLaborAgreementSignType(project.getLaborAgreementSignType());
newProject.setCateringFeeRatio(project.getCateringFeeRatio());
newProject.setProjectFeeJson(project.getProjectFeeJson());
store.put(newProject.getId(), newProject);
return newProject;

View File

@ -67,6 +67,8 @@ public class JdbcProjectRepository implements ProjectRepository {
p.setHostExecutorUsers(rs.getString("host_executor_users"));
p.setPartnerOwnerUsers(rs.getString("partner_owner_users"));
p.setPartnerExecutorUsers(rs.getString("partner_executor_users"));
p.setLaborAgreementSignType(rs.getObject("labor_agreement_sign_type") == null ? 1 : rs.getInt("labor_agreement_sign_type"));
p.setCateringFeeRatio(rs.getBigDecimal("catering_fee_ratio") == null ? 0d : rs.getBigDecimal("catering_fee_ratio").doubleValue());
p.setProjectFeeJson(rs.getString("project_fee_json"));
p.setDeleted(rs.getInt("is_deleted") == 1);
return p;
@ -82,8 +84,8 @@ public class JdbcProjectRepository implements ProjectRepository {
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(
"INSERT INTO project (tenant_id, project_name, parent_project_id, start_date, end_date, host_enterprise_name, partner_enterprise_id, budget_cent, meeting_total, meeting_completed_count, allow_meeting_over_budget, over_budget_threshold_ratio, over_budget_approval_chain_json, budget_execution_ratio, risk_flags_json, write_off_not_started_count, write_off_in_progress_count, write_off_completed_count, labor_fee_ratio, allow_project_over_budget, invoice_info, expense_ratio_json, project_fee_json, terminated_reason, freeze_reason, archived_at, key_change_log_json, status, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO project (tenant_id, project_name, parent_project_id, start_date, end_date, host_enterprise_name, partner_enterprise_id, budget_cent, meeting_total, meeting_completed_count, allow_meeting_over_budget, over_budget_threshold_ratio, over_budget_approval_chain_json, budget_execution_ratio, risk_flags_json, write_off_not_started_count, write_off_in_progress_count, write_off_completed_count, labor_fee_ratio, catering_fee_ratio, labor_agreement_sign_type, allow_project_over_budget, invoice_info, expense_ratio_json, project_fee_json, terminated_reason, freeze_reason, archived_at, key_change_log_json, status, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS
);
Long operator = safeUserId();
@ -114,21 +116,23 @@ public class JdbcProjectRepository implements ProjectRepository {
ps.setInt(17, project.getWriteOffInProgressCount());
ps.setInt(18, project.getWriteOffCompletedCount());
ps.setBigDecimal(19, java.math.BigDecimal.valueOf(project.getLaborFeeRatio()));
ps.setInt(20, project.isAllowProjectOverBudget() ? 1 : 0);
ps.setString(21, project.getInvoiceInfo());
ps.setString(22, project.getExpenseRatioJson());
ps.setString(23, project.getProjectFeeJson());
ps.setString(24, project.getTerminatedReason());
ps.setString(25, project.getFreezeReason());
ps.setBigDecimal(20, java.math.BigDecimal.valueOf(project.getCateringFeeRatio()));
ps.setInt(21, project.getLaborAgreementSignType());
ps.setInt(22, project.isAllowProjectOverBudget() ? 1 : 0);
ps.setString(23, project.getInvoiceInfo());
ps.setString(24, project.getExpenseRatioJson());
ps.setString(25, project.getProjectFeeJson());
ps.setString(26, project.getTerminatedReason());
ps.setString(27, project.getFreezeReason());
if (project.getArchivedAt() == null) {
ps.setNull(26, java.sql.Types.TIMESTAMP);
ps.setNull(28, java.sql.Types.TIMESTAMP);
} else {
ps.setTimestamp(26, java.sql.Timestamp.valueOf(project.getArchivedAt()));
ps.setTimestamp(28, java.sql.Timestamp.valueOf(project.getArchivedAt()));
}
ps.setString(27, project.getKeyChangeLogJson());
ps.setString(28, project.getStatus().name());
ps.setLong(29, operator);
ps.setLong(30, operator);
ps.setString(29, project.getKeyChangeLogJson());
ps.setString(30, project.getStatus().name());
ps.setLong(31, operator);
ps.setLong(32, operator);
return ps;
}, keyHolder);
Number key = keyHolder.getKey();
@ -176,12 +180,14 @@ public class JdbcProjectRepository implements ProjectRepository {
created.setSubProjectCount(project.getSubProjectCount());
created.setParentProjectId(project.getParentProjectId());
created.setHostEnterpriseName(project.getHostEnterpriseName());
created.setLaborAgreementSignType(project.getLaborAgreementSignType());
created.setCateringFeeRatio(project.getCateringFeeRatio());
created.setProjectFeeJson(project.getProjectFeeJson());
return created;
}
jdbcTemplate.update(
"UPDATE project SET project_name=?, parent_project_id=?, start_date=?, end_date=?, host_enterprise_name=?, partner_enterprise_id=?, budget_cent=?, meeting_total=?, meeting_completed_count=?, allow_meeting_over_budget=?, " +
"over_budget_threshold_ratio=?, over_budget_approval_chain_json=?, budget_execution_ratio=?, risk_flags_json=?, write_off_not_started_count=?, write_off_in_progress_count=?, write_off_completed_count=?, labor_fee_ratio=?, allow_project_over_budget=?, invoice_info=?, expense_ratio_json=?, project_fee_json=?, terminated_reason=?, freeze_reason=?, archived_at=?, key_change_log_json=?, status=?, updated_by=? WHERE tenant_id=? AND id=?",
"over_budget_threshold_ratio=?, over_budget_approval_chain_json=?, budget_execution_ratio=?, risk_flags_json=?, write_off_not_started_count=?, write_off_in_progress_count=?, write_off_completed_count=?, labor_fee_ratio=?, catering_fee_ratio=?, labor_agreement_sign_type=?, allow_project_over_budget=?, invoice_info=?, expense_ratio_json=?, project_fee_json=?, terminated_reason=?, freeze_reason=?, archived_at=?, key_change_log_json=?, status=?, updated_by=? WHERE tenant_id=? AND id=?",
project.getName(),
project.getParentProjectId(),
project.getStartDate() == null ? null : java.sql.Date.valueOf(project.getStartDate()),
@ -201,6 +207,8 @@ public class JdbcProjectRepository implements ProjectRepository {
project.getWriteOffInProgressCount(),
project.getWriteOffCompletedCount(),
java.math.BigDecimal.valueOf(project.getLaborFeeRatio()),
java.math.BigDecimal.valueOf(project.getCateringFeeRatio()),
project.getLaborAgreementSignType(),
project.isAllowProjectOverBudget() ? 1 : 0,
project.getInvoiceInfo(),
project.getExpenseRatioJson(),
@ -223,7 +231,7 @@ public class JdbcProjectRepository implements ProjectRepository {
"SELECT p.id, p.project_name, p.parent_project_id, " +
"(SELECT COUNT(1) FROM project c WHERE c.tenant_id=p.tenant_id AND c.parent_project_id=p.id AND c.is_deleted=0) AS sub_project_count, " +
"p.start_date, p.end_date, p.partner_enterprise_id AS enterprise_id, e.enterprise_name, p.host_enterprise_name, p.partner_enterprise_id, p.budget_cent, p.meeting_total, p.meeting_completed_count, " +
"p.allow_meeting_over_budget, p.over_budget_threshold_ratio, p.over_budget_approval_chain_json, p.budget_execution_ratio, p.risk_flags_json, p.write_off_not_started_count, p.write_off_in_progress_count, p.write_off_completed_count, p.labor_fee_ratio, p.allow_project_over_budget, p.invoice_info, p.expense_ratio_json, p.project_fee_json, p.terminated_reason, p.freeze_reason, p.archived_at, p.key_change_log_json, p.status, " +
"p.allow_meeting_over_budget, p.over_budget_threshold_ratio, p.over_budget_approval_chain_json, p.budget_execution_ratio, p.risk_flags_json, p.write_off_not_started_count, p.write_off_in_progress_count, p.write_off_completed_count, p.labor_fee_ratio, p.catering_fee_ratio, p.labor_agreement_sign_type, p.allow_project_over_budget, p.invoice_info, p.expense_ratio_json, p.project_fee_json, p.terminated_reason, p.freeze_reason, p.archived_at, p.key_change_log_json, p.status, " +
"p.is_deleted, " +
"(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM sys_user su JOIN user_role ur ON su.tenant_id=ur.tenant_id AND su.id=ur.user_id JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id WHERE su.tenant_id=p.tenant_id AND su.is_deleted=0 AND r.role_code='TENANT_ADMIN') AS host_owner_users, " +
"(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_OWNER' AND b.is_deleted=0 AND su.is_deleted=0) AS host_executor_users, " +
@ -245,7 +253,7 @@ public class JdbcProjectRepository implements ProjectRepository {
"SELECT p.id, p.project_name, p.parent_project_id, " +
"(SELECT COUNT(1) FROM project c WHERE c.tenant_id=p.tenant_id AND c.parent_project_id=p.id AND c.is_deleted=0) AS sub_project_count, " +
"p.start_date, p.end_date, p.partner_enterprise_id AS enterprise_id, e.enterprise_name, p.host_enterprise_name, p.partner_enterprise_id, p.budget_cent, p.meeting_total, p.meeting_completed_count, " +
"p.allow_meeting_over_budget, p.over_budget_threshold_ratio, p.over_budget_approval_chain_json, p.budget_execution_ratio, p.risk_flags_json, p.write_off_not_started_count, p.write_off_in_progress_count, p.write_off_completed_count, p.labor_fee_ratio, p.allow_project_over_budget, p.invoice_info, p.expense_ratio_json, p.project_fee_json, p.terminated_reason, p.freeze_reason, p.archived_at, p.key_change_log_json, p.status, " +
"p.allow_meeting_over_budget, p.over_budget_threshold_ratio, p.over_budget_approval_chain_json, p.budget_execution_ratio, p.risk_flags_json, p.write_off_not_started_count, p.write_off_in_progress_count, p.write_off_completed_count, p.labor_fee_ratio, p.catering_fee_ratio, p.labor_agreement_sign_type, p.allow_project_over_budget, p.invoice_info, p.expense_ratio_json, p.project_fee_json, p.terminated_reason, p.freeze_reason, p.archived_at, p.key_change_log_json, p.status, " +
"p.is_deleted, " +
"(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM sys_user su JOIN user_role ur ON su.tenant_id=ur.tenant_id AND su.id=ur.user_id JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id WHERE su.tenant_id=p.tenant_id AND su.is_deleted=0 AND r.role_code='TENANT_ADMIN') AS host_owner_users, " +
"(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_OWNER' AND b.is_deleted=0 AND su.is_deleted=0) AS host_executor_users, " +
@ -268,7 +276,7 @@ public class JdbcProjectRepository implements ProjectRepository {
"SELECT p.id, p.project_name, p.parent_project_id, " +
"(SELECT COUNT(1) FROM project c WHERE c.tenant_id=p.tenant_id AND c.parent_project_id=p.id AND c.is_deleted=0) AS sub_project_count, " +
"p.start_date, p.end_date, p.partner_enterprise_id AS enterprise_id, e.enterprise_name, p.host_enterprise_name, p.partner_enterprise_id, p.budget_cent, p.meeting_total, p.meeting_completed_count, " +
"p.allow_meeting_over_budget, p.over_budget_threshold_ratio, p.over_budget_approval_chain_json, p.budget_execution_ratio, p.risk_flags_json, p.write_off_not_started_count, p.write_off_in_progress_count, p.write_off_completed_count, p.labor_fee_ratio, p.allow_project_over_budget, p.invoice_info, p.expense_ratio_json, p.project_fee_json, p.terminated_reason, p.freeze_reason, p.archived_at, p.key_change_log_json, p.status, " +
"p.allow_meeting_over_budget, p.over_budget_threshold_ratio, p.over_budget_approval_chain_json, p.budget_execution_ratio, p.risk_flags_json, p.write_off_not_started_count, p.write_off_in_progress_count, p.write_off_completed_count, p.labor_fee_ratio, p.catering_fee_ratio, p.labor_agreement_sign_type, p.allow_project_over_budget, p.invoice_info, p.expense_ratio_json, p.project_fee_json, p.terminated_reason, p.freeze_reason, p.archived_at, p.key_change_log_json, p.status, " +
"p.is_deleted, " +
"(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM sys_user su JOIN user_role ur ON su.tenant_id=ur.tenant_id AND su.id=ur.user_id JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id WHERE su.tenant_id=p.tenant_id AND su.is_deleted=0 AND r.role_code='TENANT_ADMIN') AS host_owner_users, " +
"(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_OWNER' AND b.is_deleted=0 AND su.is_deleted=0) AS host_executor_users, " +

View File

@ -20,6 +20,7 @@ import com.writeoff.security.AuthContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Comparator;
@ -31,6 +32,8 @@ import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.math.BigDecimal;
import java.math.RoundingMode;
@Service
public class ProjectService {
@ -61,13 +64,18 @@ public class ProjectService {
this(projectRepository, null, null, null, null, null);
}
public PageResult<Project> list(Boolean parentOnly, Boolean includeDeleted) {
public PageResult<Project> list(Boolean parentOnly, Boolean includeDeleted, String keyword, Integer pageNo, Integer pageSize) {
List<Project> list = projectRepository.findAll(Boolean.TRUE.equals(includeDeleted));
if (Boolean.TRUE.equals(parentOnly)) {
list = list.stream()
.filter(project -> project.getParentProjectId() == null)
.collect(Collectors.toList());
}
if (keyword != null && !keyword.trim().isEmpty()) {
list = list.stream()
.filter(project -> project.getName() != null && project.getName().contains(keyword.trim()))
.collect(Collectors.toList());
}
if (dataPermissionService != null) {
DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope();
final Map<Long, Long> creatorMap = scope.isProjectOwnerOnly()
@ -77,7 +85,14 @@ public class ProjectService {
.filter(project -> dataPermissionService.canAccessProject(project.getId(), creatorMap.get(project.getId()), scope))
.collect(Collectors.toList());
}
return new PageResult<>(list, list.size(), 1, 20);
int page = pageNo == null || pageNo < 1 ? 1 : pageNo;
int size = pageSize == null || pageSize < 1 ? 20 : pageSize;
int total = list.size();
int fromIndex = (page - 1) * size;
int toIndex = Math.min(fromIndex + size, total);
List<Project> pagedList = fromIndex < total ? list.subList(fromIndex, toIndex) : new ArrayList<>();
redactSensitiveData(pagedList);
return new PageResult<>(pagedList, total, page, size);
}
public List<Project> listChildren(Long parentProjectId) {
@ -92,6 +107,7 @@ public class ProjectService {
.filter(project -> dataPermissionService.canAccessProject(project.getId(), creatorMap.get(project.getId()), scope))
.collect(Collectors.toList());
}
redactSensitiveData(children);
return children;
}
@ -107,9 +123,14 @@ public class ProjectService {
.filter(project -> dataPermissionService.canAccessProject(project.getId(), creatorMap.get(project.getId()), scope))
.collect(Collectors.toList());
}
redactSensitiveData(children);
return children;
}
public boolean hasChildren(Long projectId) {
return !projectRepository.findByParentProjectId(projectId, false).isEmpty();
}
public Project create(CreateProjectRequest request) {
if (enterpriseService != null) {
enterpriseService.assertEnabled(request.getPartnerEnterpriseId());
@ -121,12 +142,13 @@ public class ProjectService {
}
if (request.getParentProjectId() != null) {
getById(request.getParentProjectId());
assertParentBudgetCanCoverChildren(request.getParentProjectId(), null, request.getBudgetCent());
}
ProjectFeeSummary projectFee = buildProjectFeeSummary(request.getProjectFeeJson());
double budgetExecutionRatio = calculateBudgetExecutionRatio(request.getBudgetCent(), projectFee.totalCent);
assertProjectBudgetConstraint(
request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(),
request.getOverBudgetThresholdRatio() == null ? 0.1d : request.getOverBudgetThresholdRatio(),
request.getOverBudgetThresholdRatio() == null ? 0.0d : request.getOverBudgetThresholdRatio(),
budgetExecutionRatio,
request.getBudgetCent(),
projectFee.totalCent
@ -149,7 +171,7 @@ public class ProjectService {
request.getMeetingTotal(),
0,
request.getAllowMeetingOverBudget() != null && request.getAllowMeetingOverBudget(),
request.getOverBudgetThresholdRatio() == null ? 0.1d : request.getOverBudgetThresholdRatio(),
request.getOverBudgetThresholdRatio() == null ? 0.0d : request.getOverBudgetThresholdRatio(),
request.getOverBudgetApprovalChainJson(),
budgetExecutionRatio,
null,
@ -157,7 +179,7 @@ public class ProjectService {
request.getMeetingTotal(),
0,
0,
request.getLaborFeeRatio() == null ? 0d : request.getLaborFeeRatio(),
normalizeRatio(request.getLaborFeeRatio(), "鍔冲姟璐圭敤鍗犳瘮"),
request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(),
request.getInvoiceInfo(),
request.getExpenseRatioJson(),
@ -171,6 +193,8 @@ public class ProjectService {
null,
ProjectStatus.WAITING
);
project.setCateringFeeRatio(normalizeRatio(request.getCateringFeeRatio(), "餐费占比"));
project.setLaborAgreementSignType(normalizeLaborAgreementSignType(request.getLaborAgreementSignType(), 1));
project.setParentProjectId(request.getParentProjectId());
project.setHostEnterpriseName(resolveCurrentTenantName());
project.setProjectFeeJson(projectFee.normalizedJson);
@ -194,9 +218,18 @@ public class ProjectService {
: requestProjectFeeJson;
ProjectFeeSummary projectFee = buildProjectFeeSummary(sourceProjectFeeJson);
double budgetExecutionRatio = calculateBudgetExecutionRatio(request.getBudgetCent(), projectFee.totalCent);
Long targetParentProjectId = request.getParentProjectId() == null ? existing.getParentProjectId() : request.getParentProjectId();
if (targetParentProjectId != null) {
if (targetParentProjectId.equals(projectId)) {
throw new BusinessException(10001, "项目不能将自己设置为父项目");
}
getById(targetParentProjectId);
assertParentBudgetCanCoverChildren(targetParentProjectId, projectId, request.getBudgetCent());
}
assertCurrentBudgetCanCoverChildren(projectId, request.getBudgetCent());
assertProjectBudgetConstraint(
request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(),
request.getOverBudgetThresholdRatio() == null ? 0.1d : request.getOverBudgetThresholdRatio(),
request.getOverBudgetThresholdRatio() == null ? 0.0d : request.getOverBudgetThresholdRatio(),
budgetExecutionRatio,
request.getBudgetCent(),
projectFee.totalCent
@ -219,7 +252,7 @@ public class ProjectService {
request.getMeetingTotal(),
existing.getMeetingCompletedCount(),
request.getAllowMeetingOverBudget() != null && request.getAllowMeetingOverBudget(),
request.getOverBudgetThresholdRatio() == null ? 0.1d : request.getOverBudgetThresholdRatio(),
request.getOverBudgetThresholdRatio() == null ? 0.0d : request.getOverBudgetThresholdRatio(),
request.getOverBudgetApprovalChainJson(),
budgetExecutionRatio,
existing.getRiskFlagsJson(),
@ -227,7 +260,7 @@ public class ProjectService {
existing.getWriteOffNotStartedCount(),
existing.getWriteOffInProgressCount(),
existing.getWriteOffCompletedCount(),
request.getLaborFeeRatio() == null ? existing.getLaborFeeRatio() : request.getLaborFeeRatio(),
request.getLaborFeeRatio() == null ? existing.getLaborFeeRatio() : normalizeRatio(request.getLaborFeeRatio(), "鍔冲姟璐圭敤鍗犳瘮"),
request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(),
request.getInvoiceInfo(),
request.getExpenseRatioJson(),
@ -241,7 +274,13 @@ public class ProjectService {
existing.getKeyChangeLogJson(),
existing.getStatus()
);
project.setParentProjectId(request.getParentProjectId() == null ? existing.getParentProjectId() : request.getParentProjectId());
project.setCateringFeeRatio(
request.getCateringFeeRatio() == null
? existing.getCateringFeeRatio()
: normalizeRatio(request.getCateringFeeRatio(), "餐费占比")
);
project.setLaborAgreementSignType(normalizeLaborAgreementSignType(request.getLaborAgreementSignType(), existing.getLaborAgreementSignType()));
project.setParentProjectId(targetParentProjectId);
project.setHostEnterpriseName(resolveCurrentTenantName());
project.setProjectFeeJson(projectFee.normalizedJson);
Project saved = projectRepository.save(project);
@ -330,13 +369,11 @@ public class ProjectService {
return result;
}
@Transactional
public void saveBindings(Long projectId, SaveProjectBindingsRequest request) {
ensureJdbcEnabled();
getById(projectId);
boolean projectExecutorMode = isCurrentUserProjectExecutor();
List<Map<String, Object>> beforeOwnerUsers = listProjectBoundUsers(projectId, "PROJECT_OWNER");
List<Map<String, Object>> beforeExecutorUsers = listProjectBoundUsers(projectId, "PROJECT_EXECUTOR");
List<Map<String, Object>> beforeLegacyExecutorUsers = listProjectBoundUsers(projectId, "EXECUTOR");
List<Long> ownerUserIds = request.getOwnerUserIds() == null ? new ArrayList<Long>() : request.getOwnerUserIds();
List<Long> executorUserIds = request.getExecutorUserIds() == null ? new ArrayList<Long>() : request.getExecutorUserIds();
List<Long> legacyExecutorUserIds = request.getLegacyExecutorUserIds() == null ? new ArrayList<Long>() : request.getLegacyExecutorUserIds();
@ -361,21 +398,7 @@ public class ProjectService {
} else {
legacyExecutorUserIds = listProjectBoundUserIds(projectId, "EXECUTOR");
}
jdbcTemplate.update(
"DELETE FROM project_user_binding WHERE tenant_id=? AND project_id=?",
tenantId(),
projectId
);
for (Long userId : ownerUserIds) {
insertBinding(projectId, userId, "PROJECT_OWNER");
}
for (Long userId : executorUserIds) {
insertBinding(projectId, userId, "PROJECT_EXECUTOR");
}
for (Long userId : legacyExecutorUserIds) {
insertBinding(projectId, userId, "EXECUTOR");
}
logProjectBindingChanges(projectId, beforeOwnerUsers, beforeExecutorUsers, beforeLegacyExecutorUsers, ownerUserIds, executorUserIds, legacyExecutorUserIds);
applyBindingsToProjectTree(projectId, ownerUserIds, executorUserIds, legacyExecutorUserIds);
}
public List<Map<String, Object>> listKeyChangeLogs(Long projectId) {
@ -467,6 +490,44 @@ public class ProjectService {
);
}
private void applyBindingsToProjectTree(Long projectId,
List<Long> ownerUserIds,
List<Long> executorUserIds,
List<Long> legacyExecutorUserIds) {
applyBindingsToSingleProject(projectId, ownerUserIds, executorUserIds, legacyExecutorUserIds);
List<Project> children = projectRepository.findByParentProjectId(projectId, false);
for (Project child : children) {
if (child == null || child.getId() == null) {
continue;
}
applyBindingsToProjectTree(child.getId(), ownerUserIds, executorUserIds, legacyExecutorUserIds);
}
}
private void applyBindingsToSingleProject(Long projectId,
List<Long> ownerUserIds,
List<Long> executorUserIds,
List<Long> legacyExecutorUserIds) {
List<Map<String, Object>> beforeOwnerUsers = listProjectBoundUsers(projectId, "PROJECT_OWNER");
List<Map<String, Object>> beforeExecutorUsers = listProjectBoundUsers(projectId, "PROJECT_EXECUTOR");
List<Map<String, Object>> beforeLegacyExecutorUsers = listProjectBoundUsers(projectId, "EXECUTOR");
jdbcTemplate.update(
"DELETE FROM project_user_binding WHERE tenant_id=? AND project_id=?",
tenantId(),
projectId
);
for (Long userId : ownerUserIds) {
insertBinding(projectId, userId, "PROJECT_OWNER");
}
for (Long userId : executorUserIds) {
insertBinding(projectId, userId, "PROJECT_EXECUTOR");
}
for (Long userId : legacyExecutorUserIds) {
insertBinding(projectId, userId, "EXECUTOR");
}
logProjectBindingChanges(projectId, beforeOwnerUsers, beforeExecutorUsers, beforeLegacyExecutorUsers, ownerUserIds, executorUserIds, legacyExecutorUserIds);
}
private void ensureJdbcEnabled() {
if (jdbcTemplate == null) {
throw new BusinessException(10001, "当前仓储模式不支持项目人员绑定");
@ -560,7 +621,7 @@ public class ProjectService {
try {
return new ProjectFeeSummary(
objectMapper.writeValueAsString(normalizedRoot),
managementFeeCent + taxFeeCent + paidAmountCent + customTotalCent
managementFeeCent + taxFeeCent + customTotalCent
);
} catch (Exception e) {
throw new BusinessException(10001, "项目费用配置序列化失败");
@ -590,15 +651,77 @@ public class ProjectService {
return (double) feeTotalCent / budget;
}
private void assertParentBudgetCanCoverChildren(Long parentProjectId, Long currentProjectId, Long currentBudgetCent) {
if (parentProjectId == null) {
return;
}
Project parentProject = getById(parentProjectId);
long siblingBudgetTotalCent = projectRepository.findByParentProjectId(parentProjectId, false).stream()
.filter(project -> currentProjectId == null || !currentProjectId.equals(project.getId()))
.mapToLong(Project::getBudgetCent)
.sum();
long nextChildrenBudgetTotalCent = siblingBudgetTotalCent + normalizeBudgetCent(currentBudgetCent);
if (nextChildrenBudgetTotalCent > parentProject.getBudgetCent()) {
throw new BusinessException(
10001,
String.format(
Locale.ROOT,
"子项目预算合计不能超过父项目预算:父项目预算%.2f元,子项目预算合计%.2f元",
toYuanAmount(parentProject.getBudgetCent()),
toYuanAmount(nextChildrenBudgetTotalCent)
)
);
}
}
private void assertCurrentBudgetCanCoverChildren(Long projectId, Long currentBudgetCent) {
long childrenBudgetTotalCent = projectRepository.findByParentProjectId(projectId, false).stream()
.mapToLong(Project::getBudgetCent)
.sum();
long nextBudgetCent = normalizeBudgetCent(currentBudgetCent);
if (childrenBudgetTotalCent > nextBudgetCent) {
throw new BusinessException(
10001,
String.format(
Locale.ROOT,
"当前项目预算不能小于子项目预算合计:项目预算%.2f元,子项目预算合计%.2f元",
toYuanAmount(nextBudgetCent),
toYuanAmount(childrenBudgetTotalCent)
)
);
}
}
private long normalizeBudgetCent(Long budgetCent) {
return budgetCent == null ? 0L : Math.max(0L, budgetCent);
}
private double toYuanAmount(long cent) {
return cent / 100d;
}
private int normalizeLaborAgreementSignType(Integer signType, int defaultValue) {
int resolved = signType == null ? defaultValue : signType;
return resolved == 2 ? 2 : 1;
}
private double normalizeRatio(Double value, String fieldName) {
double normalized = value == null ? 0d : value;
if (Double.isNaN(normalized) || Double.isInfinite(normalized) || normalized < 0d || normalized > 1d) {
throw new BusinessException(10001, fieldName + "鍙兘鍦?0~1涔嬮棿");
}
return BigDecimal.valueOf(normalized).setScale(6, RoundingMode.HALF_UP).doubleValue();
}
private void assertProjectBudgetConstraint(boolean allowProjectOverBudget,
double thresholdRatio,
double budgetExecutionRatio,
Long budgetCent,
long feeTotalCent) {
if (allowProjectOverBudget) {
return;
}
double allowedRatio = 1d + Math.max(0d, thresholdRatio);
// 如果关闭了允许超支开关强制阈值为 0
double actualThreshold = allowProjectOverBudget ? Math.max(0d, thresholdRatio) : 0d;
double allowedRatio = 1d + actualThreshold;
if (budgetExecutionRatio > allowedRatio) {
long budget = budgetCent == null ? 0L : budgetCent;
long allowedTotalCent = Math.round(budget * allowedRatio);
@ -627,6 +750,8 @@ public class ProjectService {
logProjectFieldChange(project.getId(), "PROJECT_CREATE", "budgetCent", "项目预算(分)", null, project.getBudgetCent(), null);
logProjectFieldChange(project.getId(), "PROJECT_CREATE", "meetingTotal", "会议总期数", null, project.getMeetingTotal(), null);
logProjectFieldChange(project.getId(), "PROJECT_CREATE", "laborFeeRatio", "劳务费用占比", null, project.getLaborFeeRatio(), null);
logProjectFieldChange(project.getId(), "PROJECT_CREATE", "cateringFeeRatio", "餐费占比", null, project.getCateringFeeRatio(), null);
logProjectFieldChange(project.getId(), "PROJECT_CREATE", "laborAgreementSignType", "劳务费协议签署类型", null, project.getLaborAgreementSignType(), null);
logProjectFieldChange(project.getId(), "PROJECT_CREATE", "invoiceInfo", "发票信息", null, project.getInvoiceInfo(), null);
logProjectFieldChange(project.getId(), "PROJECT_CREATE", "expenseRatioJson", "费用占比配置", null, project.getExpenseRatioJson(), null);
logProjectFieldChange(project.getId(), "PROJECT_CREATE", "projectFeeJson", "项目费用配置", null, project.getProjectFeeJson(), null);
@ -646,6 +771,8 @@ public class ProjectService {
logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "budgetCent", "项目预算(分)", before.getBudgetCent(), after.getBudgetCent(), batchId);
logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "meetingTotal", "会议总期数", before.getMeetingTotal(), after.getMeetingTotal(), batchId);
logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "laborFeeRatio", "劳务费用占比", before.getLaborFeeRatio(), after.getLaborFeeRatio(), batchId);
logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "cateringFeeRatio", "餐费占比", before.getCateringFeeRatio(), after.getCateringFeeRatio(), batchId);
logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "laborAgreementSignType", "劳务费协议签署类型", before.getLaborAgreementSignType(), after.getLaborAgreementSignType(), batchId);
logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "invoiceInfo", "发票信息", before.getInvoiceInfo(), after.getInvoiceInfo(), batchId);
logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "expenseRatioJson", "费用占比配置", before.getExpenseRatioJson(), after.getExpenseRatioJson(), batchId);
logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "projectFeeJson", "项目费用配置", before.getProjectFeeJson(), after.getProjectFeeJson(), batchId);
@ -780,6 +907,19 @@ public class ProjectService {
}
return "项目变更";
}
private void redactSensitiveData(List<Project> list) {
if (list == null || list.isEmpty()) {
return;
}
Long userId = AuthContext.userId();
boolean canReadSensitive = permissionService != null && userId != null && permissionService.hasPermission(userId, "project.sensitive_data.read");
if (!canReadSensitive) {
for (Project project : list) {
project.setBudgetCent(-1L);
project.setProjectFeeJson("{}");
}
}
}
private static class ProjectFeeSummary {
private final String normalizedJson;

View File

@ -79,4 +79,10 @@ public class DataPermissionController {
public ApiResponse<Map<String, Object>> currentScope() {
return ApiResponse.success(dataPermissionService.currentScopeSummary());
}
@GetMapping("/match")
@RequirePermission(value = "data.permission.read", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_MATCH_SCOPE")
public ApiResponse<Map<String, Object>> matchScope(@RequestParam("account") String account) {
return ApiResponse.success(dataPermissionService.matchScopeSummary(account));
}
}

View File

@ -93,9 +93,9 @@ public class UserController {
@PostMapping("/{id}/reset-password")
@RequirePermission(value = "user.password.reset", dataScope = DataScopeType.TENANT, auditAction = "USER_RESET_PASSWORD")
public ApiResponse<String> resetPassword(@PathVariable("id") Long id, @RequestBody @Valid ResetPasswordRequest request) {
systemUserService.resetPassword(id, request);
return ApiResponse.success("OK");
public ApiResponse<String> resetPassword(@PathVariable("id") Long id) {
systemUserService.resetPassword(id);
return ApiResponse.success("密码已重置");
}
@GetMapping("/{id}/role-history")

View File

@ -166,23 +166,41 @@ public class DataPermissionService {
public Map<String, Object> currentScopeSummary() {
Long userId = AuthContext.userId();
DataScope scope = resolveCurrentUserScope();
Map<String, Object> data = new LinkedHashMap<>();
data.put("userId", userId);
data.put("projectAll", scope.isProjectAll());
data.put("projectIds", new ArrayList<>(scope.getProjectIds()));
data.put("projectOwnerOnly", scope.isProjectOwnerOnly());
data.put("meetingAll", scope.isMeetingAll());
data.put("meetingIds", new ArrayList<>(scope.getMeetingIds()));
data.put("meetingOwnerOnly", scope.isMeetingOwnerOnly());
data.put("userAll", scope.isUserAll());
data.put("userIds", new ArrayList<>(scope.getUserIds()));
data.put("userOwnerOnly", scope.isUserOwnerOnly());
data.put("expertAll", scope.isExpertAll());
data.put("expertIds", new ArrayList<>(scope.getExpertIds()));
data.put("expertOwnerOnly", scope.isExpertOwnerOnly());
data.put("matchedPolicyIds", userId == null ? new ArrayList<Long>() : listMatchedPolicyIds(userId));
data.put("exportAllowed", canExportCurrentUser());
return data;
return buildScopeSummaryMap(userId, null, null, scope, userId == null ? new ArrayList<Long>() : listMatchedPolicyIds(userId), canExportCurrentUser());
}
public Map<String, Object> matchScopeSummary(String account) {
String normalizedAccount = account == null ? "" : account.trim();
if (normalizedAccount.isEmpty()) {
throw new BusinessException(10001, "请输入账号");
}
List<Map<String, Object>> users = jdbcTemplate.queryForList(
"SELECT id, user_name, phone, status, " +
"DATE_FORMAT(valid_from, '%Y-%m-%d %H:%i:%s') AS valid_from, " +
"DATE_FORMAT(valid_to, '%Y-%m-%d %H:%i:%s') AS valid_to " +
"FROM sys_user WHERE tenant_id=? AND is_deleted=0 AND phone=? ORDER BY id DESC LIMIT 2",
tenantId(),
normalizedAccount
);
if (users.isEmpty()) {
throw new BusinessException(10003, "未找到该账号对应的用户");
}
if (users.size() > 1) {
throw new BusinessException(10001, "该账号匹配到多个用户,请检查租户内账号数据");
}
Map<String, Object> user = users.get(0);
Long userId = ((Number) user.get("id")).longValue();
DataScope scope = resolveUserScope(userId);
List<Long> matchedPolicyIds = listMatchedPolicyIds(userId);
boolean exportAllowed = canExportUser(userId);
return buildScopeSummaryMap(
userId,
user.get("user_name") == null ? "" : String.valueOf(user.get("user_name")),
user.get("phone") == null ? "" : String.valueOf(user.get("phone")),
scope,
matchedPolicyIds,
exportAllowed
);
}
public List<Long> listPolicyRoleIds(Long policyId) {
@ -314,6 +332,13 @@ public class DataPermissionService {
public boolean canExportCurrentUser() {
Long userId = AuthContext.userId();
if (userId == null) {
return false;
}
return canExportUser(userId);
}
public boolean canExportUser(Long userId) {
if (userId == null) {
return false;
}
@ -371,6 +396,33 @@ public class DataPermissionService {
);
}
private Map<String, Object> buildScopeSummaryMap(Long userId,
String userName,
String phone,
DataScope scope,
List<Long> matchedPolicyIds,
boolean exportAllowed) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("userId", userId);
data.put("userName", userName);
data.put("phone", phone);
data.put("projectAll", scope.isProjectAll());
data.put("projectIds", new ArrayList<>(scope.getProjectIds()));
data.put("projectOwnerOnly", scope.isProjectOwnerOnly());
data.put("meetingAll", scope.isMeetingAll());
data.put("meetingIds", new ArrayList<>(scope.getMeetingIds()));
data.put("meetingOwnerOnly", scope.isMeetingOwnerOnly());
data.put("userAll", scope.isUserAll());
data.put("userIds", new ArrayList<>(scope.getUserIds()));
data.put("userOwnerOnly", scope.isUserOwnerOnly());
data.put("expertAll", scope.isExpertAll());
data.put("expertIds", new ArrayList<>(scope.getExpertIds()));
data.put("expertOwnerOnly", scope.isExpertOwnerOnly());
data.put("matchedPolicyIds", matchedPolicyIds == null ? new ArrayList<Long>() : matchedPolicyIds);
data.put("exportAllowed", exportAllowed);
return data;
}
public Map<Long, Long> listProjectCreators(Collection<Long> projectIds) {
Map<Long, Long> result = new LinkedHashMap<>();
if (projectIds == null || projectIds.isEmpty()) {

View File

@ -22,6 +22,7 @@ import com.writeoff.module.system.model.UserRoleHistory;
import com.writeoff.security.AuthContext;
import com.writeoff.security.PasswordCodecService;
import com.writeoff.security.PasswordPolicyService;
import com.writeoff.security.PasswordSetupService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
@ -55,7 +56,9 @@ public class SystemUserService {
private final PasswordPolicyService passwordPolicyService;
private final PasswordCodecService passwordCodecService;
private final TransactionTemplate transactionTemplate;
private final PasswordSetupService passwordSetupService;
private final ObjectMapper objectMapper = new ObjectMapper();
private final String frontendBaseUrl;
private static final RowMapper<SystemUser> USER_ROW_MAPPER = (rs, n) -> new SystemUser(
rs.getLong("id"),
@ -97,13 +100,17 @@ public class SystemUserService {
NotificationDispatchService notificationDispatchService,
PasswordPolicyService passwordPolicyService,
PasswordCodecService passwordCodecService,
PlatformTransactionManager transactionManager) {
PlatformTransactionManager transactionManager,
PasswordSetupService passwordSetupService,
@org.springframework.beans.factory.annotation.Value("${app.frontend-base-url:http://localhost:5173}") String frontendBaseUrl) {
this.jdbcTemplate = jdbcTemplate;
this.dataPermissionService = dataPermissionService;
this.notificationDispatchService = notificationDispatchService;
this.passwordPolicyService = passwordPolicyService;
this.passwordCodecService = passwordCodecService;
this.transactionTemplate = new TransactionTemplate(transactionManager);
this.passwordSetupService = passwordSetupService;
this.frontendBaseUrl = frontendBaseUrl;
}
public PageResult<SystemUser> listUsers(int pageNo, int pageSize, Boolean includeDeleted) {
@ -179,10 +186,8 @@ public class SystemUserService {
final String phone = request.getPhone() == null ? "" : request.getPhone().trim();
final String email = request.getEmail() == null ? "" : request.getEmail().trim();
final String rawPassword = request.getPassword() == null ? "" : request.getPassword().trim();
if (rawPassword.isEmpty()) {
throw new BusinessException(10001, "\u5bc6\u7801\u4e0d\u80fd\u4e3a\u7a7a");
}
passwordPolicyService.validate(rawPassword);
final String finalPassword = rawPassword.isEmpty() ? ("Tmp@" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8) + "aA1") : rawPassword;
passwordPolicyService.validate(finalPassword);
final String validFrom = request.getValidFrom() == null || request.getValidFrom().trim().isEmpty()
? LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
: normalizeDateTimeString(request.getValidFrom());
@ -191,7 +196,7 @@ public class SystemUserService {
: normalizeDateTimeString(request.getValidTo());
return transactionTemplate.execute(status -> {
assertPhoneAvailable(phone, null);
String passwordHash = passwordCodecService.encode(rawPassword);
String passwordHash = passwordCodecService.encode(finalPassword);
String tenantSwitchAccountKey = resolveTenantSwitchAccountKeyByPhone(phone, null);
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
@ -217,7 +222,8 @@ public class SystemUserService {
}, keyHolder);
Long id = keyHolder.getKey() == null ? null : keyHolder.getKey().longValue();
autoAssignExecutorRoleWhenCreatorIsProjectExecutor(id);
sendUserCreatedMail(id, userName, phone, email, validFrom, validTo);
String setupLink = passwordSetupService.issueUserSetupLink(tenantId(), id, safeUserId());
sendUserCreatedMail(id, userName, phone, email, finalPassword, validFrom, validTo, setupLink);
return new SystemUser(id, userName, phone, email, "ENABLED", validFrom, validTo, "", "");
});
}
@ -248,8 +254,8 @@ public class SystemUserService {
ps.setTimestamp(idx++, validTo == null ? Timestamp.valueOf(LocalDateTime.of(2099, 12, 31, 23, 59, 59)) : validTo);
ps.setLong(idx++, operator);
if (request.getPassword() != null && !request.getPassword().trim().isEmpty()) {
passwordPolicyService.validate(request.getPassword());
ps.setString(idx++, passwordCodecService.encode(request.getPassword()));
passwordPolicyService.validate(request.getPassword().trim());
ps.setString(idx++, passwordCodecService.encode(request.getPassword().trim()));
}
ps.setLong(idx++, tenantId());
ps.setLong(idx, userId);
@ -413,14 +419,54 @@ public class SystemUserService {
);
}
public void resetPassword(Long userId, ResetPasswordRequest request) {
public void resetPassword(Long userId) {
assertUserExists(userId);
passwordPolicyService.validate(request.getNewPassword());
jdbcTemplate.update(
"UPDATE sys_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
passwordCodecService.encode(request.getNewPassword()),
SystemUser user = jdbcTemplate.queryForObject(
"SELECT id, user_name, phone, email, status, valid_from, valid_to FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1",
(rs, rowNum) -> new SystemUser(
rs.getLong("id"),
rs.getString("user_name"),
rs.getString("phone"),
rs.getString("email"),
rs.getString("status"),
rs.getString("valid_from"),
rs.getString("valid_to")
),
tenantId(),
userId
);
String setupLink = passwordSetupService.issueUserSetupLink(tenantId(), userId, safeUserId());
List<Map<String, Object>> tenants = jdbcTemplate.queryForList(
"SELECT tenant_code, tenant_name FROM tenant WHERE id=? LIMIT 1",
tenantId()
);
String tenantCode = tenants.isEmpty() ? "" : String.valueOf(tenants.get(0).get("tenant_code"));
String tenantName = tenants.isEmpty() ? "" : String.valueOf(tenants.get(0).get("tenant_name"));
Map<String, Object> variables = new LinkedHashMap<String, Object>();
variables.put("userId", userId);
variables.put("targetUserId", userId);
variables.put("userName", user.getUserName());
variables.put("phone", user.getPhone());
variables.put("email", user.getEmail());
variables.put("tenantCode", tenantCode);
variables.put("tenantName", tenantName);
variables.put("setupLink", setupLink);
DispatchNotificationRequest dispatchRequest = new DispatchNotificationRequest();
dispatchRequest.setIdempotencyKey("user-password-reset-" + tenantId() + "-" + userId + "-" + System.currentTimeMillis());
dispatchRequest.setEventCode("USER_PASSWORD_RESET");
dispatchRequest.setBizType("USER");
dispatchRequest.setBizId("user-" + userId);
try {
dispatchRequest.setVariablesJson(objectMapper.writeValueAsString(variables));
} catch (Exception e) {
log.error("Failed to write variables json", e);
}
notificationDispatchService.dispatch(dispatchRequest);
}
public void changeMyPassword(Long userId, String oldPassword, String newPassword) {
@ -707,8 +753,8 @@ public class SystemUserService {
if (item.getUserName() == null || item.getUserName().trim().isEmpty()) {
throw new BusinessException(10001, "用户名不能为空");
}
if (item.getPassword() == null || item.getPassword().trim().isEmpty()) {
throw new BusinessException(10001, "密码不能为空");
if (item.getPassword() != null && !item.getPassword().trim().isEmpty()) {
passwordPolicyService.validate(item.getPassword().trim());
}
ImportValidationUtils.validatePhone(item.getPhone());
ImportValidationUtils.validateRequiredEmail(item.getEmail());
@ -717,7 +763,6 @@ public class SystemUserService {
if (!batchPhones.add(phone)) {
throw new BusinessException(10001, "批次内手机号重复");
}
passwordPolicyService.validate(item.getPassword().trim());
String roleCode = ImportValidationUtils.trim(item.getRoleCode());
if (roleCode.isEmpty()) {
return null;
@ -750,7 +795,7 @@ public class SystemUserService {
: ex.getMessage();
}
private void sendUserCreatedMail(Long userId, String userName, String phone, String email, String validFrom, String validTo) {
private void sendUserCreatedMail(Long userId, String userName, String phone, String email, String rawPassword, String validFrom, String validTo, String setupLink) {
if (userId == null || userId <= 0) {
return;
}
@ -761,18 +806,26 @@ public class SystemUserService {
);
String tenantCode = tenants.isEmpty() ? "" : String.valueOf(tenants.get(0).get("tenant_code"));
String tenantName = tenants.isEmpty() ? "" : String.valueOf(tenants.get(0).get("tenant_name"));
String loginPath = "/" + tenantCode + "/login";
String baseUrl = frontendBaseUrl == null ? "" : frontendBaseUrl;
while (baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
}
String loginPath = baseUrl.isEmpty() ? "/" + tenantCode + "/login" : baseUrl + "/" + tenantCode + "/login";
String displayValidTo = (validTo != null && validTo.contains("2099-12-31")) ? "长期有效" : validTo;
Map<String, Object> variables = new LinkedHashMap<String, Object>();
variables.put("userId", userId);
variables.put("targetUserId", userId);
variables.put("userName", userName);
variables.put("phone", phone);
variables.put("email", email);
variables.put("password", rawPassword);
variables.put("validFrom", validFrom);
variables.put("validTo", validTo);
variables.put("validTo", displayValidTo);
variables.put("tenantCode", tenantCode);
variables.put("tenantName", tenantName);
variables.put("loginPath", loginPath);
variables.put("loginPath", setupLink != null ? setupLink : loginPath);
variables.put("setupLink", setupLink != null ? setupLink : "");
DispatchNotificationRequest request = new DispatchNotificationRequest();
request.setIdempotencyKey("user-created-" + tenantId() + "-" + userId);
request.setEventCode("USER_CREATED");

View File

@ -3,6 +3,7 @@ package com.writeoff.module.system.service;
import com.writeoff.common.api.PageResult;
import com.writeoff.common.exception.BusinessException;
import com.writeoff.module.file.service.OssService;
import com.writeoff.module.notification.service.PlatformNotifyGatewayService;
import com.writeoff.module.system.dto.CreateTenantAdminRequest;
import com.writeoff.module.system.dto.CreateTenantRequest;
import com.writeoff.module.system.model.TenantInfo;
@ -29,6 +30,7 @@ public class TenantService {
private final JdbcTemplate jdbcTemplate;
private final OssService ossService;
private final Map<String, NotificationChannelProvider> providerMap;
private final PlatformNotifyGatewayService notifyGatewayService;
private final PasswordPolicyService passwordPolicyService;
private final PasswordCodecService passwordCodecService;
@Autowired
@ -48,6 +50,7 @@ public class TenantService {
public TenantService(JdbcTemplate jdbcTemplate,
OssService ossService,
List<NotificationChannelProvider> providers,
PlatformNotifyGatewayService notifyGatewayService,
PasswordPolicyService passwordPolicyService,
PasswordCodecService passwordCodecService,
@Value("${app.notification.tenant-admin-mail-subject-template:租户管理员账号通知}") String tenantAdminMailSubjectTemplate,
@ -55,6 +58,7 @@ public class TenantService {
this.jdbcTemplate = jdbcTemplate;
this.ossService = ossService;
this.providerMap = new HashMap<String, NotificationChannelProvider>();
this.notifyGatewayService = notifyGatewayService;
this.passwordPolicyService = passwordPolicyService;
this.passwordCodecService = passwordCodecService;
this.tenantAdminMailSubjectTemplate = tenantAdminMailSubjectTemplate;
@ -100,6 +104,7 @@ public class TenantService {
Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM tenant", Long.class);
long tenantId = id == null ? 0L : id;
initTenantBaseline(tenantId);
notifyGatewayService.ensureTenantDefaults(tenantId);
return findById(tenantId);
}
@ -118,6 +123,7 @@ public class TenantService {
rolePermCount += ensureRolePermissionsFromTemplate(tenantId, targetRoleId, roleCode);
roleMenuCount += ensureRoleMenusFromTemplate(tenantId, targetRoleId, roleCode);
}
notifyGatewayService.ensureTenantDefaults(tenantId);
java.util.Map<String, Object> data = new java.util.LinkedHashMap<String, Object>();
data.put("tenantId", tenantId);
data.put("menuInitialized", menuCount);
@ -264,7 +270,8 @@ public class TenantService {
);
String setupLink = passwordSetupService.issueTenantAdminSetupLink(tenantId, uid, safeUserId());
sendTenantAdminMail(tenantId, request, action, setupLink);
// 移除了发送邮件功能
// sendTenantAdminMail(tenantId, request, action, setupLink);
java.util.Map<String, Object> data = new java.util.LinkedHashMap<String, Object>();
data.put("tenantId", tenantId);
@ -272,6 +279,7 @@ public class TenantService {
data.put("roleId", roleId);
data.put("roleCode", roleCode);
data.put("action", action);
data.put("setupLink", setupLink);
return data;
}
@ -547,7 +555,9 @@ public class TenantService {
String payload = "{\"subject\":\"" + jsonEscape(subject) + "\",\"content\":\"" + jsonEscape(content) + "\",\"action\":\"" + jsonEscape(action)
+ "\",\"tenantCode\":\"" + jsonEscape(tenantCode) + "\",\"tenantName\":\"" + jsonEscape(tenantName) + "\",\"loginPath\":\""
+ jsonEscape(loginPath) + "\",\"phone\":\"" + jsonEscape(request.getPhone().trim()) + "\",\"setupLink\":\"" + jsonEscape(setupLink) + "\"}";
NotificationSendResult result = provider.send(request.getEmail().trim(), payload, null);
Map<String, Object> context = new LinkedHashMap<String, Object>();
context.put("tenantId", tenantId);
NotificationSendResult result = provider.send(request.getEmail().trim(), payload, context);
if (result == null || !result.isAccepted()) {
throw new BusinessException(10001, "管理员账号邮件发送失败");
}

View File

@ -39,11 +39,23 @@ public class TemplateController {
@RequestParam(value = "status", required = false) String status,
@RequestParam(value = "scopeType", required = false) String scopeType,
@RequestParam(value = "bizScene", required = false) String bizScene,
@RequestParam(value = "watermarkEnabled", required = false) Boolean watermarkEnabled,
@RequestParam(value = "effectiveStatus", required = false) String effectiveStatus,
@RequestParam(value = "pageNo", defaultValue = "1") int pageNo,
@RequestParam(value = "pageSize", defaultValue = "20") int pageSize) {
return ApiResponse.success(templateService.list(templateName, templateType, status, scopeType, bizScene, watermarkEnabled, effectiveStatus, pageNo, pageSize));
return ApiResponse.success(templateService.list(templateName, templateType, status, scopeType, bizScene, effectiveStatus, pageNo, pageSize));
}
@GetMapping("/view-list")
@RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_VIEW_LIST")
public ApiResponse<PageResult<TemplateInfo>> viewList(
@RequestParam(value = "templateName", required = false) String templateName,
@RequestParam(value = "templateType", required = false) String templateType,
@RequestParam(value = "scopeType", required = false) String scopeType,
@RequestParam(value = "bizScene", required = false) String bizScene,
@RequestParam(value = "effectiveStatus", required = false) String effectiveStatus,
@RequestParam(value = "pageNo", defaultValue = "1") int pageNo,
@RequestParam(value = "pageSize", defaultValue = "20") int pageSize) {
return ApiResponse.success(templateService.listView(templateName, templateType, scopeType, bizScene, effectiveStatus, pageNo, pageSize));
}
@GetMapping("/published-options")
@ -98,6 +110,12 @@ public class TemplateController {
return ApiResponse.success(templateService.create(request));
}
@PostMapping("/{id}/update")
@RequirePermission(value = "template.update", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_UPDATE")
public ApiResponse<TemplateInfo> update(@PathVariable("id") Long id, @RequestBody @Valid com.writeoff.module.template.dto.UpdateTemplateRequest request) {
return ApiResponse.success(templateService.update(id, request));
}
@PostMapping("/upload-sign")
@RequirePermission(value = "template.create", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_UPLOAD_SIGN")
public ApiResponse<Map<String, Object>> uploadSign(@RequestBody @Valid TemplateUploadSignRequest request) {
@ -105,7 +123,7 @@ public class TemplateController {
}
@GetMapping("/{id}/versions")
@RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_VERSIONS")
@RequirePermission(value = "template.detail.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_VERSIONS")
public ApiResponse<List<TemplateVersionInfo>> versions(@PathVariable("id") Long id) {
return ApiResponse.success(templateService.versions(id));
}
@ -148,16 +166,10 @@ public class TemplateController {
return ApiResponse.success(templateService.download(id, request.getRemoteAddr(), request.getHeader("User-Agent")));
}
@GetMapping("/{id}/download-watermark")
@RequirePermission(value = "template.download", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_DOWNLOAD_WATERMARK")
public ApiResponse<Map<String, Object>> downloadWatermark(@PathVariable("id") Long id,
@RequestParam(value = "watermarkText", required = false) String watermarkText,
HttpServletRequest request) {
return ApiResponse.success(templateService.downloadWatermark(id, watermarkText, request.getRemoteAddr(), request.getHeader("User-Agent")));
}
@GetMapping("/{id}/versions/diff")
@RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_VERSION_DIFF")
@RequirePermission(value = "template.detail.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_VERSION_DIFF")
public ApiResponse<Map<String, Object>> versionDiff(@PathVariable("id") Long id,
@RequestParam(value = "leftVersionNo", required = false) Integer leftVersionNo,
@RequestParam(value = "rightVersionNo", required = false) Integer rightVersionNo) {
@ -172,7 +184,6 @@ public class TemplateController {
@RequestParam(value = "userId", required = false) Long userId,
@RequestParam(value = "userKeyword", required = false) String userKeyword,
@RequestParam(value = "versionNo", required = false) Integer versionNo,
@RequestParam(value = "downloadType", required = false) String downloadType,
@RequestParam(value = "ip", required = false) String ip,
@RequestParam(value = "downloadedFrom", required = false) String downloadedFrom,
@RequestParam(value = "downloadedTo", required = false) String downloadedTo,
@ -184,7 +195,6 @@ public class TemplateController {
userId,
userKeyword,
versionNo,
downloadType,
ip,
downloadedFrom,
downloadedTo,

View File

@ -18,7 +18,6 @@ public class CreateTemplateRequest {
private String changeLog;
private String effectiveFrom;
private String effectiveTo;
private Boolean watermarkEnabled;
private Integer downloadRateLimitPerHour;
public String getTemplateName() {
@ -109,13 +108,6 @@ public class CreateTemplateRequest {
this.effectiveTo = effectiveTo;
}
public Boolean getWatermarkEnabled() {
return watermarkEnabled;
}
public void setWatermarkEnabled(Boolean watermarkEnabled) {
this.watermarkEnabled = watermarkEnabled;
}
public Integer getDownloadRateLimitPerHour() {
return downloadRateLimitPerHour;

View File

@ -0,0 +1,100 @@
package com.writeoff.module.template.dto;
import javax.validation.constraints.NotBlank;
public class UpdateTemplateRequest {
@NotBlank(message = "模板名称不能为空")
private String templateName;
@NotBlank(message = "模板类型不能为空")
private String templateType;
@NotBlank(message = "适用范围不能为空")
private String scopeType;
private Long projectId;
private Long meetingId;
private Long scopeId;
private String bizScene;
private String effectiveFrom;
private String effectiveTo;
private Integer downloadRateLimitPerHour;
public String getTemplateName() {
return templateName;
}
public void setTemplateName(String templateName) {
this.templateName = templateName;
}
public String getTemplateType() {
return templateType;
}
public void setTemplateType(String templateType) {
this.templateType = templateType;
}
public String getScopeType() {
return scopeType;
}
public void setScopeType(String scopeType) {
this.scopeType = scopeType;
}
public Long getProjectId() {
return projectId;
}
public void setProjectId(Long projectId) {
this.projectId = projectId;
}
public Long getMeetingId() {
return meetingId;
}
public void setMeetingId(Long meetingId) {
this.meetingId = meetingId;
}
public Long getScopeId() {
return scopeId;
}
public void setScopeId(Long scopeId) {
this.scopeId = scopeId;
}
public String getBizScene() {
return bizScene;
}
public void setBizScene(String bizScene) {
this.bizScene = bizScene;
}
public String getEffectiveFrom() {
return effectiveFrom;
}
public void setEffectiveFrom(String effectiveFrom) {
this.effectiveFrom = effectiveFrom;
}
public String getEffectiveTo() {
return effectiveTo;
}
public void setEffectiveTo(String effectiveTo) {
this.effectiveTo = effectiveTo;
}
public Integer getDownloadRateLimitPerHour() {
return downloadRateLimitPerHour;
}
public void setDownloadRateLimitPerHour(Integer downloadRateLimitPerHour) {
this.downloadRateLimitPerHour = downloadRateLimitPerHour;
}
}

View File

@ -10,7 +10,6 @@ public class TemplateDownloadLogInfo {
private String userPhone;
private String objectKey;
private String downloadType;
private String watermarkText;
private Long projectId;
private Long meetingId;
private String ip;
@ -18,7 +17,7 @@ public class TemplateDownloadLogInfo {
private String downloadedAt;
public TemplateDownloadLogInfo(Long id, Long templateId, String templateName, Integer versionNo, Long userId, String userName, String userPhone,
String objectKey, String downloadType, String watermarkText, Long projectId, Long meetingId,
String objectKey, String downloadType, Long projectId, Long meetingId,
String ip, String userAgent, String downloadedAt) {
this.id = id;
this.templateId = templateId;
@ -29,7 +28,6 @@ public class TemplateDownloadLogInfo {
this.userPhone = userPhone;
this.objectKey = objectKey;
this.downloadType = downloadType;
this.watermarkText = watermarkText;
this.projectId = projectId;
this.meetingId = meetingId;
this.ip = ip;
@ -73,9 +71,7 @@ public class TemplateDownloadLogInfo {
return downloadType;
}
public String getWatermarkText() {
return watermarkText;
}
public Long getProjectId() {
return projectId;

View File

@ -14,13 +14,12 @@ public class TemplateInfo {
private String currentObjectKey;
private String effectiveFrom;
private String effectiveTo;
private Boolean watermarkEnabled;
private Integer downloadRateLimitPerHour;
private String createdAt;
private String updatedAt;
public TemplateInfo(Long id, String templateName, String templateType, String scopeType, Long projectId, Long meetingId, Long scopeId, String bizScene,
String status, Integer currentVersionNo, String currentObjectKey, String effectiveFrom, String effectiveTo, Boolean watermarkEnabled, Integer downloadRateLimitPerHour,
String status, Integer currentVersionNo, String currentObjectKey, String effectiveFrom, String effectiveTo, Integer downloadRateLimitPerHour,
String createdAt, String updatedAt) {
this.id = id;
this.templateName = templateName;
@ -35,7 +34,6 @@ public class TemplateInfo {
this.currentObjectKey = currentObjectKey;
this.effectiveFrom = effectiveFrom;
this.effectiveTo = effectiveTo;
this.watermarkEnabled = watermarkEnabled;
this.downloadRateLimitPerHour = downloadRateLimitPerHour;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
@ -93,9 +91,6 @@ public class TemplateInfo {
return effectiveTo;
}
public Boolean getWatermarkEnabled() {
return watermarkEnabled;
}
public Integer getDownloadRateLimitPerHour() {
return downloadRateLimitPerHour;

View File

@ -59,7 +59,6 @@ public class TemplateService {
rs.getString("current_object_key"),
rs.getString("effective_from"),
rs.getString("effective_to"),
rs.getInt("watermark_enabled") == 1,
rs.getInt("download_rate_limit_per_hour"),
rs.getString("created_at"),
rs.getString("updated_at")
@ -86,7 +85,6 @@ public class TemplateService {
rs.getString("user_phone"),
rs.getString("object_key"),
rs.getString("download_type"),
rs.getString("watermark_text"),
rs.getObject("project_id") == null ? null : rs.getLong("project_id"),
rs.getObject("meeting_id") == null ? null : rs.getLong("meeting_id"),
rs.getString("ip"),
@ -114,7 +112,6 @@ public class TemplateService {
String status,
String scopeType,
String bizScene,
Boolean watermarkEnabled,
String effectiveStatus,
int pageNo,
int pageSize) {
@ -150,10 +147,7 @@ public class TemplateService {
whereSql.append(" AND t.biz_scene=?");
whereArgs.add(normalizedBizScene);
}
if (watermarkEnabled != null) {
whereSql.append(" AND t.watermark_enabled=?");
whereArgs.add(Boolean.TRUE.equals(watermarkEnabled) ? 1 : 0);
}
appendEffectiveStatusFilter(whereSql, normalizedEffectiveStatus);
Integer total = jdbcTemplate.queryForObject(
@ -176,6 +170,16 @@ public class TemplateService {
return new PageResult<>(list, totalCount, safePage, safeSize);
}
public PageResult<TemplateInfo> listView(String templateName,
String templateType,
String scopeType,
String bizScene,
String effectiveStatus,
int pageNo,
int pageSize) {
return list(templateName, templateType, "PUBLISHED", scopeType, bizScene, effectiveStatus, pageNo, pageSize);
}
public List<TemplateInfo> listPublishedOptions(String bizScene) {
String normalizedBizScene = normalizeOptionalBizScene(bizScene);
StringBuilder sql = new StringBuilder(templateSelectSql())
@ -248,8 +252,8 @@ public class TemplateService {
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(
"INSERT INTO template (tenant_id, template_name, template_type, scope_type, project_id, meeting_id, scope_id, biz_scene, status, current_version_no, effective_from, effective_to, watermark_enabled, download_rate_limit_per_hour, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'DRAFT', 1, STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), ?, ?, ?, ?)",
"INSERT INTO template (tenant_id, template_name, template_type, scope_type, project_id, meeting_id, scope_id, biz_scene, status, current_version_no, effective_from, effective_to, download_rate_limit_per_hour, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'DRAFT', 1, STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS
);
ps.setLong(1, tenantId());
@ -262,10 +266,9 @@ public class TemplateService {
ps.setString(8, normalizeBizScene(request.getBizScene()));
ps.setString(9, effectiveFrom);
ps.setString(10, effectiveTo);
ps.setInt(11, Boolean.TRUE.equals(request.getWatermarkEnabled()) ? 1 : 0);
ps.setInt(12, downloadRateLimitPerHour);
ps.setInt(11, downloadRateLimitPerHour);
ps.setLong(12, userId);
ps.setLong(13, userId);
ps.setLong(14, userId);
return ps;
}, keyHolder);
Long templateId = keyHolder.getKey() == null ? null : keyHolder.getKey().longValue();
@ -282,6 +285,36 @@ public class TemplateService {
return findById(validTemplateId);
}
@Transactional(rollbackFor = Exception.class)
public TemplateInfo update(Long templateId, com.writeoff.module.template.dto.UpdateTemplateRequest request) {
TemplateInfo template = findById(templateId);
assertTemplateEditable(template);
String scopeType = normalizeScope(request.getScopeType());
String templateType = normalizeTemplateType(request.getTemplateType());
String effectiveFrom = normalizeOptionalDateTime(request.getEffectiveFrom(), "effectiveFrom");
String effectiveTo = normalizeOptionalDateTime(request.getEffectiveTo(), "effectiveTo");
Integer downloadRateLimitPerHour = normalizeDownloadRateLimit(request.getDownloadRateLimitPerHour());
assertEffectiveRangeValid(effectiveFrom, effectiveTo);
assertTypeOptionEnabled(templateType);
jdbcTemplate.update(
"UPDATE template SET template_name=?, template_type=?, scope_type=?, project_id=?, meeting_id=?, scope_id=?, biz_scene=?, effective_from=STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), effective_to=STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), download_rate_limit_per_hour=?, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?",
request.getTemplateName(),
templateType,
scopeType,
request.getProjectId(),
request.getMeetingId(),
request.getScopeId(),
normalizeBizScene(request.getBizScene()),
effectiveFrom,
effectiveTo,
downloadRateLimitPerHour,
safeUserId(),
tenantId(),
templateId
);
return findById(templateId);
}
public List<TemplateVersionInfo> versions(Long templateId) {
assertTemplateExists(templateId);
return jdbcTemplate.query(
@ -451,15 +484,14 @@ public class TemplateService {
assertTemplateEffectiveNow(template, "下载");
assertDownloadRateLimit(template, userId);
jdbcTemplate.update(
"INSERT INTO template_download_log (tenant_id, template_id, version_no, user_id, object_key, download_type, watermark_text, project_id, meeting_id, ip, user_agent) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO template_download_log (tenant_id, template_id, version_no, user_id, object_key, download_type, project_id, meeting_id, ip, user_agent) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
tenantId(),
template.getId(),
template.getCurrentVersionNo(),
userId,
template.getCurrentObjectKey(),
"NORMAL",
null,
template.getProjectId(),
template.getMeetingId(),
ip,
@ -473,46 +505,6 @@ public class TemplateService {
return result;
}
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> downloadWatermark(Long templateId, String watermarkText, String ip, String userAgent) {
TemplateInfo template = findById(templateId);
if ("DISABLED".equalsIgnoreCase(template.getStatus()) || "ARCHIVED".equalsIgnoreCase(template.getStatus())) {
throw new BusinessException(10003, "模板已停用,无法下载");
}
if (template.getCurrentObjectKey() == null || template.getCurrentObjectKey().trim().isEmpty()) {
throw new BusinessException(10003, "模板当前版本文件不存在");
}
String signedUrl = ossService.generateDownloadUrl(template.getCurrentObjectKey());
Long userId = safeUserId();
assertTemplateDownloadAllowed(template);
if (!template.getWatermarkEnabled()) {
throw new BusinessException(10003, "模板未启用水印下载");
}
assertTemplateEffectiveNow(template, "水印下载");
assertDownloadRateLimit(template, userId);
jdbcTemplate.update(
"INSERT INTO template_download_log (tenant_id, template_id, version_no, user_id, object_key, download_type, watermark_text, project_id, meeting_id, ip, user_agent) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
tenantId(),
template.getId(),
template.getCurrentVersionNo(),
userId,
template.getCurrentObjectKey(),
"WATERMARK",
watermarkText == null ? null : watermarkText.trim(),
template.getProjectId(),
template.getMeetingId(),
ip,
userAgent
);
Map<String, Object> result = new LinkedHashMap<String, Object>();
result.put("templateId", template.getId());
result.put("versionNo", template.getCurrentVersionNo());
result.put("objectKey", template.getCurrentObjectKey());
result.put("signedUrl", signedUrl);
result.put("watermarkText", watermarkText == null ? "" : watermarkText.trim());
return result;
}
public Map<String, Object> versionDiff(Long templateId, Integer leftVersionNo, Integer rightVersionNo) {
assertTemplateExists(templateId);
@ -548,7 +540,6 @@ public class TemplateService {
Long userId,
String userKeyword,
Integer versionNo,
String downloadType,
String ip,
String downloadedFrom,
String downloadedTo,
@ -559,7 +550,6 @@ public class TemplateService {
int offset = (safePage - 1) * safeSize;
String normalizedTemplateName = trimToNull(templateName);
String normalizedUserKeyword = trimToNull(userKeyword);
String normalizedDownloadType = normalizeOptionalDownloadType(downloadType);
String normalizedIp = trimToNull(ip);
String normalizedDownloadedFrom = trimToNull(downloadedFrom);
String normalizedDownloadedTo = trimToNull(downloadedTo);
@ -598,10 +588,7 @@ public class TemplateService {
whereSql.append(" AND l.version_no=?");
whereArgs.add(versionNo);
}
if (normalizedDownloadType != null) {
whereSql.append(" AND l.download_type=?");
whereArgs.add(normalizedDownloadType);
}
if (normalizedIp != null) {
whereSql.append(" AND l.ip LIKE ?");
whereArgs.add("%" + normalizedIp + "%");
@ -628,7 +615,7 @@ public class TemplateService {
List<TemplateDownloadLogInfo> list = jdbcTemplate.query(
"SELECT l.id, l.template_id, COALESCE(t.template_name, '') AS template_name, " +
"l.version_no, l.user_id, COALESCE(u.user_name, '') AS user_name, COALESCE(u.phone, '') AS user_phone, " +
"l.object_key, l.download_type, COALESCE(l.watermark_text, '') AS watermark_text, " +
"l.object_key, l.download_type, " +
"l.project_id, l.meeting_id, l.ip, l.user_agent, DATE_FORMAT(l.downloaded_at, '%Y-%m-%d %H:%i:%s') AS downloaded_at" +
whereSql +
" ORDER BY l.downloaded_at DESC, l.id DESC LIMIT ? OFFSET ?",
@ -709,7 +696,7 @@ public class TemplateService {
"t.current_version_no, tv.object_key AS current_object_key, " +
"DATE_FORMAT(t.effective_from, '%Y-%m-%d %H:%i:%s') AS effective_from, " +
"DATE_FORMAT(t.effective_to, '%Y-%m-%d %H:%i:%s') AS effective_to, " +
"t.watermark_enabled, t.download_rate_limit_per_hour, " +
"t.download_rate_limit_per_hour, " +
"DATE_FORMAT(t.created_at, '%Y-%m-%d %H:%i:%s') AS created_at, " +
"DATE_FORMAT(t.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " +
"FROM template t " +
@ -806,17 +793,7 @@ public class TemplateService {
return normalized;
}
private String normalizeOptionalDownloadType(String downloadType) {
String value = trimToNull(downloadType);
if (value == null) {
return null;
}
String normalized = value.toUpperCase();
if (!"NORMAL".equals(normalized) && !"WATERMARK".equals(normalized)) {
throw new BusinessException(10003, "downloadType仅支持NORMAL/WATERMARK");
}
return normalized;
}
private String normalizeContentType(String contentType) {
if (contentType == null || contentType.trim().isEmpty()) {
@ -936,7 +913,7 @@ public class TemplateService {
private void assertTemplateEditable(TemplateInfo template) {
if ("ARCHIVED".equalsIgnoreCase(template.getStatus())) {
throw new BusinessException(10003, "已归档模板不允许新增版本");
throw new BusinessException(10003, "已归档模板不允许编辑或新增版本");
}
}

View File

@ -20,6 +20,7 @@ import java.util.Map;
@Service
public class PasswordSetupService {
private static final String SCENARIO_TENANT_ADMIN_SETUP = "TENANT_ADMIN_SETUP";
private static final String SCENARIO_USER_SETUP = "USER_SETUP";
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final JdbcTemplate jdbcTemplate;
@ -71,6 +72,36 @@ public class PasswordSetupService {
return buildSetupLink(tenantCode, rawToken);
}
@Transactional
public String issueUserSetupLink(Long tenantId, Long userId, Long operatorUserId) {
Map<String, Object> user = loadSystemUser(tenantId, userId);
String tenantCode = String.valueOf(user.get("tenant_code"));
jdbcTemplate.update(
"UPDATE auth_password_setup_token " +
"SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP " +
"WHERE tenant_id=? AND user_id=? AND scenario=? AND is_deleted=0 AND used_at IS NULL",
safeOperator(operatorUserId),
tenantId,
userId,
SCENARIO_USER_SETUP
);
String rawToken = generateToken();
LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(Math.max(passwordSetupExpireMinutes, 10L));
jdbcTemplate.update(
"INSERT INTO auth_password_setup_token (tenant_id, user_id, scenario, token_hash, expires_at, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?)",
tenantId,
userId,
SCENARIO_USER_SETUP,
hashToken(rawToken),
Timestamp.valueOf(expiresAt),
safeOperator(operatorUserId),
safeOperator(operatorUserId)
);
return buildSetupLink(tenantCode, rawToken);
}
public Map<String, Object> verifyTenantPasswordSetupToken(String tenantCode, String rawToken) {
Map<String, Object> tokenRecord = loadAvailableTokenRecord(tenantCode, rawToken);
Map<String, Object> data = new LinkedHashMap<String, Object>();
@ -112,11 +143,12 @@ public class PasswordSetupService {
jdbcTemplate.update(
"UPDATE auth_password_setup_token " +
"SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP " +
"WHERE tenant_id=? AND user_id=? AND scenario=? AND id<>? AND is_deleted=0 AND used_at IS NULL",
"WHERE tenant_id=? AND user_id=? AND scenario IN (?, ?) AND id<>? AND is_deleted=0 AND used_at IS NULL",
userId,
tenantId,
userId,
SCENARIO_TENANT_ADMIN_SETUP,
SCENARIO_USER_SETUP,
tokenId
);
@ -139,11 +171,12 @@ public class PasswordSetupService {
"FROM auth_password_setup_token tkn " +
"JOIN tenant t ON tkn.tenant_id=t.id " +
"JOIN sys_user u ON tkn.user_id=u.id AND tkn.tenant_id=u.tenant_id " +
"WHERE tkn.token_hash=? AND tkn.scenario=? AND tkn.is_deleted=0 " +
"WHERE tkn.token_hash=? AND tkn.scenario IN (?, ?) AND tkn.is_deleted=0 " +
"AND t.is_deleted=0 AND u.is_deleted=0 " +
"LIMIT 1",
hashToken(normalizedToken),
SCENARIO_TENANT_ADMIN_SETUP
SCENARIO_TENANT_ADMIN_SETUP,
SCENARIO_USER_SETUP
);
if (rows.isEmpty()) {
throw new BusinessException(10001, "设置链接无效或已过期");
@ -181,6 +214,22 @@ public class PasswordSetupService {
return rows.get(0);
}
private Map<String, Object> loadSystemUser(Long tenantId, Long userId) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT u.id, u.tenant_id, t.tenant_code " +
"FROM sys_user u " +
"JOIN tenant t ON u.tenant_id=t.id " +
"WHERE u.tenant_id=? AND u.id=? AND u.is_deleted=0 AND t.is_deleted=0 " +
"LIMIT 1",
tenantId,
userId
);
if (rows.isEmpty()) {
throw new BusinessException(10003, "用户不存在");
}
return rows.get(0);
}
private String buildSetupLink(String tenantCode, String rawToken) {
String baseUrl = normalizeBaseUrl(frontendBaseUrl);
String path = "/" + tenantCode + "/setup-password?token=" + urlEncode(rawToken);

View File

@ -0,0 +1,29 @@
SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission);
INSERT INTO permission (id, permission_code, permission_name, module)
SELECT @next_permission_id, 'meeting.labor-agreement.extract', '上传并抽取劳务费协议', 'meeting'
FROM dual
WHERE NOT EXISTS (SELECT 1 FROM permission WHERE permission_code = 'meeting.labor-agreement.extract');
UPDATE permission
SET permission_name = '上传并抽取劳务费协议',
module = 'meeting'
WHERE permission_code = 'meeting.labor-agreement.extract';
SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission);
INSERT INTO role_permission (id, tenant_id, role_id, permission_id)
SELECT
(@next_role_permission_id := @next_role_permission_id + 1) AS id,
r.tenant_id,
r.id AS role_id,
p.id AS permission_id
FROM role r
JOIN permission p ON p.permission_code = 'meeting.labor-agreement.extract'
WHERE r.role_code = 'TENANT_ADMIN'
AND r.is_deleted = 0
AND NOT EXISTS (
SELECT 1
FROM role_permission rp
WHERE rp.tenant_id = r.tenant_id
AND rp.role_id = r.id
AND rp.permission_id = p.id
);

View File

@ -0,0 +1,17 @@
SET @c := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'project'
AND COLUMN_NAME = 'labor_agreement_sign_type'
);
SET @sql := IF(
@c = 0,
'ALTER TABLE project ADD COLUMN labor_agreement_sign_type TINYINT NOT NULL DEFAULT 1 COMMENT ''劳务费协议签署类型1-放心签2-线下签'' AFTER labor_fee_ratio',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
UPDATE project
SET labor_agreement_sign_type = 1
WHERE labor_agreement_sign_type IS NULL OR labor_agreement_sign_type NOT IN (1, 2);

View File

@ -0,0 +1,10 @@
ALTER TABLE notification_policy
ADD COLUMN sms_template_code VARCHAR(128) DEFAULT NULL AFTER template_id;
UPDATE notification_policy p
JOIN platform_notify_gateway g
ON g.channel_code = 'SMS'
AND g.is_deleted = 0
SET p.sms_template_code = NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.config_json, '$.templateCode')), '')
WHERE p.channel = 'SMS'
AND (p.sms_template_code IS NULL OR p.sms_template_code = '');

View File

@ -0,0 +1,46 @@
CREATE TABLE IF NOT EXISTS meeting_submission_version (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
meeting_id BIGINT UNSIGNED NOT NULL,
version_no INT NOT NULL,
remark VARCHAR(500) DEFAULT NULL,
snapshot_json LONGTEXT NOT NULL,
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_tenant_meeting_version (tenant_id, meeting_id, version_no),
KEY idx_tenant_meeting_created (tenant_id, meeting_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SET @c := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'audit_task'
AND COLUMN_NAME = 'submission_version_id'
);
SET @sql := IF(
@c = 0,
'ALTER TABLE audit_task ADD COLUMN submission_version_id BIGINT UNSIGNED NULL AFTER meeting_id',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @idx := (
SELECT COUNT(1)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'audit_task'
AND INDEX_NAME = 'idx_tenant_submission_version'
);
SET @sql := IF(
@idx = 0,
'ALTER TABLE audit_task ADD INDEX idx_tenant_submission_version (tenant_id, submission_version_id)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,39 @@
CREATE TABLE IF NOT EXISTS audit_issue (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
task_id BIGINT UNSIGNED NOT NULL,
meeting_id BIGINT UNSIGNED NOT NULL,
submission_version_id BIGINT UNSIGNED DEFAULT NULL,
review_node VARCHAR(32) NOT NULL,
module_code VARCHAR(32) NOT NULL,
target_path VARCHAR(255) NOT NULL,
target_label VARCHAR(255) NOT NULL,
reason VARCHAR(500) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'OPEN',
response_text VARCHAR(1000) DEFAULT NULL,
responded_at DATETIME DEFAULT NULL,
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_audit_issue_task (tenant_id, task_id),
KEY idx_audit_issue_meeting (tenant_id, meeting_id),
KEY idx_audit_issue_version (tenant_id, submission_version_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS issue_response (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
issue_id BIGINT UNSIGNED NOT NULL,
submission_version_id BIGINT UNSIGNED DEFAULT NULL,
response_text VARCHAR(1000) NOT NULL,
response_status VARCHAR(16) NOT NULL DEFAULT 'PENDING_CONFIRM',
responded_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
responded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_issue_response_issue (tenant_id, issue_id),
KEY idx_issue_response_version (tenant_id, submission_version_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -0,0 +1,94 @@
CREATE TABLE IF NOT EXISTS version_change_set (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
meeting_id BIGINT NOT NULL,
from_submission_version_id BIGINT NULL,
to_submission_version_id BIGINT NULL,
changed_module_count INT NOT NULL DEFAULT 0,
changed_item_count INT NOT NULL DEFAULT 0,
issue_related_count INT NOT NULL DEFAULT 0,
extra_change_count INT NOT NULL DEFAULT 0,
created_by BIGINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_vcs_meeting (tenant_id, meeting_id),
KEY idx_vcs_to_submission (tenant_id, to_submission_version_id)
);
CREATE TABLE IF NOT EXISTS version_change_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
change_set_id BIGINT NOT NULL,
meeting_id BIGINT NOT NULL,
module_code VARCHAR(64) NOT NULL,
target_path VARCHAR(255) NOT NULL,
target_label VARCHAR(255) NOT NULL,
change_type VARCHAR(32) NOT NULL,
old_value TEXT NULL,
new_value TEXT NULL,
related_issue_id BIGINT NULL,
is_extra_change TINYINT NOT NULL DEFAULT 0,
created_by BIGINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_vci_change_set (tenant_id, change_set_id),
KEY idx_vci_meeting_module (tenant_id, meeting_id, module_code)
);
SET @c := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'version_change_item'
AND COLUMN_NAME = 'target_kind'
);
SET @sql := IF(
@c = 0,
'ALTER TABLE version_change_item ADD COLUMN target_kind VARCHAR(32) NOT NULL DEFAULT ''FIELD'' AFTER target_label',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @c := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'version_change_item'
AND COLUMN_NAME = 'target_row_key'
);
SET @sql := IF(
@c = 0,
'ALTER TABLE version_change_item ADD COLUMN target_row_key VARCHAR(255) NULL AFTER target_kind',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @c := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'version_change_item'
AND COLUMN_NAME = 'attachment_identity'
);
SET @sql := IF(
@c = 0,
'ALTER TABLE version_change_item ADD COLUMN attachment_identity VARCHAR(255) NULL AFTER target_row_key',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @c := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'version_change_item'
AND COLUMN_NAME = 'attachment_hash'
);
SET @sql := IF(
@c = 0,
'ALTER TABLE version_change_item ADD COLUMN attachment_hash VARCHAR(128) NULL AFTER attachment_identity',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,38 @@
SET @c := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'meeting_material'
AND COLUMN_NAME = 'draft_content_json'
);
SET @sql := IF(
@c = 0,
'ALTER TABLE meeting_material ADD COLUMN draft_content_json TEXT NULL AFTER content_json',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @c := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'meeting_material'
AND COLUMN_NAME = 'draft_remark'
);
SET @sql := IF(
@c = 0,
'ALTER TABLE meeting_material ADD COLUMN draft_remark VARCHAR(500) NULL AFTER submit_remark',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
UPDATE meeting_material
SET draft_content_json = CASE
WHEN draft_content_json IS NULL OR draft_content_json = '' THEN content_json
ELSE draft_content_json
END,
draft_remark = CASE
WHEN draft_remark IS NULL OR draft_remark = '' THEN submit_remark
ELSE draft_remark
END
WHERE tenant_id IS NOT NULL;

View File

@ -0,0 +1,195 @@
CREATE TABLE IF NOT EXISTS tenant_notify_gateway (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
channel_code VARCHAR(32) NOT NULL,
gateway_name VARCHAR(64) NOT NULL,
provider_code VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'DISABLED',
config_json TEXT DEFAULT NULL,
secret_config_cipher TEXT DEFAULT NULL,
remark VARCHAR(255) DEFAULT NULL,
is_deleted TINYINT(1) NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_tenant_notify_gateway_channel (tenant_id, channel_code),
KEY idx_tenant_notify_gateway_tenant (tenant_id, is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO tenant_notify_gateway (
tenant_id,
channel_code,
gateway_name,
provider_code,
status,
config_json,
secret_config_cipher,
remark,
is_deleted,
created_by,
updated_by
)
SELECT
t.id,
'EMAIL',
'邮件网关',
COALESCE(NULLIF(pg.provider_code, ''), 'SMTP'),
COALESCE(NULLIF(pg.status, ''), 'DISABLED'),
COALESCE(pg.config_json, '{}'),
COALESCE(pg.secret_config_cipher, ''),
COALESCE(NULLIF(pg.remark, ''), '当前租户邮件网关配置'),
0,
0,
0
FROM tenant t
LEFT JOIN platform_notify_gateway pg
ON pg.channel_code = 'EMAIL'
AND pg.is_deleted = 0
WHERE t.is_deleted = 0
AND NOT EXISTS (
SELECT 1
FROM tenant_notify_gateway tg
WHERE tg.tenant_id = t.id
AND tg.channel_code = 'EMAIL'
AND tg.is_deleted = 0
);
INSERT INTO tenant_notify_gateway (
tenant_id,
channel_code,
gateway_name,
provider_code,
status,
config_json,
secret_config_cipher,
remark,
is_deleted,
created_by,
updated_by
)
SELECT
t.id,
'SMS',
'短信网关',
COALESCE(NULLIF(pg.provider_code, ''), 'MOCK'),
COALESCE(NULLIF(pg.status, ''), 'DISABLED'),
COALESCE(pg.config_json, '{}'),
COALESCE(pg.secret_config_cipher, ''),
COALESCE(NULLIF(pg.remark, ''), '当前租户短信网关配置'),
0,
0,
0
FROM tenant t
LEFT JOIN platform_notify_gateway pg
ON pg.channel_code = 'SMS'
AND pg.is_deleted = 0
WHERE t.is_deleted = 0
AND NOT EXISTS (
SELECT 1
FROM tenant_notify_gateway tg
WHERE tg.tenant_id = t.id
AND tg.channel_code = 'SMS'
AND tg.is_deleted = 0
);
SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission);
INSERT INTO permission (id, permission_code, permission_name, module)
SELECT @next_permission_id, 'notification.notify-gateway.read', '查看通知网关配置', 'notification'
FROM dual
WHERE NOT EXISTS (
SELECT 1
FROM permission
WHERE permission_code = 'notification.notify-gateway.read'
);
SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission);
INSERT INTO permission (id, permission_code, permission_name, module)
SELECT @next_permission_id, 'notification.notify-gateway.manage', '管理通知网关配置', 'notification'
FROM dual
WHERE NOT EXISTS (
SELECT 1
FROM permission
WHERE permission_code = 'notification.notify-gateway.manage'
);
UPDATE permission
SET permission_name = '查看通知网关配置',
module = 'notification'
WHERE permission_code = 'notification.notify-gateway.read';
UPDATE permission
SET permission_name = '管理通知网关配置',
module = 'notification'
WHERE permission_code = 'notification.notify-gateway.manage';
INSERT INTO menu (tenant_id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by)
VALUES
(1, 'notification_notify_gateway', '通知网关配置', '/notify-gateways', 'notification.notify-gateway.read', 178, 'ENABLED', 0, 0, 0)
ON DUPLICATE KEY UPDATE
menu_name = VALUES(menu_name),
route_path = VALUES(route_path),
permission_code = VALUES(permission_code),
sort_no = VALUES(sort_no),
status = VALUES(status),
is_deleted = VALUES(is_deleted),
updated_at = CURRENT_TIMESTAMP;
SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission);
INSERT INTO role_permission (id, tenant_id, role_id, permission_id)
SELECT
(@next_role_permission_id := @next_role_permission_id + 1) AS id,
r.tenant_id,
r.id,
p.id
FROM role r
JOIN permission p ON p.permission_code = 'notification.notify-gateway.read'
WHERE r.role_code = 'TENANT_ADMIN'
AND r.is_deleted = 0
AND NOT EXISTS (
SELECT 1
FROM role_permission rp
WHERE rp.tenant_id = r.tenant_id
AND rp.role_id = r.id
AND rp.permission_id = p.id
);
INSERT INTO role_permission (id, tenant_id, role_id, permission_id)
SELECT
(@next_role_permission_id := @next_role_permission_id + 1) AS id,
r.tenant_id,
r.id,
p.id
FROM role r
JOIN permission p ON p.permission_code = 'notification.notify-gateway.manage'
WHERE r.role_code = 'TENANT_ADMIN'
AND r.is_deleted = 0
AND NOT EXISTS (
SELECT 1
FROM role_permission rp
WHERE rp.tenant_id = r.tenant_id
AND rp.role_id = r.id
AND rp.permission_id = p.id
);
SET @next_role_menu_id = (SELECT IFNULL(MAX(id), 0) FROM role_menu);
INSERT INTO role_menu (id, tenant_id, role_id, menu_id)
SELECT
(@next_role_menu_id := @next_role_menu_id + 1) AS id,
r.tenant_id,
r.id,
m.id
FROM role r
JOIN menu m
ON m.tenant_id = r.tenant_id
AND m.menu_code = 'notification_notify_gateway'
AND m.is_deleted = 0
WHERE r.role_code = 'TENANT_ADMIN'
AND r.is_deleted = 0
AND NOT EXISTS (
SELECT 1
FROM role_menu rm
WHERE rm.tenant_id = r.tenant_id
AND rm.role_id = r.id
AND rm.menu_id = m.id
);

View File

@ -0,0 +1,48 @@
CREATE TABLE IF NOT EXISTS tenant_notify_delivery_guard (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
channel_code VARCHAR(32) NOT NULL,
receiver_ref VARCHAR(128) NOT NULL,
stat_date DATE NOT NULL,
daily_count INT NOT NULL DEFAULT 0,
last_sent_at DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_tenant_notify_delivery_guard (tenant_id, channel_code, receiver_ref, stat_date),
KEY idx_tenant_notify_delivery_guard_date (tenant_id, stat_date, channel_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS tenant_notify_circuit_breaker (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
channel_code VARCHAR(32) NOT NULL,
consecutive_failures INT NOT NULL DEFAULT 0,
breaker_until DATETIME DEFAULT NULL,
last_failure_message VARCHAR(500) DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_tenant_notify_circuit_breaker (tenant_id, channel_code),
KEY idx_tenant_notify_circuit_breaker_tenant (tenant_id, channel_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO tenant_notify_circuit_breaker (tenant_id, channel_code, consecutive_failures, breaker_until, last_failure_message)
SELECT t.id, 'EMAIL', 0, NULL, NULL
FROM tenant t
WHERE t.is_deleted = 0
AND NOT EXISTS (
SELECT 1
FROM tenant_notify_circuit_breaker cb
WHERE cb.tenant_id = t.id
AND cb.channel_code = 'EMAIL'
);
INSERT INTO tenant_notify_circuit_breaker (tenant_id, channel_code, consecutive_failures, breaker_until, last_failure_message)
SELECT t.id, 'SMS', 0, NULL, NULL
FROM tenant t
WHERE t.is_deleted = 0
AND NOT EXISTS (
SELECT 1
FROM tenant_notify_circuit_breaker cb
WHERE cb.tenant_id = t.id
AND cb.channel_code = 'SMS'
);

View File

@ -0,0 +1,8 @@
UPDATE notification_policy p
JOIN tenant_notify_gateway g
ON g.tenant_id = p.tenant_id
AND g.channel_code = 'SMS'
AND g.is_deleted = 0
SET p.sms_template_code = NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.config_json, '$.templateCode')), '')
WHERE p.channel = 'SMS'
AND (p.sms_template_code IS NULL OR p.sms_template_code = '');

View File

@ -0,0 +1,18 @@
SET @c := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'project'
AND COLUMN_NAME = 'catering_fee_ratio'
);
SET @sql := IF(
@c = 0,
'ALTER TABLE project ADD COLUMN catering_fee_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 AFTER labor_fee_ratio',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
ALTER TABLE project
MODIFY COLUMN catering_fee_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 COMMENT '餐费占比';

View File

@ -0,0 +1,28 @@
SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission);
INSERT INTO permission (id, permission_code, permission_name, module)
SELECT @next_permission_id, 'project.sensitive_data.read', '查看项目敏感数据', 'project'
FROM dual
WHERE NOT EXISTS (
SELECT 1
FROM permission
WHERE permission_code = 'project.sensitive_data.read'
);
SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission);
INSERT INTO role_permission (id, tenant_id, role_id, permission_id)
SELECT
(@next_role_permission_id := @next_role_permission_id + 1) AS id,
r.tenant_id,
r.id AS role_id,
p.id AS permission_id
FROM role r
JOIN permission p ON p.permission_code = 'project.sensitive_data.read'
WHERE r.role_code IN ('TENANT_ADMIN', 'PROJECT_OWNER', 'FINANCE')
AND r.is_deleted = 0
AND NOT EXISTS (
SELECT 1
FROM role_permission rp
WHERE rp.tenant_id = r.tenant_id
AND rp.role_id = r.id
AND rp.permission_id = p.id
);

View File

@ -0,0 +1,27 @@
-- 添加模板编辑权限
INSERT INTO permission (id, permission_code, permission_name, module)
SELECT t.next_id, 'template.update', '编辑模板', 'template'
FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t
WHERE NOT EXISTS (
SELECT 1 FROM permission WHERE permission_code = 'template.update'
);
-- 授予角色编辑模板权限(通常跟随 template.create 一起授权)
-- 找出所有拥有 template.create 权限的角色,给他们也加上 template.update 权限
SET @next_rp_id = (SELECT IFNULL(MAX(id), 0) FROM role_permission);
INSERT INTO role_permission (id, tenant_id, role_id, permission_id)
SELECT
(@next_rp_id := @next_rp_id + 1) AS id,
rp.tenant_id,
rp.role_id,
p.id
FROM role_permission rp
JOIN permission source_p ON source_p.id = rp.permission_id AND source_p.permission_code = 'template.create'
JOIN permission p ON p.permission_code = 'template.update'
WHERE NOT EXISTS (
SELECT 1 FROM role_permission existing_rp
WHERE existing_rp.tenant_id = rp.tenant_id
AND existing_rp.role_id = rp.role_id
AND existing_rp.permission_id = p.id
);

View File

@ -0,0 +1,51 @@
-- 添加查看详情和水印下载权限
INSERT INTO permission (id, permission_code, permission_name, module)
SELECT t.next_id, 'template.detail.read', '查看模板详情', 'template'
FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t
WHERE NOT EXISTS (
SELECT 1 FROM permission WHERE permission_code = 'template.detail.read'
);
INSERT INTO permission (id, permission_code, permission_name, module)
SELECT t.next_id, 'template.watermark.download', '水印下载模板', 'template'
FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t
WHERE NOT EXISTS (
SELECT 1 FROM permission WHERE permission_code = 'template.watermark.download'
);
-- 授予角色新权限(继承现有权限)
SET @next_rp_id = (SELECT IFNULL(MAX(id), 0) FROM role_permission);
-- 给有 template.read 的加上 template.detail.read
INSERT INTO role_permission (id, tenant_id, role_id, permission_id)
SELECT
(@next_rp_id := @next_rp_id + 1) AS id,
rp.tenant_id,
rp.role_id,
p.id
FROM role_permission rp
JOIN permission source_p ON source_p.id = rp.permission_id AND source_p.permission_code = 'template.read'
JOIN permission p ON p.permission_code = 'template.detail.read'
WHERE NOT EXISTS (
SELECT 1 FROM role_permission existing_rp
WHERE existing_rp.tenant_id = rp.tenant_id
AND existing_rp.role_id = rp.role_id
AND existing_rp.permission_id = p.id
);
-- 给有 template.download 的加上 template.watermark.download
INSERT INTO role_permission (id, tenant_id, role_id, permission_id)
SELECT
(@next_rp_id := @next_rp_id + 1) AS id,
rp.tenant_id,
rp.role_id,
p.id
FROM role_permission rp
JOIN permission source_p ON source_p.id = rp.permission_id AND source_p.permission_code = 'template.download'
JOIN permission p ON p.permission_code = 'template.watermark.download'
WHERE NOT EXISTS (
SELECT 1 FROM role_permission existing_rp
WHERE existing_rp.tenant_id = rp.tenant_id
AND existing_rp.role_id = rp.role_id
AND existing_rp.permission_id = p.id
);

View File

@ -0,0 +1,7 @@
-- 移除水印相关字段
ALTER TABLE template DROP COLUMN watermark_enabled;
ALTER TABLE template_download_log DROP COLUMN watermark_text;
-- 删除之前新增的 template.watermark.download 权限
DELETE FROM role_permission WHERE permission_id IN (SELECT id FROM permission WHERE permission_code = 'template.watermark.download');
DELETE FROM permission WHERE permission_code = 'template.watermark.download';

View File

@ -0,0 +1,27 @@
SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission);
INSERT INTO permission (id, permission_code, permission_name, module)
SELECT @next_permission_id, 'meeting.update', '编辑会议', 'meeting'
FROM dual
WHERE NOT EXISTS (
SELECT 1
FROM permission
WHERE permission_code = 'meeting.update'
);
SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission);
INSERT INTO role_permission (id, tenant_id, role_id, permission_id)
SELECT
(@next_role_permission_id := @next_role_permission_id + 1) AS id,
rp.tenant_id,
rp.role_id,
p.id AS permission_id
FROM role_permission rp
JOIN permission source_p ON source_p.id = rp.permission_id AND source_p.permission_code = 'meeting.create'
JOIN permission p ON p.permission_code = 'meeting.update'
WHERE NOT EXISTS (
SELECT 1
FROM role_permission exist_rp
WHERE exist_rp.tenant_id = rp.tenant_id
AND exist_rp.role_id = rp.role_id
AND exist_rp.permission_id = p.id
);

View File

@ -0,0 +1,85 @@
package com.writeoff.module.audit.service;
import com.writeoff.module.audit.dto.AuditActionRequest;
import com.writeoff.module.audit.model.AuditNode;
import com.writeoff.module.audit.model.AuditTask;
import com.writeoff.module.audit.model.AuditTaskStatus;
import com.writeoff.module.audit.repository.InMemoryAuditTaskRepository;
import com.writeoff.module.meeting.service.MeetingService;
import com.writeoff.module.scheduler.repository.InMemoryAsyncJobRepository;
import com.writeoff.module.scheduler.service.AsyncJobService;
import com.writeoff.security.AuthContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class AuditServiceNotificationTest {
@AfterEach
void clearAuthContext() {
AuthContext.clear();
}
@Test
void approveShouldTriggerAssignedNotificationForNextNode() {
AuthContext.set(2001L, 1L);
InMemoryAuditTaskRepository repository = new InMemoryAuditTaskRepository();
AsyncJobService asyncJobService = new AsyncJobService(new InMemoryAsyncJobRepository());
MeetingService meetingService = mock(MeetingService.class);
AuditFlowConfigService auditFlowConfigService = mock(AuditFlowConfigService.class);
AuditTask pendingTask = repository.save(new AuditTask(
null,
9001L,
AuditNode.INIT_REVIEW,
2001L,
AuditTaskStatus.PENDING,
""
));
when(auditFlowConfigService.nextNode(1L, AuditNode.INIT_REVIEW)).thenReturn(AuditNode.RE_REVIEW);
when(auditFlowConfigService.resolveAssigneeUserId(1L, AuditNode.RE_REVIEW)).thenReturn(3001L);
AuditService auditService = new AuditService(
repository,
meetingService,
null,
asyncJobService,
auditFlowConfigService,
null,
null,
null,
null,
null,
null,
null,
null,
null
);
AuditActionRequest request = new AuditActionRequest();
request.setIdempotencyKey("approve-next-node-notify");
request.setOpinion("初审通过");
auditService.approve(pendingTask.getId(), request);
ArgumentCaptor<AuditTask> taskCaptor = ArgumentCaptor.forClass(AuditTask.class);
verify(meetingService, times(1)).updateCurrentAuditNode(9001L, AuditNode.RE_REVIEW.name(), 3001L);
verify(meetingService, times(1)).triggerAuditTaskAssignedNotification(taskCaptor.capture());
AuditTask nextTask = taskCaptor.getValue();
assertNotNull(nextTask);
assertEquals(9001L, nextTask.getMeetingId());
assertEquals(AuditNode.RE_REVIEW, nextTask.getNode());
assertEquals(Long.valueOf(3001L), nextTask.getAssigneeUserId());
assertEquals(AuditTaskStatus.PENDING, nextTask.getStatus());
}
}

View File

@ -7,6 +7,7 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatcher;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
@ -25,11 +26,14 @@ class SystemUserServicePasswordTest {
void shouldAcceptLegacyPlaintextOldPasswordAndUpgradeToHashWhenChangingPassword() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
PasswordCodecService passwordCodecService = new PasswordCodecService();
PlatformTransactionManager transactionManager = mock(PlatformTransactionManager.class);
SystemUserService systemUserService = new SystemUserService(
jdbcTemplate,
null,
null,
new PasswordPolicyService(),
passwordCodecService
passwordCodecService,
transactionManager
);
AuthContext.set(1001L, 2001L);
when(jdbcTemplate.queryForObject(

16
docker-compose.yml Normal file
View File

@ -0,0 +1,16 @@
version: '3.8'
services:
writeoff-backend:
image: writeoff-backend:latest
container_name: writeoff-backend
ports:
- "8080:8080"
restart: always
environment:
- SPRING_PROFILES_ACTIVE=prod
# 在这里配置数据库等环境变量
# - SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/writeoff...
# 如果有同级的数据库容器,可以在这里关联
# depends_on:
# - db

281
docs/审核流程优化.md Normal file
View File

@ -0,0 +1,281 @@
优先级 ToDoList
P0先做不做后面的方案立不住
- [ ] 审核任务改为绑定“提交版本”而不是 `meetingId/current material`
状态:已完成
说明:`audit_task` 已绑定 `submission_version_id`;审核详情、材料抽屉、差异摘要、问题闭环等新任务读取链路均优先消费绑定的提交版本快照,不再读取申请人当前草稿,旧任务仅在缺少版本绑定时走兼容兜底。
- [ ] 提交后版本不可变,申请人只允许编辑草稿,重新提交生成新版本
状态:已完成
说明:已为 `meeting_material` 增加草稿字段,申请端模块保存/模块提交只更新草稿;整单提交时才把草稿固化为新的提交版本并同步 `meeting_submission_version``meeting_material_history`,已提交版本不再被后续编辑覆盖。
- [ ] 驳回改为结构化问题数组,禁止仅提交自由文本驳回意见
状态:已完成
说明:单任务驳回现已强制要求提交非空 `issues[]`,且每条结构化问题必须包含模块、定位、标签、原因等必填字段;自由文本 `opinion` 仅保留为可选补充说明,不能再单独作为驳回依据。批量驳回仍明确禁用,避免绕过结构化问题闭环。
- [ ] 建立 `audit_issue / issue_response / version_change_set / version_change_item` 核心表结构
状态:部分完成
说明:已落 `audit_issue / issue_response` 基础表并接入问题写入/回应闭环;同时新增 `version_change_set / version_change_item` 表结构与会议提交后的差异持久化链路,但差异粒度仍以当前模块级 `itemKey` 为主,尚未升级到文档目标里的统一字段/行/附件模型。
- [ ] 审核详情聚合接口一次返回版本、问题、回应、差异、完整快照
状态:已完成
说明:整单级审核详情接口已可一次返回审核任务、绑定提交版本、完整快照、问题列表、回应结果、差异摘要与模块详情;新任务统一读取绑定版本快照,旧任务仅在历史数据缺版本绑定时做兼容回退。
P1核心流程闭环
- [ ] 申请端支持逐条填写问题处理说明,并落库到 `issue_response`
状态:已完成
说明:会议级与模块级重新提交均支持逐条填写问题处理说明,申请端“去修改”跳转与字段高亮链路已接入;提交后会把回应按提交版本写入 `issue_response`,并回写问题当前处理说明。
- [ ] 重新提交时强制校验未关闭问题都已响应,否则不允许提交
状态:已完成
说明:会议级与模块级重新提交均已在前后端双重校验未关闭问题必须逐条响应;待处理问题读取范围已统一覆盖 `OPEN / PENDING_CONFIRM`,避免遗漏“已回复待确认”的问题。
- [ ] 审核端问题闭环支持三态:已修改待确认、未修改、已确认解决
状态:已完成
说明:审核端复审摘要已稳定区分“已修改待确认 / 未修改 / 已确认解决”三态;审核人确认解决时会按问题真实归属记录更新状态,不再受当前待审任务与原驳回任务 ID 不一致影响。
- [ ] 审核通过时自动关闭当前任务及关联未关闭问题;再次驳回时挂到新版本
状态:已完成
说明:终审通过时会自动关闭会议下未关闭问题;再次驳回前会先收口旧版本未关闭问题,再按当前提交版本重新生成新的结构化问题,确保问题生命周期按版本闭环流转。
- [ ] 审核处理逻辑校验“只能处理分配给自己的任务”
状态:已完成
说明:后端审核主入口和资料审核入口已统一增加处理人硬校验;除未分配任务外,非当前处理人无法执行通过、驳回、退回、转审及资料项审核动作。
P2差异引擎与规则完善
- [ ] 差异结果从“模块 itemKey 对比”升级为“字段/行/附件”统一 ChangeSet
状态:已完成
说明:`version_change_set / version_change_item` 已升级为统一的字段 / 行 / 附件差异模型,并持久化 `target_kind / target_row_key / attachment_identity / attachment_hash`;会议重新提交后会沉淀跨版本差异,审核详情聚合接口、审核抽屉“本次修改”、申请端复审预览均已优先消费持久化 `changeSet`
- [ ] 实现问题命中规则:精确命中、父子路径命中、模块内额外修改识别
状态:已完成
说明:已支持精确命中、父子路径命中和模块内额外修改识别;审核复审摘要、持久化 `version_change_item`、审核抽屉高亮定位与申请端“去修改”链路均会优先关联命中的驳回问题,并兼容 `agenda / invitation / profileFile / meeting_invoice:OTHER / invoice:*` 等历史别名。
- [ ] 附件比对改为 `asset_id/hash` 维度,避免仅按文件名或当前结构判断
状态:已完成
说明:当前已按工程化附件身份实现稳定比对:优先使用 `ossKey/objectKey` 作为附件 identity缺失时回退到 `fileName|size|contentType` 的短 hash并持久化 `attachment_identity / attachment_hash`;尚未引入独立 `attachment_asset` 实体,但已不再依赖纯文件名或当前数组结构判断。
- [ ] 明细数组建立稳定行主键,避免只靠数组下标做差异
状态:已完成
说明:明细数组已按业务稳定键建模差异与审核项:专家资料优先用 `expertId`,专家发票优先用 `invoiceNo`,会议发票按 `sectionCode + fieldKey`,文档附件优先用 `ossKey`,仅在历史或异常数据缺少稳定键时才回退到顺序索引,避免常态场景只靠数组下标做差异。
P3审核端页面
- [ ] 审核列表完整显示:重新提交、本次修改数、驳回项处理进度、额外修改数
状态:已完成
说明:审核列表已完整展示“重新提交 / 本次修改数 / 驳回项处理进度 / 额外修改数 / 未解决项 / 高风险复审”等核心标签;同时补齐复审快速筛选、后端级 `reviewFocus` 查询过滤,以及待我处理列表按 `riskScore` 的服务端风险优先排序,避免列表结果继续依赖前端二次筛选。
- [ ] 审核详情页固定为四块:本次摘要、驳回问题闭环、仅看修改、完整资料
状态:已完成
说明:审核详情抽屉已改为整单级四区固定布局:`本次摘要 / 驳回问题闭环 / 仅看修改 / 完整资料`;顶部摘要统一消费 `fetchAuditTaskDetail` 聚合结果,完整资料保留模块 tabs 作为第四区内部导航,不再以模块级抽屉替代整单视图。
- [ ] 完整资料页支持变更高亮,并可从字段跳回对应 ChangeItem
状态:已完成
说明:完整资料区已覆盖基础信息、核销材料、专家简介、专家资料、会议发票等模块的变更高亮;支持从整单“仅看修改”点击后自动切换到目标模块并滚动定位到对应字段/材料卡片,也支持从高亮字段反向回跳到对应 ChangeItem。
- [ ] 审核端优先展示“驳回相关修改”,其次展示“额外修改”
状态:已完成
说明:整单“仅看修改”区已固定拆分为“驳回相关修改 / 额外修改”两个分组,并默认先展示驳回相关修改;同时补齐整单级 ChangeItem 与完整资料字段之间的双向跳转和跨模块切换。
P4申请端页面
- [ ] 驳回后进入“修改并重新提交”模式,顶部固定展示上次驳回问题清单
状态:已完成
说明:驳回后进入资料页会明确进入“修改并重新提交模式”;侧栏持续展示上次驳回问题与本次修改摘要,主内容顶部提供统一 sticky 复审头部,集中展示问题总数、未解决数、驳回相关修改、额外修改及待处理模块入口,形成稳定的整页复审模式。
- [ ] 每条问题增加“去修改”跳转,直达对应模块/字段
状态:已完成
说明:会议级待回应弹窗、模块级提交复审预览、资料页顶部/侧栏问题清单都已支持“去修改”;点击后可直接切到对应模块,并结合字段高亮、滚动定位、专家子模块切换与历史别名兼容,已覆盖基础信息、核销材料、专家简介、专家现场照片/劳务协议、会议发票及 `agenda / invitation / profileFile / meeting_invoice:OTHER / invoice:*` 等关键路径。
- [ ] 提交前展示标准化变更预览:驳回项相关修改 / 额外修改 / 未处理问题
状态:已完成
说明:模块提交前的“提交复审预览”已标准化拆分为“上次驳回项 / 未处理问题 / 驳回项相关修改 / 额外修改”四块,并新增逐条提交状态、处理说明缺失提示与表格高亮;未完成修改或未填写处理说明的问题会在预览中显式暴露,并继续阻断提交。
P5审计与治理
- [ ] 关键动作全链路留痕:谁提交了哪个版本、谁创建了问题、谁回应、谁确认解决
状态:已完成
说明:会议提交版本、结构化问题创建、申请人回应、审核确认解决、审核通过/驳回/退回/转审动作均已纳入统一留痕链路;审核详情现可直接返回版本链、问题闭环留痕、审核动作时间线与操作者信息,形成端到端审计追溯视图。
- [ ] 任意审核结论都能追溯到具体版本和具体差异
状态:已完成
说明:审核详情已固定绑定 `submission_version_id`,并优先返回该版本对应的持久化 `version_change_set / version_change_item`;对历史旧任务,读取详情时会按版本链自动补建缺失的 ChangeSet使审核结论可继续追溯到具体版本与具体差异而不再仅依赖当前草稿或人工推断。
- [ ] 补充迁移与回填方案,兼容当前 `meeting_material_history``audit_material_item_review`
状态:已完成
说明:已明确并落地兼容策略:新数据继续写入 `meeting_submission_version / version_change_set / version_change_item`;旧任务读取详情时,若缺少持久化差异,则基于 `meeting_material_history` 的提交快照与 `audit_material_item_review` 的历史审核项自动回填 ChangeSet并在追溯信息中标记“读取时自动回填/兼容旧任务”,避免历史记录断层。
最终方案
结论先定下来:把审核对象从“当前表单”改成“提交版本”,把驳回原因从“自由文本”改成“结构化问题”,把重新提交后的复审入口从“整单重看”改成“问题闭环 + 自动差异审核”。这样审核人打开后先看“这次改了什么、是否改到了上次驳回点”,而不是重新读全量资料。
一、整体架构
Application申请单主实体只承载申请编号、申请人、当前草稿、最新状态。
ApplicationVersion每次“提交审核”生成一份不可变版本审核永远针对 version_id不针对可变草稿。
AuditTask某个版本对应的一次审核任务。
AuditIssue审核不通过时产生的问题项必须结构化记录到模块/字段/明细行/附件。
IssueResponse申请人对每条问题项的处理说明。
VersionChangeSet重新提交时系统自动生成的版本差异汇总。
VersionChangeItem字段级/行级/附件级差异明细。
AttachmentAsset附件资源独立存储用 asset_id/hash 做稳定比对。
二、核心业务规则
所有审核都基于版本,版本一旦提交不可修改。
申请人编辑的是草稿,重新提交时生成新版本,不覆盖旧版本。
驳回时不能只写一句“请完善资料”,必须至少创建 1 条 AuditIssue。
AuditIssue 必须挂到具体位置,至少精确到模块,理想情况精确到字段或明细行。
重新提交时,申请人必须对每条未关闭问题填写处理说明。
审核人再次审核时,默认只看“本次修改”和“问题闭环”,完整资料作为第二层查看。
如果申请人除了驳回项外还改了别的内容,系统单独标红提示“额外修改”。
三、标准流程
申请人填写草稿,点击提交,系统生成 V1 快照并创建审核任务。
审核人审核 V1若不通过创建若干 AuditIssue例如
basic.meetingTime会议时间与通知不一致
budget.items[itemId=3].amount预算金额计算错误
attachments.agenda缺少议程附件
申请人查看驳回问题,修改草稿,并逐条填写“本次如何处理”。
申请人重新提交,系统生成 V2并自动计算 V1 -> V2 的 ChangeSet。
审核人打开 V2 时,页面顶部先展示:
本次共修改 8 处,涉及 3 个模块
上次驳回 5 条,已命中修改 4 条,未修改 1 条
另有 2 处非驳回项修改
审核人先处理“问题闭环”,确认每条问题是否已改到位,再决定通过或再次驳回。
若再次驳回,生成新的问题项并挂到 V2若通过关闭当前审核任务和所有未关闭问题项。
四、数据模型
application
id, code, applicant_id, current_draft_json, latest_version_id, status
application_version
id, application_id, version_no, snapshot_json, snapshot_hash, submit_note, submitted_by, submitted_at, base_version_id
audit_task
id, application_version_id, status, auditor_id, started_at, decided_at, decision_comment
audit_issue
id, audit_task_id, issue_no, module_code, target_path, target_label, target_row_key, reason, severity, status
issue_response
id, issue_id, application_version_id, response_text, response_status, responded_at
version_change_set
id, from_version_id, to_version_id, changed_module_count, changed_item_count, issue_related_count, extra_change_count
version_change_item
id, change_set_id, module_code, target_path, target_label, target_row_key, change_type, old_value, new_value, related_issue_id, is_extra_change
attachment_asset
id, file_name, file_hash, storage_key, mime_type, file_size
version_attachment_ref
id, version_id, target_path, asset_id
五、字段路径规范
普通字段basic.meetingName
嵌套对象schedule.startTime
明细行budget.items[itemId=3].amount
参会人participants[userId=1001].title
附件attachments.noticeFile
路径必须稳定,不能靠前端展示文案比对;明细表必须有稳定行主键,不能靠数组下标。
六、差异引擎规则
标量字段:直接比较前后值。
对象字段:递归比较。
明细数组:按稳定主键判断 新增/删除/修改。
附件:按 asset_id 或 file_hash 判断变化,不能只看文件名。
忽略字段:更新时间、操作人、系统流水号等非业务字段。
值展示规则:长文本截断展示,支持展开;金额保留格式化前后值;附件显示文件名和预览入口。
问题命中规则:
路径完全相同:直接命中
当前字段是问题字段的子路径或父路径:视为相关命中
同模块但不同字段:视为“模块内额外修改”
输出结果分三类:
已按驳回项修改
未命中驳回项
额外修改
七、审核端页面设计
审核列表页增加字段:
重新提交 标记
本次修改 x 处
驳回项已处理 y/z
额外修改 n 处
审核详情页固定四个区域:
本次摘要
驳回问题闭环
仅看修改
完整资料
本次摘要
显示版本链V1 驳回 -> V2 待审
显示修改统计和风险提示
驳回问题闭环
每条问题展示:问题内容、定位字段、上次值、本次值、申请人处理说明、审核结论
状态只有:已修改待确认、未修改、已确认解决
仅看修改
按模块分组展示所有 ChangeItem
默认先显示“驳回相关修改”,再显示“额外修改”
支持前后值并排对比
完整资料
仍展示整单
所有变更字段高亮
点击字段可跳回对应 ChangeItem
八、申请端页面设计
驳回后进入“修改并重新提交”模式。
页面顶部固定显示“上次驳回问题清单”。
每条问题旁边有“去修改”按钮,直接跳转到对应字段/模块。
每条问题必须填写“处理说明”,例如“已按会议通知修改时间为 2026-05-22 09:00”。
提交前显示“本次变更预览”,让申请人确认:
驳回项相关修改
额外修改
未处理问题
若存在未响应的问题,不允许提交。
九、接口设计
POST /applications
创建申请草稿
PUT /applications/{id}/draft
保存草稿
POST /applications/{id}/submit
生成新版本并提交审核
GET /audit-tasks/{taskId}
返回审核详情聚合数据:版本信息、问题项、问题回应、变更集、完整快照
POST /audit-tasks/{taskId}/reject
入参为结构化问题项数组和总评
POST /audit-tasks/{taskId}/approve
审核通过
GET /versions/{versionId}/diff?baseVersionId=xxx
获取版本差异
POST /issues/{issueId}/response
申请人填写问题处理说明
十、审核入参与出参约束
驳回入参必须包含:
target_path
target_label
module_code
reason
重新提交时必须包含:
base_version_id
submit_note
issue_responses[]
审核详情聚合接口一次返回前端所需全部信息,前端不要自己拼多接口。
十一、权限与审计
申请人只能编辑草稿,不能改已提交版本。
审核人只能审核分配给自己的 AuditTask。
所有关键动作留痕:
谁在何时提交了哪个版本
谁创建了哪条驳回问题
谁对问题做了什么回应
谁最终确认问题已解决
任意审核结论都能追溯到具体版本和具体差异。
十二、必须落地的两个强约束
审核针对版本,不针对当前表
驳回必须结构化,不允许只有自由文本
如果这两个不做,后面的“只看修改、不重看整单”就做不稳。
十三、最终效果
审核人再次打开时,先看到的是“改了什么”和“是否改到了上次问题”,不是整页资料。
申请人无法模糊处理驳回意见,必须逐条回应。
系统能明确区分“问题修复”和“额外修改”,减少复审成本和漏审风险。
整个流程天然可审计、可追溯、可统计。

View File

@ -0,0 +1,160 @@
# 通知事件触发时机备忘录
本文用于记录当前项目内通知事件码的实际触发时机,便于后续配置“通知策略中心”、排查通知链路和补齐模板。
## 1. 已实现事件
| 事件码 | 业务含义 | 触发时机 | 触发位置 | 业务对象 | 主要变量 | 默认接收人建议 | 备注 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `AUDIT_TASK_ASSIGNED` | 审核任务已分配 | 会议提交后创建首个审核任务时触发 | `backend/src/main/java/com/writeoff/module/meeting/service/MeetingService.java:477` | `MEETING` | `meetingId` `meetingTopic` `auditNode` `auditTaskId` `assigneeUserId` | `AUDITOR` | 仅在存在明确审核人时触发,实际派发封装在 `MeetingService.triggerAuditTaskAssignedNotification()` |
| `AUDIT_TASK_ASSIGNED` | 审核任务已分配 | 某一审核节点通过后,创建下一审核节点任务时触发 | `backend/src/main/java/com/writeoff/module/audit/service/AuditService.java:294` | `MEETING` | `meetingId` `meetingTopic` `auditNode` `auditTaskId` `assigneeUserId` | `AUDITOR` | 适用于初审通过进入复审、复审通过进入终审 |
| `AUDIT_TASK_ASSIGNED` | 审核任务已分配 | 复审或终审拒绝后,流程回到首节点并重新创建首节点任务时触发 | `backend/src/main/java/com/writeoff/module/audit/service/AuditService.java:336` | `MEETING` | `meetingId` `meetingTopic` `auditNode` `auditTaskId` `assigneeUserId` | `AUDITOR` | 这里是“重新发起首审任务”的提醒,不是给提交人的结果通知 |
| `AUDIT_APPROVED_FINAL` | 终审通过 | 审核通过且不存在下一审核节点时触发 | `backend/src/main/java/com/writeoff/module/audit/service/AuditService.java:288` | `MEETING` | `meetingId` `meetingTopic` `auditNode` `auditTaskId` `result` `opinion` | `SUBMITTER` | 当前用于“最终审核通过”通知提交人;`result` 固定解析为“通过” |
| `AUDIT_REJECTED` | 审核拒绝 | 初审节点执行拒绝,且本轮审核直接结束时触发 | `backend/src/main/java/com/writeoff/module/audit/service/AuditService.java:312` | `MEETING` | `meetingId` `meetingTopic` `auditNode` `auditTaskId` `result` `opinion` | `SUBMITTER` | 当前只在初审拒绝时触发;`result` 固定解析为“不通过” |
| `AUDIT_RETURNED` | 审核退回 | 审核人员执行“退回修改”动作时触发 | `backend/src/main/java/com/writeoff/module/audit/service/AuditService.java:350` | `MEETING` | `meetingId` `meetingTopic` `auditNode` `auditTaskId` `result` `opinion` | `SUBMITTER` | 与“拒绝”分开建码;`result` 固定解析为“退回” |
| `USER_CREATED` | 用户创建成功 | 新用户创建完成后自动触发 | `backend/src/main/java/com/writeoff/module/system/service/SystemUserService.java:778` | `USER` | `email` `validFrom` `validTo` `tenantCode` `tenantName` `loginPath` | `TARGET_USER` | 与会议审核无关,但已是现网实际触发事件 |
## 2. 兼容保留事件
| 事件码 | 当前状态 | 说明 | 备注 |
| --- | --- | --- | --- |
| `AUDIT_APPROVED` | 兼容保留,不再作为主触发事件 | 前端通知策略页和状态文案仍保留该选项,用于兼容历史策略或历史数据 | 后端审核主流程已改为触发 `AUDIT_APPROVED_FINAL``AuditService.resolveAuditResultText()` 仍兼容识别该旧码 |
## 3. 预留未接入事件
| 事件码 | 当前状态 | 说明 | 建议 |
| --- | --- | --- | --- |
| `FINANCE_CONFIRMED` | 前端已展示,后端未发现实际触发点 | 当前代码扫描只发现状态文案和通知策略下拉选项,未发现自动派发逻辑 | 后续确定“财务确认”的真实业务动作后,再补后端触发点和变量定义 |
## 4. 审核事件变量说明
### 4.1 审核任务分配类
`AUDIT_TASK_ASSIGNED` 当前由 `MeetingService.triggerAuditTaskAssignedNotification()` 统一派发,变量如下:
| 变量名 | 含义 |
| --- | --- |
| `meetingId` | 会议 ID |
| `meetingTopic` | 会议主题 |
| `auditNode` | 当前审核节点,如 `INIT_REVIEW``RE_REVIEW``FINAL_REVIEW` |
| `auditTaskId` | 审核任务 ID |
| `assigneeUserId` | 被分配审核人的用户 ID |
### 4.2 审核结果类
`AUDIT_APPROVED_FINAL``AUDIT_REJECTED``AUDIT_RETURNED` 当前由 `AuditService.triggerAuditNotification()` 派发,变量如下:
| 变量名 | 含义 |
| --- | --- |
| `meetingId` | 会议 ID |
| `meetingTopic` | 会议主题 |
| `auditNode` | 触发该动作的审核节点 |
| `auditTaskId` | 审核任务 ID |
| `result` | 结果文案,当前可能为“通过”“不通过”“退回” |
| `opinion` | 审核意见 |
## 5. 当前落地口径
1. “提醒审核人员”统一使用 `AUDIT_TASK_ASSIGNED`,由通知策略决定站内信、邮件等发送方式。
2. “审核结果提醒提交人”当前拆分为三个结果事件:`AUDIT_APPROVED_FINAL``AUDIT_REJECTED``AUDIT_RETURNED`
3. 复审通过本身不会直接通知提交人,因为流程仍未结束;它会触发下一节点的 `AUDIT_TASK_ASSIGNED`,通知下一位审核人。
4. 复审或终审拒绝时,当前实现是重置到首审并重新派发首审任务,因此会触发 `AUDIT_TASK_ASSIGNED`,但不会额外触发 `AUDIT_REJECTED`
5. 前端当前默认接收人规则已对齐:
- `AUDIT_TASK_ASSIGNED` -> `AUDITOR`
- `AUDIT_APPROVED_FINAL` -> `SUBMITTER`
- `AUDIT_REJECTED` -> `SUBMITTER`
- `AUDIT_RETURNED` -> `SUBMITTER`
- `USER_CREATED` -> `TARGET_USER`
## 6. 后续建议
1. 在“通知策略中心”中为上述事件分别配置默认模板,避免事件已触发但无可用策略。
2. 若业务上希望“复审拒绝/终审拒绝”同时通知提交人,需要补充一个新的结果事件,或调整现有拒绝分支逻辑。
3. 若后续启用 `FINANCE_CONFIRMED`,应先明确触发动作、接收人、变量字段,再补后端派发。
## 7. 建议通知文案模板
### 7.1 使用说明
1. 系统当前通知模板占位符语法为 `${变量名}`
2. 以下模板均只使用当前后端已实际传递的变量,可以直接用于现有通知模板配置。
3. `auditNode` 当前是英文枚举值,如 `INIT_REVIEW``RE_REVIEW``FINAL_REVIEW`。如果希望直接展示“初审 / 复审 / 终审”中文名称,建议后续补充 `auditNodeName` 变量。
4. 以下内容更适合站内信和邮件;如果后续要接短信,建议再单独准备精简版。
### 7.2 `AUDIT_TASK_ASSIGNED`
- 模板名称:会议审核任务提醒
- 适用场景:会议提交审核后、审核通过流转到下一节点后、复审/终审拒绝后重新回到初审时
- `subjectTemplate``会议审核任务待处理`
- `titleTemplate``会议《${meetingTopic}》待您审核`
- `contentTemplate`
```text
您有一条新的会议审核任务待处理。
会议主题:${meetingTopic}
会议ID${meetingId}
当前审核节点:${auditNode}
审核任务ID${auditTaskId}
请尽快登录系统完成审核处理。
```
### 7.3 `AUDIT_REJECTED`
- 模板名称:会议初审拒绝通知
- 适用场景:初审拒绝时触发
- `subjectTemplate``会议初审未通过通知`
- `titleTemplate``您提交的会议《${meetingTopic}》初审未通过`
- `contentTemplate`
```text
您提交的会议审核未通过。
会议主题:${meetingTopic}
会议ID${meetingId}
处理节点:${auditNode}
审核结果:${result}
审核意见:${opinion}
请根据审核意见修改后重新提交。
```
### 7.4 `AUDIT_RETURNED`
- 模板名称:会议退回修改通知
- 适用场景:退回修改时触发
- `subjectTemplate``会议已退回修改`
- `titleTemplate``您提交的会议《${meetingTopic}》已退回修改`
- `contentTemplate`
```text
您提交的会议已被退回修改。
会议主题:${meetingTopic}
会议ID${meetingId}
处理节点:${auditNode}
审核结果:${result}
审核意见:${opinion}
请根据审核意见完成修改后再次提交审核。
```
### 7.5 `AUDIT_APPROVED_FINAL`
- 模板名称:会议终审通过通知
- 适用场景:最后一个审核节点通过时触发
- `subjectTemplate``会议终审通过通知`
- `titleTemplate``您提交的会议《${meetingTopic}》已审核通过`
- `contentTemplate`
```text
您提交的会议已完成审核并通过。
会议主题:${meetingTopic}
会议ID${meetingId}
通过节点:${auditNode}
审核结果:${result}
审核意见:${opinion}
您可以继续后续业务处理。
```
### 7.6 模板命名建议
1. `AUDIT_TASK_ASSIGNED`:会议审核任务提醒
2. `AUDIT_REJECTED`:会议初审拒绝通知
3. `AUDIT_RETURNED`:会议退回修改通知
4. `AUDIT_APPROVED_FINAL`:会议终审通过通知

1
find_mojibake.js Normal file
View File

@ -0,0 +1 @@
const fs = require('fs'); ['backend/src/main/java/com/writeoff/module/meeting/service/MeetingMaterialService.java', 'frontend/src/views/modules/MeetingPage.vue'].forEach(f => { const lines = fs.readFileSync(f, 'utf8').split('\n'); lines.forEach((l, i) => { if (l.includes('\uFFFD')) console.log(f + ':' + (i+1) + ': ' + l.trim()); }); });

File diff suppressed because it is too large Load Diff

View File

@ -6,19 +6,27 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.7.7",
"compressorjs": "^1.3.0",
"element-plus": "^2.8.4",
"pinia": "^3.0.4",
"vue": "^3.5.10",
"vue-router": "^4.4.5"
"vue-router": "^4.4.5",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/node": "^25.9.1",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/test-utils": "^2.4.11",
"happy-dom": "^20.10.1",
"jsdom": "^27.0.1",
"typescript": "^5.6.2",
"vite": "^5.4.8"
"vite": "^5.4.8",
"vitest": "^4.1.8"
}
}

View File

@ -3,6 +3,7 @@ import { ElMessage } from "element-plus";
import { pinia } from "../stores";
import { useAuthStore } from "../stores/auth";
import { resolveLoginPath } from "../utils/authNavigation";
import { markRequestErrorNotified } from "../utils/requestError";
const http = axios.create({
baseURL: "/api",
@ -12,6 +13,7 @@ const http = axios.create({
const FORCE_LOGOUT_CODES = new Set([11001, 11003, 11004, 11005, 11006, 11007]);
let refreshPromise: Promise<string> | null = null;
let pendingForceLogout = false;
const getAuthStore = () => useAuthStore(pinia);
const isAuthSessionEndpoint = (url: string): boolean => {
@ -85,6 +87,7 @@ http.interceptors.response.use((resp) => resp.data, (error) => {
const businessCode = Number(error?.response?.data?.code || 0);
const isAuthRequest = isAuthSessionEndpoint(requestUrl);
const originalRequest = error?.config as any;
if (!isAuthRequest && businessCode === 11002 && !originalRequest?._retry) {
originalRequest._retry = true;
return ensureRefreshedToken()
@ -95,13 +98,12 @@ http.interceptors.response.use((resp) => resp.data, (error) => {
})
.catch((refreshError) => {
forceLogoutAndRedirect("会话已过期,请重新登录");
markRequestErrorNotified(refreshError);
return Promise.reject(refreshError);
});
}
if (
!isAuthRequest &&
FORCE_LOGOUT_CODES.has(businessCode)
) {
if (!isAuthRequest && FORCE_LOGOUT_CODES.has(businessCode)) {
const backendMessage = error?.response?.data?.message || error?.response?.data?.msg || error?.response?.data?.error;
const fallbackMessage = backendMessage || getForceLogoutMessage(businessCode);
if (!error?.response) {
@ -119,8 +121,10 @@ http.interceptors.response.use((resp) => resp.data, (error) => {
};
}
forceLogoutAndRedirect(fallbackMessage);
markRequestErrorNotified(error);
return Promise.reject(error);
}
const backendMessage = error?.response?.data?.message || error?.response?.data?.msg || error?.response?.data?.error;
let errorMessage = backendMessage;
if (!errorMessage) {
@ -134,13 +138,14 @@ http.interceptors.response.use((resp) => resp.data, (error) => {
errorMessage = "请求失败,请稍后重试";
}
}
// 从响应头或响应体中解析 requestId附加到错误提示
const requestId = error?.response?.headers?.["x-request-id"]
|| error?.response?.data?.requestId
|| "";
if (requestId) {
errorMessage = `${errorMessage}RequestId: ${requestId}`;
}
markRequestErrorNotified(error);
ElMessage.error(errorMessage);
return Promise.reject(error);
});

View File

@ -23,7 +23,7 @@ export const logoutAuth = () => http.post("/auth/logout");
export const logoutAllAuth = () => http.post("/auth/logout-all");
export const fetchGlobalSearch = (params: { q: string; limitPerType?: number }) => http.get("/search/global", { params });
export const fetchProjects = (params?: { parentOnly?: boolean; includeDeleted?: boolean }) => http.get("/projects", { params });
export const fetchProjects = (params?: { parentOnly?: boolean; includeDeleted?: boolean; keyword?: string; pageNo?: number; pageSize?: number }) => http.get("/projects", { params });
export const fetchProjectChildren = (id: number, params?: { includeDeleted?: boolean }) => http.get(`/projects/${id}/children`, { params });
export const createProject = (payload: {
name: string;
@ -40,6 +40,8 @@ export const createProject = (payload: {
paymentStatus?: string;
writeOffStatus?: string;
laborFeeRatio?: number;
cateringFeeRatio?: number;
laborAgreementSignType?: 1 | 2;
allowProjectOverBudget?: boolean;
invoiceInfo?: string;
expenseRatioJson?: string;
@ -62,6 +64,8 @@ export const updateProject = (
paymentStatus?: string;
writeOffStatus?: string;
laborFeeRatio?: number;
cateringFeeRatio?: number;
laborAgreementSignType?: 1 | 2;
allowProjectOverBudget?: boolean;
invoiceInfo?: string;
expenseRatioJson?: string;
@ -96,6 +100,8 @@ export const fetchMeetings = (params?: {
lastSubmitFrom?: string;
lastSubmitTo?: string;
includeDeleted?: boolean;
pageNo?: number;
pageSize?: number;
}) => http.get("/meetings", { params });
export const fetchMeetingPlatformExperts = (params?: { keyword?: string }) => http.get("/meetings/tenant-experts", { params });
export const createMeetingPlatformExpert = (payload: {
@ -127,6 +133,10 @@ export const submitMeetingLaborAgreementExtractTask = (
meetingId: number,
payload: { objectKey: string; fileName: string },
) => http.post(`/meetings/${meetingId}/labor-agreement-extract/task`, payload);
export const fetchMeetingLaborAgreementUploadSign = (
meetingId: number,
payload: { fileName: string; contentType?: string },
) => http.post(`/meetings/${meetingId}/labor-agreement-extract/upload-sign`, payload);
export const queryMeetingLaborAgreementExtract = (
meetingId: number,
payload: { taskId: string },
@ -146,6 +156,8 @@ export const bindMeetingExperts = (meetingId: number, payload: { expertIds: numb
http.post(`/meetings/${meetingId}/experts/bind`, payload);
export const unbindMeetingExpert = (meetingId: number, expertId: number) =>
http.delete(`/meetings/${meetingId}/experts/${expertId}`);
export const fetchDefaultMeetingBudgetCent = (projectId: number) =>
http.get(`/meetings/default-budget?projectId=${projectId}`);
export const createMeeting = (payload: {
projectId: number;
topic: string;
@ -175,8 +187,17 @@ export const updateMeeting = (
cateringRatio?: number;
},
) => http.put(`/meetings/${id}`, payload);
export const submitMeeting = (id: number, payload: { idempotencyKey: string; remark: string }) =>
export const submitMeeting = (id: number, payload: {
idempotencyKey: string;
remark: string;
issueResponses?: Array<{
issueId: number;
responseText: string;
}>;
}) =>
http.post(`/meetings/${id}/submit`, payload);
export const fetchMeetingPendingIssues = (id: number) =>
http.get(`/meetings/${id}/pending-issues`);
export const withdrawMeeting = (id: number, payload: { idempotencyKey: string; reason: string }) =>
http.post(`/meetings/${id}/withdraw`, payload);
export const deleteMeeting = (id: number) =>
@ -189,27 +210,39 @@ export const fetchMeetingMaterials = (meetingId: number) =>
http.get(`/meetings/${meetingId}/materials`);
export const fetchMeetingMaterialCurrent = (
meetingId: number,
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE",
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE",
) => http.get(`/meetings/${meetingId}/materials/${moduleCode}/current`);
export const saveMeetingMaterial = (
meetingId: number,
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE",
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE",
payload: { contentJson: string; remark?: string },
) => http.post(`/meetings/${meetingId}/materials/${moduleCode}/save`, payload);
export const submitMeetingMaterial = (
meetingId: number,
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE",
payload: { contentJson: string; remark?: string },
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE",
payload: {
contentJson: string;
remark?: string;
issueResponses?: Array<{
issueId: number;
responseText: string;
}>;
},
) => http.post(`/meetings/${meetingId}/materials/${moduleCode}/submit`, payload);
export const fetchMeetingMaterialUploadSign = (
meetingId: number,
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE",
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE",
payload: { fileName: string; contentType?: string },
) => http.post(`/meetings/${meetingId}/materials/${moduleCode}/upload-sign`, payload);
export const fetchMeetingMaterialHistory = (
meetingId: number,
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE",
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE",
) => http.get(`/meetings/${meetingId}/materials/${moduleCode}/history`);
export const fetchMeetingMaterialResubmitPreview = (
meetingId: number,
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE",
payload: { contentJson: string; remark?: string },
) => http.post(`/meetings/${meetingId}/materials/${moduleCode}/resubmit-preview`, payload);
export const fetchFilePresignDownload = (params: { objectKey: string }) =>
http.get("/files/presign-download", { params });
@ -269,6 +302,7 @@ export const fetchAuditTasks = (params?: boolean | {
meetingId?: number;
pageNo?: number;
pageSize?: number;
reviewFocus?: string;
sortBy?: string;
order?: "asc" | "desc";
}) => {
@ -282,12 +316,15 @@ export const fetchAuditTasks = (params?: boolean | {
meetingId: params?.meetingId,
pageNo: params?.pageNo,
pageSize: params?.pageSize,
reviewFocus: params?.reviewFocus,
sortBy: params?.sortBy,
order: params?.order,
},
});
};
export const exportAuditOpinions = () => http.get("/audits/export-opinions");
export const fetchAuditTaskDetail = (taskId: number) =>
http.get(`/audits/tasks/${taskId}`);
export const readAuditTaskMaterial = (
taskId: number,
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE" | "EXPERT_PROFILE",
@ -310,9 +347,20 @@ export const rejectAuditMaterialItem = (
reason: string;
},
) => http.post(`/audits/tasks/${taskId}/material/reject-item`, payload);
export const confirmAuditIssueResolved = (taskId: number, issueId: number) =>
http.post(`/audits/tasks/${taskId}/issues/${issueId}/resolve`);
export const approveAuditTask = (id: number, payload: { idempotencyKey: string; opinion: string }) =>
http.post(`/audits/tasks/${id}/approve`, payload);
export const rejectAuditTask = (id: number, payload: { idempotencyKey: string; opinion: string }) =>
export const rejectAuditTask = (id: number, payload: {
idempotencyKey: string;
opinion: string;
issues: Array<{
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE";
targetPath: string;
targetLabel: string;
reason: string;
}>;
}) =>
http.post(`/audits/tasks/${id}/reject`, payload);
export const returnAuditTask = (id: number, payload: { idempotencyKey: string; opinion: string }) =>
http.post(`/audits/tasks/${id}/return`, payload);
@ -327,6 +375,17 @@ export const batchApproveAuditTasks = (payload: { idempotencyKey: string; taskId
export const batchRejectAuditTasks = (payload: { idempotencyKey: string; taskIds: number[]; opinion: string }) =>
http.post("/audits/tasks/batch-reject", payload);
export const fetchAuditSlaStat = () => http.get("/audits/tasks/sla-stat");
export const fetchAuditReviewStat = (params?: {
mine?: boolean;
scope?: string;
reviewFocus?: string;
}) => http.get("/audits/tasks/review-stat", {
params: {
mine: !!params?.mine,
scope: params?.scope,
reviewFocus: params?.reviewFocus,
},
});
export const fetchAuditFlows = (params?: { pageNo?: number; pageSize?: number }) =>
http.get("/audit-flows", { params });
@ -542,6 +601,7 @@ export const enableDataPermission = (id: number) => http.post(`/data-permissions
export const disableDataPermission = (id: number) => http.post(`/data-permissions/${id}/disable`);
export const fetchDataPermissionRoles = (id: number) => http.get(`/data-permissions/${id}/roles`);
export const fetchCurrentDataScope = () => http.get("/data-permissions/current-scope");
export const fetchMatchedDataScope = (params: { account: string }) => http.get("/data-permissions/match", { params });
export const fetchAuditLogs = (params?: { userId?: number; actionCode?: string; pageNo?: number; pageSize?: number }) =>
http.get("/audit-logs", { params });
export const fetchPlatformAuditLogs = (params?: {
@ -656,12 +716,23 @@ export const fetchTemplates = (params?: {
status?: string;
scopeType?: string;
bizScene?: string;
watermarkEnabled?: boolean;
effectiveStatus?: string;
pageNo?: number;
pageSize?: number;
}) =>
http.get("/templates", { params });
export const fetchTemplateViewList = (params?: {
templateName?: string;
templateType?: string;
scopeType?: string;
bizScene?: string;
effectiveStatus?: string;
pageNo?: number;
pageSize?: number;
}) =>
http.get("/templates/view-list", { params });
export const fetchPublishedTemplateOptions = (params?: { bizScene?: string }) =>
http.get("/templates/published-options", { params });
export const fetchTemplateTypeOptions = () => http.get("/templates/type-options");
@ -682,9 +753,21 @@ export const createTemplate = (payload: {
changeLog?: string;
effectiveFrom?: string;
effectiveTo?: string;
watermarkEnabled?: boolean;
downloadRateLimitPerHour?: number;
}) => http.post("/templates", payload);
export const updateTemplate = (id: number, payload: {
templateName: string;
templateType: string;
scopeType: "ALL" | "PROJECT" | "MEETING";
projectId?: number;
meetingId?: number;
bizScene?: "MEETING_RECOMMEND" | "AUDIT_NOTIFY" | "SETTLEMENT";
effectiveFrom?: string;
effectiveTo?: string;
downloadRateLimitPerHour?: number;
}) => http.post(`/templates/${id}/update`, payload);
export const fetchTemplateUploadSign = (payload: {
fileName: string;
contentType?: string;
@ -704,8 +787,6 @@ export const archiveTemplate = (id: number) => http.post(`/templates/${id}/archi
export const rollbackTemplate = (id: number, payload: { versionNo: number; rollbackReason: string }) =>
http.post(`/templates/${id}/rollback`, payload);
export const downloadTemplate = (id: number) => http.get(`/templates/${id}/download`);
export const downloadTemplateWatermark = (id: number, params?: { watermarkText?: string }) =>
http.get(`/templates/${id}/download-watermark`, { params });
export const fetchTemplateVersionDiff = (id: number, params?: { leftVersionNo?: number; rightVersionNo?: number }) =>
http.get(`/templates/${id}/versions/diff`, { params });
export const fetchTemplateDownloadLogs = (params?: {
@ -714,7 +795,7 @@ export const fetchTemplateDownloadLogs = (params?: {
userId?: number;
userKeyword?: string;
versionNo?: number;
downloadType?: "NORMAL" | "WATERMARK";
ip?: string;
downloadedFrom?: string;
downloadedTo?: string;
@ -894,8 +975,8 @@ export const revokePlatformPrincipalSessions = (payload: {
scope: "TENANT" | "PLATFORM";
tenantId?: number;
}) => http.post("/platform/auth-sessions/revoke-principal", payload);
export const fetchPlatformNotifyGateways = () => http.get("/platform/notify-gateways");
export const savePlatformNotifyGateway = (
export const fetchNotifyGateways = () => http.get("/notify-gateways");
export const saveNotifyGateway = (
channelCode: string,
payload: {
gatewayName: string;
@ -904,15 +985,16 @@ export const savePlatformNotifyGateway = (
remark?: string;
config: Record<string, unknown>;
},
) => http.put(`/platform/notify-gateways/${channelCode}`, payload);
export const testPlatformNotifyGateway = (
) => http.put(`/notify-gateways/${channelCode}`, payload);
export const testNotifyGateway = (
channelCode: string,
payload: {
receiverRef: string;
subject?: string;
content?: string;
smsTemplateCode?: string;
},
) => http.post(`/platform/notify-gateways/${channelCode}/test`, payload);
) => http.post(`/notify-gateways/${channelCode}/test`, payload);
export const fetchNotificationPolicies = (params?: { pageNo?: number; pageSize?: number }) =>
http.get("/notification-policies", { params });
@ -943,6 +1025,7 @@ export const createNotificationPolicy = (payload: {
channel: string;
receiverType: string;
templateId: number;
smsTemplateCode?: string;
variablesJson?: string;
status?: "ENABLED" | "DISABLED";
}) => http.post("/notification-policies", payload);
@ -954,6 +1037,7 @@ export const updateNotificationPolicy = (
channel: string;
receiverType: string;
templateId: number;
smsTemplateCode?: string;
variablesJson?: string;
status?: "ENABLED" | "DISABLED";
},
@ -979,10 +1063,13 @@ export const ingestNotificationReceipt = (payload: {
receiptMessage?: string;
delivered?: boolean;
}) => http.post("/notifications/receipts", payload);
export const fetchInAppNotifications = (params?: { ts?: number }) => http.get("/in-app-notifications", { params });
export const fetchInAppNotifications = (params?: { ts?: number; pageNo?: number; pageSize?: number; onlyUnread?: boolean }) =>
http.get("/in-app-notifications", { params });
export const fetchInAppNotificationSummary = (params?: { ts?: number }) =>
http.get("/in-app-notifications/summary", { params });
export const markInAppNotificationRead = (id: number) => http.post(`/in-app-notifications/${id}/read`);
export const markAllInAppNotificationsRead = () => http.post("/in-app-notifications/read-all");
export const fetchExportTasks = () => http.get("/export-tasks");
export const fetchExportTasks = (params?: { pageNo?: number; pageSize?: number }) => http.get("/export-tasks", { params });
export const createExportTask = (payload: {
idempotencyKey: string;
taskCode: string;

Some files were not shown because too many files have changed in this diff Show More