feat: work-report module V1 — four report types with review flow
客户 2026-07-18 语音需求落地:「报」分四种——优秀案例/操作视频/
完整SOP/有用知识,员工提交、主管或运营审核。
后端(新包 org.dromara.aihr.report):
- aihr_work_report 表(自建,utf8mb4_general_ci 显式声明,含提交人/
审核人/状态/附件 OSS 编号,提交人与状态双索引)
- POST /api/aihr/work-report/reports 员工提交(类型/标题/说明校验)
- POST /attachment 附件上传(≤100MB,OSS 落盘,ossId 字符串安全)
- GET /reports/mine 我的上报;GET /reports 审核队列(canModerate 门禁,
复用社区 Policy);POST /{id}/review APPROVE/REJECT
(仅 PENDING 可审,重复审核 400)
- 审核通过后的入库动作留给运营在案例库/知识库人工完成(不自动入库,
遵守'入知识库必须人工确认'原则)
员工端:
- 新页 pages/user/report(替换'建设中'占位):四类型卡片选择、
标题+说明(说明支持录音转写)、附件选择上传(视频类必传校验)、
我的上报列表(待审核/已通过/已驳回+审核意见)
- 「我」页常用功能入口文案更新
生产验证(端到端):
- API:员工提交→我的上报可见→员工查审核队列403→主管队列可见
→审核通过→重复审核400 全通过
- UI:我页入口→上报页→选类型→填表→提交→列表显示'待审核',
历史记录显示'已通过'+审核意见
- 后端发布(备份 ruoyi-admin.jar.bak-20260718123032,25s恢复),
H5 已发布(备份 h5-before-20260718123032)
二期待办:主管端待审列表页(现主管可用接口审核)、通过后一键转
案例库/知识库、上报采纳积分奖励(等客户定规则)。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+70
@@ -0,0 +1,70 @@
|
||||
package org.dromara.aihr.report;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.aihr.report.AihrWorkReportDto.AttachmentResponse;
|
||||
import org.dromara.aihr.report.AihrWorkReportDto.CreateReportRequest;
|
||||
import org.dromara.aihr.report.AihrWorkReportDto.ReportResponse;
|
||||
import org.dromara.aihr.report.AihrWorkReportDto.ReviewReportRequest;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.system.domain.vo.SysOssVo;
|
||||
import org.dromara.system.service.ISysOssService;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 工作上报:员工提交优秀案例/操作视频/完整SOP/有用知识,主管或运营审核。
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@SaCheckLogin
|
||||
@RequestMapping("/api/aihr/work-report")
|
||||
public class AihrWorkReportController {
|
||||
|
||||
private static final long MAX_ATTACHMENT_BYTES = 100L * 1024 * 1024;
|
||||
|
||||
private final AihrWorkReportService reportService;
|
||||
private final ISysOssService ossService;
|
||||
|
||||
@PostMapping("/reports")
|
||||
public R<ReportResponse> create(@RequestBody CreateReportRequest request) {
|
||||
return R.ok(reportService.create(request));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/attachment", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public R<AttachmentResponse> uploadAttachment(@RequestPart("file") MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
return R.fail("请选择附件文件");
|
||||
}
|
||||
if (file.getSize() > MAX_ATTACHMENT_BYTES) {
|
||||
return R.fail("附件不能超过 100MB");
|
||||
}
|
||||
SysOssVo oss = ossService.upload(file);
|
||||
return R.ok(new AttachmentResponse(oss.getOssId(), file.getOriginalFilename()));
|
||||
}
|
||||
|
||||
@GetMapping("/reports/mine")
|
||||
public R<List<ReportResponse>> mine() {
|
||||
return R.ok(reportService.myReports());
|
||||
}
|
||||
|
||||
@GetMapping("/reports")
|
||||
public R<List<ReportResponse>> list(@RequestParam(value = "status", required = false) String status) {
|
||||
return R.ok(reportService.pendingReports(status));
|
||||
}
|
||||
|
||||
@PostMapping("/reports/{id}/review")
|
||||
public R<ReportResponse> review(@PathVariable Long id, @RequestBody ReviewReportRequest request) {
|
||||
return R.ok(reportService.review(id, request));
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package org.dromara.aihr.report;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 工作上报 DTO:员工提交四类上报(案例/操作视频/SOP/知识),主管或运营审核。
|
||||
* 审核通过后由运营在对应库(案例库/知识库)完成最终入库,上报模块只管收集与审核。
|
||||
*/
|
||||
public final class AihrWorkReportDto {
|
||||
|
||||
private AihrWorkReportDto() {
|
||||
}
|
||||
|
||||
public record CreateReportRequest(String type,
|
||||
String title,
|
||||
String content,
|
||||
Long attachmentOssId,
|
||||
String attachmentName) {
|
||||
}
|
||||
|
||||
public record ReviewReportRequest(String decision, String note) {
|
||||
}
|
||||
|
||||
public record AttachmentResponse(Long ossId, String fileName) {
|
||||
}
|
||||
|
||||
public record ReportResponse(Long id,
|
||||
String type,
|
||||
String typeLabel,
|
||||
String title,
|
||||
String content,
|
||||
String attachmentName,
|
||||
Long attachmentOssId,
|
||||
String status,
|
||||
String statusLabel,
|
||||
String submitterName,
|
||||
String reviewNote,
|
||||
String createdAt,
|
||||
String reviewedAt) {
|
||||
}
|
||||
|
||||
public record ReportListResponse(List<ReportResponse> records) {
|
||||
}
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
package org.dromara.aihr.report;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.aihr.community.AihrCommunityPolicy;
|
||||
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
|
||||
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
|
||||
import org.dromara.aihr.report.AihrWorkReportDto.CreateReportRequest;
|
||||
import org.dromara.aihr.report.AihrWorkReportDto.ReportResponse;
|
||||
import org.dromara.aihr.report.AihrWorkReportDto.ReviewReportRequest;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 工作上报:员工提交 → 主管/运营审核(通过/驳回)。
|
||||
* 通过后的入库动作(案例库/知识库)由运营在对应模块人工完成,不自动入库。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AihrWorkReportService {
|
||||
|
||||
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("MM-dd HH:mm");
|
||||
private static final Map<String, String> TYPE_LABELS = Map.of(
|
||||
"CASE", "优秀案例",
|
||||
"VIDEO", "操作视频",
|
||||
"SOP", "完整SOP",
|
||||
"KNOWLEDGE", "有用知识"
|
||||
);
|
||||
private static final Map<String, String> STATUS_LABELS = Map.of(
|
||||
"PENDING", "待审核",
|
||||
"APPROVED", "已通过",
|
||||
"REJECTED", "已驳回"
|
||||
);
|
||||
private static final Set<String> REVIEW_DECISIONS = Set.of("APPROVE", "REJECT");
|
||||
private static final int MAX_TITLE = 100;
|
||||
private static final int MAX_CONTENT = 2000;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final AihrKnowledgePrincipalResolver principalResolver;
|
||||
|
||||
private volatile boolean tableReady;
|
||||
|
||||
public ReportResponse create(CreateReportRequest request) {
|
||||
ensureTable();
|
||||
AihrKnowledgePrincipal principal = principalResolver.current();
|
||||
String type = clean(request == null ? null : request.type()).toUpperCase();
|
||||
if (!TYPE_LABELS.containsKey(type)) {
|
||||
throw new ServiceException("上报类型无效,仅支持:优秀案例/操作视频/完整SOP/有用知识", 400);
|
||||
}
|
||||
String title = clean(request.title());
|
||||
if (title.isBlank()) {
|
||||
throw new ServiceException("请填写上报标题", 400);
|
||||
}
|
||||
String content = clean(request.content());
|
||||
if (content.isBlank()) {
|
||||
throw new ServiceException("请填写上报说明", 400);
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO aihr_work_report
|
||||
(tenant_id, report_type, title, content, attachment_oss_id, attachment_name,
|
||||
status, submitter_user_id, submitter_identity, submitter_name, create_time, update_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?, ?)
|
||||
""",
|
||||
principal.tenantId(),
|
||||
type,
|
||||
truncate(title, MAX_TITLE),
|
||||
truncate(content, MAX_CONTENT),
|
||||
request.attachmentOssId(),
|
||||
truncate(clean(request.attachmentName()), 200),
|
||||
principal.userId(),
|
||||
clean(principal.extPartyId()),
|
||||
displayName(principal),
|
||||
Timestamp.valueOf(now),
|
||||
Timestamp.valueOf(now)
|
||||
);
|
||||
Long id = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
|
||||
return byId(id, principal.tenantId());
|
||||
}
|
||||
|
||||
public List<ReportResponse> myReports() {
|
||||
ensureTable();
|
||||
AihrKnowledgePrincipal principal = principalResolver.current();
|
||||
return jdbcTemplate.query(SELECT_SQL + """
|
||||
WHERE tenant_id = ? AND submitter_user_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 50
|
||||
""", this::mapRow, principal.tenantId(), principal.userId());
|
||||
}
|
||||
|
||||
public List<ReportResponse> pendingReports(String status) {
|
||||
ensureTable();
|
||||
AihrKnowledgePrincipal principal = requireModerator();
|
||||
String filter = clean(status).toUpperCase();
|
||||
if (!filter.isBlank() && !STATUS_LABELS.containsKey(filter)) {
|
||||
filter = "";
|
||||
}
|
||||
if (filter.isBlank()) {
|
||||
return jdbcTemplate.query(SELECT_SQL + """
|
||||
WHERE tenant_id = ?
|
||||
ORDER BY CASE status WHEN 'PENDING' THEN 0 ELSE 1 END, id DESC
|
||||
LIMIT 100
|
||||
""", this::mapRow, principal.tenantId());
|
||||
}
|
||||
return jdbcTemplate.query(SELECT_SQL + """
|
||||
WHERE tenant_id = ? AND status = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 100
|
||||
""", this::mapRow, principal.tenantId(), filter);
|
||||
}
|
||||
|
||||
public ReportResponse review(Long id, ReviewReportRequest request) {
|
||||
ensureTable();
|
||||
AihrKnowledgePrincipal principal = requireModerator();
|
||||
String decision = clean(request == null ? null : request.decision()).toUpperCase();
|
||||
if (!REVIEW_DECISIONS.contains(decision)) {
|
||||
throw new ServiceException("审核结论无效,仅支持 APPROVE / REJECT", 400);
|
||||
}
|
||||
String nextStatus = "APPROVE".equals(decision) ? "APPROVED" : "REJECTED";
|
||||
int updated = jdbcTemplate.update("""
|
||||
UPDATE aihr_work_report
|
||||
SET status = ?, review_note = ?, reviewer_user_id = ?, reviewer_name = ?,
|
||||
review_time = ?, update_time = ?
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'PENDING'
|
||||
""",
|
||||
nextStatus,
|
||||
truncate(clean(request.note()), 500),
|
||||
principal.userId(),
|
||||
displayName(principal),
|
||||
Timestamp.valueOf(LocalDateTime.now()),
|
||||
Timestamp.valueOf(LocalDateTime.now()),
|
||||
principal.tenantId(),
|
||||
id
|
||||
);
|
||||
if (updated == 0) {
|
||||
throw new ServiceException("该上报不存在或已审核", 400);
|
||||
}
|
||||
return byId(id, principal.tenantId());
|
||||
}
|
||||
|
||||
private ReportResponse byId(Long id, String tenantId) {
|
||||
List<ReportResponse> rows = jdbcTemplate.query(SELECT_SQL + " WHERE tenant_id = ? AND id = ?",
|
||||
this::mapRow, tenantId, id);
|
||||
if (rows.isEmpty()) {
|
||||
throw new ServiceException("上报记录不存在", 404);
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private static final String SELECT_SQL = """
|
||||
SELECT id, report_type, title, content, attachment_oss_id, attachment_name,
|
||||
status, submitter_name, review_note, create_time, review_time
|
||||
FROM aihr_work_report
|
||||
""";
|
||||
|
||||
private ReportResponse mapRow(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
||||
String type = rs.getString("report_type");
|
||||
String status = rs.getString("status");
|
||||
return new ReportResponse(
|
||||
rs.getLong("id"),
|
||||
type,
|
||||
TYPE_LABELS.getOrDefault(type, type),
|
||||
rs.getString("title"),
|
||||
rs.getString("content"),
|
||||
rs.getString("attachment_name"),
|
||||
rs.getObject("attachment_oss_id", Long.class),
|
||||
status,
|
||||
STATUS_LABELS.getOrDefault(status, status),
|
||||
rs.getString("submitter_name"),
|
||||
rs.getString("review_note"),
|
||||
time(rs.getTimestamp("create_time")),
|
||||
time(rs.getTimestamp("review_time"))
|
||||
);
|
||||
}
|
||||
|
||||
private AihrKnowledgePrincipal requireModerator() {
|
||||
AihrKnowledgePrincipal principal = principalResolver.current();
|
||||
if (!AihrCommunityPolicy.canModerate(principal)) {
|
||||
throw new ServiceException("仅主管或管理员可执行该操作", 403);
|
||||
}
|
||||
return principal;
|
||||
}
|
||||
|
||||
private void ensureTable() {
|
||||
if (tableReady) {
|
||||
return;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (tableReady) {
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_work_report` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '000000' COMMENT '租户编号',
|
||||
`report_type` varchar(20) COLLATE utf8mb4_general_ci NOT NULL COMMENT '类型 CASE/VIDEO/SOP/KNOWLEDGE',
|
||||
`title` varchar(100) COLLATE utf8mb4_general_ci NOT NULL COMMENT '标题',
|
||||
`content` varchar(2000) COLLATE utf8mb4_general_ci NOT NULL COMMENT '说明',
|
||||
`attachment_oss_id` bigint DEFAULT NULL COMMENT '附件OSS编号',
|
||||
`attachment_name` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '附件名',
|
||||
`status` varchar(20) COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'PENDING' COMMENT '状态 PENDING/APPROVED/REJECTED',
|
||||
`submitter_user_id` bigint NOT NULL COMMENT '提交人用户ID',
|
||||
`submitter_identity` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '提交人身份',
|
||||
`submitter_name` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '提交人显示名',
|
||||
`reviewer_user_id` bigint DEFAULT NULL COMMENT '审核人用户ID',
|
||||
`reviewer_name` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '审核人显示名',
|
||||
`review_note` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '审核意见',
|
||||
`review_time` datetime DEFAULT NULL COMMENT '审核时间',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_aihr_work_report_submitter` (`tenant_id`, `submitter_user_id`, `id`),
|
||||
KEY `idx_aihr_work_report_status` (`tenant_id`, `status`, `id`)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '工作上报'
|
||||
""");
|
||||
tableReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static String displayName(AihrKnowledgePrincipal principal) {
|
||||
String identity = clean(principal.extPartyId());
|
||||
if (identity.isBlank()) {
|
||||
return "管理员" + principal.userId();
|
||||
}
|
||||
return identity.length() <= 4 ? identity : "员工" + identity.substring(identity.length() - 4);
|
||||
}
|
||||
|
||||
private static String clean(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static String truncate(String value, int max) {
|
||||
return value.length() <= max ? value : value.substring(0, max);
|
||||
}
|
||||
|
||||
private static String time(Timestamp value) {
|
||||
return value == null ? "" : value.toLocalDateTime().format(TIME_FORMAT);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user