This commit is contained in:
haomingming
2026-05-29 10:38:34 +08:00
parent edffa25ccd
commit db10401b13
111 changed files with 38710 additions and 1643 deletions
@@ -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,
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
@@ -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()
@@ -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
);
@@ -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;
}
}
File diff suppressed because it is too large Load Diff
@@ -117,7 +117,7 @@ public class ExpertService {
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("%')");
}
Integer total = jdbcTemplate.queryForObject(
@@ -109,11 +109,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 +129,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);
}
@@ -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;
@@ -79,7 +80,7 @@ public class MeetingController {
@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 +116,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));
@@ -175,6 +183,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 +255,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) {
@@ -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;
}
}
@@ -1,4 +1,4 @@
package com.writeoff.module.meeting.dto;
package com.writeoff.module.meeting.dto;
import javax.validation.constraints.NotBlank;
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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, " +
@@ -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);
@@ -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();
}
}
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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);
File diff suppressed because it is too large Load Diff
@@ -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,15 +206,29 @@ 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");
@@ -200,8 +244,8 @@ public class MeetingService {
request.getStartTime(),
request.getEndTime(),
defaultBudgetCent,
request.getLaborRatio() == null ? 0d : request.getLaborRatio(),
request.getCateringRatio() == null ? 0d : request.getCateringRatio(),
laborRatio,
cateringRatio,
MeetingStatus.NOT_STARTED,
MeetingAuditStatus.PENDING,
null,
@@ -231,7 +275,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 +284,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,6 +296,25 @@ public class MeetingService {
.count();
}
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, "会议餐费占比不能高于项目餐费占比");
}
}
private long calculateDefaultMeetingBudgetCent(Project project) {
long projectBudgetCent = Math.max(0L, project.getBudgetCent());
ProjectFeeSummary feeSummary = parseProjectFeeSummary(project.getProjectFeeJson());
@@ -321,9 +387,12 @@ 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);
Meeting updated = new Meeting(
existing.getId(),
existing.getProjectId(),
@@ -334,8 +403,8 @@ public class MeetingService {
request.getStartTime(),
request.getEndTime(),
request.getBudgetCent(),
request.getLaborRatio() == null ? 0d : request.getLaborRatio(),
request.getCateringRatio() == null ? 0d : request.getCateringRatio(),
laborRatio,
cateringRatio,
existing.getStatus(),
existing.getAuditStatus(),
existing.getCurrentAuditNode(),
@@ -397,6 +466,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 +475,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 +495,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 +547,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 +556,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 +652,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, "只有待审核的草稿会议才可删除");
}
@@ -592,6 +752,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();
@@ -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;
}
}
@@ -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;
}
}
@@ -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")
@@ -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));
@@ -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;
}
@@ -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;
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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();
}
@@ -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;
@@ -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)
@@ -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;
@@ -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>();
@@ -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",
@@ -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;
@@ -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();
}
@@ -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;
}
@@ -61,6 +61,8 @@ public class Project {
private int writeOffCompletedCount;
/** 劳务费用占比 */
private double laborFeeRatio;
private double cateringFeeRatio;
private int laborAgreementSignType = 1;
/** 是否允许超过项目总费用 */
private boolean allowProjectOverBudget;
/** 发票信息快照(便于一键复制) */
@@ -308,6 +310,14 @@ public class Project {
return laborFeeRatio;
}
public double getCateringFeeRatio() {
return cateringFeeRatio;
}
public int getLaborAgreementSignType() {
return laborAgreementSignType;
}
public boolean isAllowProjectOverBudget() {
return allowProjectOverBudget;
}
@@ -424,6 +434,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;
}
@@ -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;
@@ -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, " +
@@ -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 {
@@ -110,6 +113,10 @@ public class ProjectService {
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,6 +128,7 @@ 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);
@@ -157,7 +165,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 +179,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,6 +204,15 @@ 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(),
@@ -227,7 +246,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 +260,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 +355,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 +384,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 +476,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, "当前仓储模式不支持项目人员绑定");
@@ -590,6 +637,68 @@ 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,
@@ -627,6 +736,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 +757,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);
@@ -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));
}
}
@@ -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()) {
@@ -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);
@@ -547,7 +553,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, "管理员账号邮件发送失败");
}
@@ -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
);
@@ -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);
@@ -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 = '');
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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
);
@@ -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'
);
@@ -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 = '');
@@ -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 '餐费占比';
@@ -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());
}
}
@@ -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(