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:
2026-07-18 12:38:14 +08:00
co-authored by Claude Fable 5
parent d5ea4b5451
commit 0a7eb38e05
7 changed files with 983 additions and 2 deletions
@@ -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));
}
}
@@ -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) {
}
}
@@ -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);
}
}