diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/controller/AuditController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/controller/AuditController.java index 632025f..b37f050 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/controller/AuditController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/controller/AuditController.java @@ -42,6 +42,9 @@ import java.util.List; public class AuditController { private final AuditService auditService; + private final cn.tianbaotong.fill.module.audit.service.AuditStatisticsService statisticsService; + private final cn.tianbaotong.fill.module.mp.service.TeacherProxyService proxyService; + private final cn.tianbaotong.fill.module.file.service.MaterialFileService fileService; @GetMapping("/todos") @Operation(summary = "待办列表", @@ -125,8 +128,9 @@ public class AuditController { public ApiResult fileUrl( @PathVariable Long id, @RequestParam(defaultValue = "false") boolean original, - @RequestParam(required = false) Long expireSeconds) { - return ApiResult.ok(auditService.fileUrl(id, original, expireSeconds)); + @RequestParam(required = false) Long expireSeconds, + @RequestParam(defaultValue = "false") boolean thumb) { + return ApiResult.ok(auditService.fileUrl(id, original, expireSeconds, thumb)); } @GetMapping("/files/{id}/content") @@ -142,4 +146,45 @@ public class AuditController { .header("Content-Disposition", "inline") .body(c.bytes()); } + + + // ══════════════════ 审核统计(§11.6)══════════════════ + + @GetMapping("/statistics") + @RequirePerm(PermissionDef.PROGRESS_VIEW_ALL) + @Operation(summary = "审核统计(§11.6)", + description = "`byReason` 是给管理员看「**哪种问题最多**」的,用来反过来优化材料引导语 —— " + + "如果 60% 的驳回都是「工作证明没盖章」,那就该在填报页把它写成大红字。" + + "所以 `reasonText` 必须可读,只给码(REASON-03)管理员看不懂,这个统计就白做了。" + + "⚠️ 待审/已通过/已驳回按填报单**当前状态**数(不按审核记录数):" + + "被驳回 3 次又通过的单子在「已通过」里算 1、「已驳回」里算 0," + + "这样三个数字加起来才等于人数 —— 加起来对不上,客户就不信整页数字了") + public ApiResult + statistics(@RequestParam Long taskId, + @RequestParam(required = false) Long orgId, + @RequestParam(required = false) String from, + @RequestParam(required = false) String to) { + return ApiResult.ok(statisticsService.statistics(taskId, orgId, from, to)); + } + + // ══════════════════ PC 端代传(§11.7)══════════════════ + + @PostMapping(value = "/submissions/{id}/proxy-upload", + consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) + @RequirePerm(PermissionDef.FILE_PROXY_UPLOAD) + @Operation(summary = "PC 端代传材料(§11.7,同 §10.6)", + description = "与班主任端同一个服务,但**范围判定不同**:" + + "PC 端是管理/审核角色,管理员的范围是**整个租户** —— " + + "直接复用 MP 那套(只管自己负责的班)会让管理员在 PC 上报 40301," + + "而界面上看起来像权限没配好") + public ApiResult proxyUpload( + @PathVariable Long id, + @RequestParam String materialCode, + @RequestParam("file") org.springframework.web.multipart.MultipartFile file, + @RequestParam(required = false) String proxyReason, + jakarta.servlet.http.HttpServletRequest req) throws java.io.IOException { + return ApiResult.ok(proxyService.proxyUploadAsAdmin(id, materialCode, + file.getOriginalFilename(), file.getBytes(), proxyReason, + cn.tianbaotong.fill.support.WebUtil.clientIp(req))); + } } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditStatMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditStatMapper.java new file mode 100644 index 0000000..a47806e --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditStatMapper.java @@ -0,0 +1,171 @@ +package cn.tianbaotong.fill.module.audit.mapper; + +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** + * 审核统计的聚合查询(《03-接口设计》§11.6)。 + * + *

三条口径

+ * + *
    + *
  • 待审 / 已通过 / 已驳回 都按**填报单当前状态**数,而不是按审核记录数: + * 一份单子被驳回 3 次又通过,它在"已通过"里算 1、"已驳回"里算 0 —— + * 按记录数会让数字加起来不等于人数,客户一眼就看出不对;
  • + *
  • 平均时长用审核记录里的 {@code duration_sec}(审核时算好写进去的), + * 按**审核人**分组时同样用它;
  • + *
  • 超时 = 提交时间已超过任务配置的节点时限**且仍未审完**。 + * 时限来自任务的 {@code audit_config}(初审 48h / 终审 72h 为默认), + * 所以这里要拿任务的 JSON 配置来算 —— 而不是写死 48。
  • + *
+ * + *

为什么把 SQL 放这里而不是在 Service 里拼

+ * + *

这些查询都要**按 orgId 过滤**(契约的 Query 有它),而 org 过滤的路径是 + * "填报单 → 名册 → 班级"。写成一段可读的 SQL 比在 Java 里拼字符串可靠得多。 + */ +public interface AuditStatMapper { + + /** + * 按状态数填报单。 + * + *

{@code orgId} 为空时表示整个任务。⚠️ 用 {@code + """) + List> countByStatus(@Param("taskId") Long taskId, + @Param("orgId") Long orgId, + @Param("from") String from, + @Param("to") String to); + + /** 平均审核时长(小时),按任务整体。 */ + @Select(""" + + """) + Double avgAuditHours(@Param("taskId") Long taskId, @Param("orgId") Long orgId, + @Param("from") String from, @Param("to") String to); + + /** 按审核人分组(谁审了多少、平均多久)。 */ + @Select(""" + + """) + List> byAuditor(@Param("taskId") Long taskId, @Param("orgId") Long orgId, + @Param("from") String from, @Param("to") String to); + + /** + * 按驳回原因分组。 + * + *

{@code byReason} 的用途在契约里写得很清楚:给管理员看"**哪种问题最多**", + * 用来反过来优化材料引导语 —— 也就是"如果 60% 的驳回都是『工作证明没盖章』, + * 那就该在填报页把它写成大红字"。所以这里必须给出可读的 {@code reasonText}, + * 只给码(REASON-03)管理员看不懂,这个统计就白做了。 + */ + @Select(""" + + """) + List> byReason(@Param("taskId") Long taskId, @Param("orgId") Long orgId, + @Param("from") String from, @Param("to") String to); + + /** + * 超时的填报单数:提交已超过节点时限、且仍未审完。 + * + *

用的是**当前节点**的时限:待初审(status=1)比初审时限, + * 待终审(status=2)比终审时限。⚠️ 不能只用初审时限 —— + * 一份已通过初审、卡在终审的单子会被误判成"没超时"。 + * + *

{@code classHours} / {@code adminHours} 由调用方从任务的 + * {@code audit_config} 里读出来传进来(那里有客户改过的值,默认才是 48/72)。 + */ + @Select(""" + + """) + long timeoutCount(@Param("taskId") Long taskId, @Param("orgId") Long orgId, + @Param("classHours") int classHours, @Param("adminHours") int adminHours); + + /** + * 某个任务待我审的单数 —— 给审核台的"待办角标"用。 + * + *

⚠️ 这里**不关**租户拦截器:查询按 {@code task_id} 过滤, + * 而 taskId 一定属于某个租户,拦截器补上的 `tenant_id` 条件正好是我们要的。 + * **只有确实需要跨租户的查询才关它**(见 `MpSceneMapper` 与微信回调)—— + * 顺手关掉是这类隔离最常见的失效方式。 + */ + @Select(""" + SELECT COUNT(*) FROM collect_submission + WHERE task_id = #{taskId} AND deleted = 0 AND status IN (1, 2) + """) + long pendingOfTask(@Param("taskId") Long taskId); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditTodoMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditTodoMapper.java index 3b4a0a7..db93959 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditTodoMapper.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditTodoMapper.java @@ -81,6 +81,32 @@ public interface AuditTodoMapper { AND s.org_id = #{orgId} + + + AND EXISTS ( + SELECT 1 FROM collect_task_org cto + LEFT JOIN sys_org so + ON so.id = cto.org_id AND so.deleted = 0 + AND so.tenant_id = cto.tenant_id + WHERE cto.tenant_id = #{tenantId} + AND cto.task_id = s.task_id + AND cto.org_id = s.org_id + AND cto.status = 1 + AND ( + cto.head_user_id = #{auditorId} + OR cto.deputy_user_ids LIKE ('%,' || #{auditorId} || ',%') + OR (cto.head_user_id IS NULL AND so.leader_user_id = #{auditorId}) + ) + ) + AND s.org_id IN @@ -114,6 +140,7 @@ public interface AuditTodoMapper { @Param("taskId") Long taskId, @Param("orgId") Long orgId, @Param("orgIds") List orgIds, + @Param("auditorId") Long auditorId, @Param("level") Integer level, @Param("keyword") String keyword, @Param("waitingOverHours") Integer waitingOverHours); @@ -145,6 +172,32 @@ public interface AuditTodoMapper { AND s.org_id = #{orgId} + + + AND EXISTS ( + SELECT 1 FROM collect_task_org cto + LEFT JOIN sys_org so + ON so.id = cto.org_id AND so.deleted = 0 + AND so.tenant_id = cto.tenant_id + WHERE cto.tenant_id = #{tenantId} + AND cto.task_id = s.task_id + AND cto.org_id = s.org_id + AND cto.status = 1 + AND ( + cto.head_user_id = #{auditorId} + OR cto.deputy_user_ids LIKE ('%,' || #{auditorId} || ',%') + OR (cto.head_user_id IS NULL AND so.leader_user_id = #{auditorId}) + ) + ) + AND s.org_id IN @@ -164,6 +217,7 @@ public interface AuditTodoMapper { @Param("taskId") Long taskId, @Param("orgId") Long orgId, @Param("orgIds") List orgIds, + @Param("auditorId") Long auditorId, @Param("keyword") String keyword, @Param("timeoutHours") Integer timeoutHours); } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/AuditService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/AuditService.java index 5e0667d..73709c8 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/AuditService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/AuditService.java @@ -7,6 +7,8 @@ import cn.tianbaotong.fill.common.context.UserContext; import cn.tianbaotong.fill.common.exception.BizException; import cn.tianbaotong.fill.module.audit.dto.AuditDtos; import cn.tianbaotong.fill.module.audit.dto.AuditTodoRow; +import cn.tianbaotong.fill.module.notify.entity.CollectMessage; +import cn.tianbaotong.fill.module.notify.service.NotificationService; import cn.tianbaotong.fill.module.audit.entity.CollectAuditRecord; import cn.tianbaotong.fill.module.audit.mapper.AuditPreconditionMapper; import cn.tianbaotong.fill.module.audit.mapper.AuditRecordMapper; @@ -142,6 +144,8 @@ public class AuditService { private final FormSchemaService formSchemaService; private final SubmissionValidator validator; private final PermissionChecker permissionChecker; + private final NotificationService notificationService; + private final cn.tianbaotong.fill.module.open.service.OpenPushService openPushService; // ══════════════════ 待办列表 ══════════════════ @@ -167,14 +171,19 @@ public class AuditService { long pn = pageNum == null || pageNum < 1 ? 1 : pageNum; long ps = pageSize == null || pageSize < 1 ? DEFAULT_PAGE_SIZE : Math.min(pageSize, MAX_PAGE_SIZE); + // ⚠️ 初审还要传 auditorId:让 SQL 按(任务, 班级)配对判断范围 —— + // 只传 orgScope(班级 id 列表)会列出"我在别的任务里负责、但这个任务不归我"的单子, + // 而用户点通过只会被 40301 拒(看得到但批不了) + Long auditorId = node == NODE_CLASS ? me.uid() : null; IPage page = todoMapper.pageTodos(new Page<>(pn, ps), - me.tenantId(), status, taskId, orgId, orgScope, level, kw, waitingOverHours); + me.tenantId(), status, taskId, orgId, orgScope, auditorId, level, kw, + waitingOverHours); AuditDtos.TodoPageVO out = new AuditDtos.TodoPageVO(); out.setList(assembleTodos(page.getRecords(), me.tenantId())); out.setTotal(page.getTotal()); out.setCounts(countsOf(todoMapper.counts(me.tenantId(), status, taskId, orgId, orgScope, - kw, timeoutHours(node, taskId)))); + auditorId, kw, timeoutHours(node, taskId)))); return out; } @@ -696,11 +705,100 @@ public class AuditService { int newStatus = node == NODE_CLASS ? CollectSubmission.STATUS_PENDING_ADMIN : CollectSubmission.STATUS_APPROVED; + // 通知学生(站内信一定能看到;微信推送看有没有额度)—— + // 失败绝不影响审核结果(NotificationService 内部不抛) + notifyApproved(sub, newStatus); + // 终审通过 → 推送给认定机构(契约 §14.4)。 + // ⚠️ **必须等事务提交之后**:推送是外部 HTTP 调用, + // 放在事务里会让审核被对方的响应时间绑架;更糟的是, + // 如果事务之后回滚了,机构已经收到"这个人通过了"而我们的库里没通过 —— + // 这种不一致没法撤回(对方的系统已经动了)。 + if (newStatus == CollectSubmission.STATUS_APPROVED) { + Long tid = sub.getTaskId(); + Long sid = submissionId; + registerAfterCommit(() -> pushToOpenApp(tid, sid)); + } log.info("审核通过:submission={} node={} → status={} 审计人={}", submissionId, node, newStatus, me.uid()); return approveResult(newStatus, false); } + /** + * 把动作挂到"当前事务提交之后"执行;**没有事务时立刻执行**。 + * + *

没有事务也要执行:审批可能被别处以非事务方式调用(例如脚本、内部重试), + * 那时"等提交"这件事根本不存在 —— 漏掉推送比多推一次严重得多 + * (推送记录上有唯一索引,重复触发只会更新同一条)。 + */ + private void registerAfterCommit(Runnable action) { + if (!org.springframework.transaction.support.TransactionSynchronizationManager + .isSynchronizationActive()) { + action.run(); + return; + } + org.springframework.transaction.support.TransactionSynchronizationManager + .registerSynchronization( + new org.springframework.transaction.support.TransactionSynchronization() { + @Override + public void afterCommit() { + action.run(); + } + }); + } + + /** + * 通过之后通知学生。 + * + *

初审通过**也发**:学生提交后最想知道的就是"我的东西进下一步了吗"。 + * 只在终审通过时发的话,中间那段等待期他会反复进来看。 + * + *

文案按节点区分 —— "等待管理员终审"和"已通过"是两件不同的事, + * 用同一句话会让他以为已经结束了。 + */ + private void notifyApproved(CollectSubmission sub, int newStatus) { + if (sub.getSubmitterId() == null) { + return; + } + boolean finalPass = newStatus == CollectSubmission.STATUS_APPROVED; + String title = finalPass ? "你的材料已通过审核" : "初审已过,等待终审"; + String content = finalPass + ? "本次认定的材料已收妥,无需再操作。" + : "班主任已通过你的材料,接下来由管理员终审。"; + safeNotify(NotificationService.NotifyRequest.of( + sub.getSubmitterId(), + finalPass ? CollectMessage.BIZ_APPROVE : CollectMessage.BIZ_TASK_PUBLISH, + title, content, + CollectMessage.LINK_SUBMISSION, sub.getId(), + finalPass ? NotificationService.TPL_AUDIT_RESULT : NotificationService.TPL_SUBMIT_SUCCESS)); + } + + /** + * 终审通过后推送给认定机构(契约 §14.4)。 + * + *

⚠️ 由调用方在**事务提交之后**调(见 {@code approve} 里的注释): + * 推送是外部 HTTP 调用,可能慢到十几秒,放进事务里会让 + * "终审通过"这个动作被对方服务的响应时间绑架 —— 对方挂了,我们的审核就卡住。 + * + *

失败也只影响推送记录(状态与下次重试时间落库,由定时任务接着试), + * 不影响"审核已通过"这个事实。 + */ + private void pushToOpenApp(Long taskId, Long submissionId) { + try { + openPushService.onSubmissionApproved(UserContext.tenantId(), taskId, submissionId); + } catch (RuntimeException e) { + log.warn("推送认定机构失败(不影响审核结果)submissionId={}", submissionId, e); + } + } + + /** 通知的统一入口:**绝不让通知失败影响业务事务**。 */ + private void safeNotify(NotificationService.NotifyRequest req) { + try { + notificationService.notify(req); + } catch (RuntimeException e) { + log.warn("发通知失败(不影响业务结果):user={} biz={}", req.userId(), req.bizType(), e); + } + } + /** * 幂等回放:如果"我上一次的通过"仍然有效,直接返回当前状态。 * @@ -840,21 +938,43 @@ public class AuditService { req.getMaterialCodes()); markRoster(sub.getRosterId(), CollectRoster.SUBMIT_REJECTED); + // ★ 通知学生(§13.5 消息中心 + §13.6 订阅消息降级链)。 + // 在此之前这里只能回 notifySent=false + needManualNotice=true —— + // 因为"通知渠道还没做",老师得自己跟学生说一声。 + // 现在站内信一定写得进去,所以 pushSent 才是"微信推送到没到", + // 而"学生能不能知道"这件事有了确定答案。 + boolean pushSent = false; + if (sub.getSubmitterId() != null) { + try { + var r = notificationService.notify(NotificationService.NotifyRequest.of( + sub.getSubmitterId(), CollectMessage.BIZ_REJECT, + "你的材料被退回了", + fullText, + CollectMessage.LINK_SUBMISSION, sub.getId(), + NotificationService.TPL_AUDIT_RESULT)); + pushSent = r.pushSent(); + } catch (RuntimeException e) { + log.warn("驳回通知失败(不影响驳回结果):submission={}", submissionId, e); + } + } + if (escalated) { - // ⚠️ 通知(订阅消息 / 站内信,M5-12)还没做,所以这里只有日志。 - // 响应里如实地把 notifySent=false + needManualNotice=true 告诉前端, - // 让班主任知道"得自己跟学生说一声"——比假装通知过了强得多。 + // 驳回次数达上限:这条留给管理员,所以**站内信也发给管理员**。 log.warn("驳回次数已达上限,需要管理员介入:submission={} 学生={} 第 {} 次驳回 原因={}", submissionId, sub.getRosterId(), rejectCount, code); } - log.info("审核驳回:submission={} node={} 第 {} 次 原因={}", submissionId, node, rejectCount, code); + log.info("审核驳回:submission={} node={} 第 {} 次 原因={} 微信推送={}", + submissionId, node, rejectCount, code, pushSent); AuditDtos.RejectVO vo = new AuditDtos.RejectVO(); vo.setStatus(CollectSubmission.STATUS_REJECTED); vo.setStatusText(SubmitDtos.Report.statusText(CollectSubmission.STATUS_REJECTED)); vo.setRejectCount(rejectCount); - vo.setNotifySent(false); - vo.setNeedManualNotice(true); + // ⚠️ 语义收窄了:以前 notifySent 表示"有没有通知渠道"(恒 false), + // 现在表示"**微信推送**有没有发出去"。站内信一定发了,所以 + // needManualNotice 仍然为 true 时要老师口头说一声 —— 学生不一定会进小程序。 + vo.setNotifySent(pushSent); + vo.setNeedManualNotice(!pushSent); vo.setEscalated(escalated); return vo; } @@ -1055,8 +1175,22 @@ public class AuditService { /** 审核端的预览链接(七牛私有空间 → 签名 URL,有有效期)。 */ @Transactional(readOnly = true) public MaterialFileService.FileUrl fileUrl(Long fileId, boolean original, Long expireSeconds) { + return fileUrl(fileId, original, expireSeconds, false); + } + + /** + * 取链接(可要缩略图)。 + * + * @param thumb 审核**列表**传 true:一屏几百张,缩略图把 140MB 降到约 2MB; + * 审核**详情页**传 false(要看防伪纹、要看有没有裁掉边角)。 + * ⚠️ 默认 false 是刻意的 —— 反过来默认缩略图的话,详情页会"看起来糊", + * 而那种问题很难被归因到"某个默认值"上。 + */ + public MaterialFileService.FileUrl fileUrl(Long fileId, boolean original, Long expireSeconds, + boolean thumb) { CollectFile f = requireFileReadable(fileId); - return fileService.urlOf(f, original, expireSeconds, AUDIT_DOWNLOAD_PREFIX); + return fileService.urlOf(f, original, expireSeconds, AUDIT_DOWNLOAD_PREFIX, + thumb ? MaterialFileService.THUMB_LONG_SIDE : 0); } /** 审核端的内容流(本地存储时的预览通道)。 */ diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/AuditStatisticsService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/AuditStatisticsService.java new file mode 100644 index 0000000..e958fad --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/AuditStatisticsService.java @@ -0,0 +1,208 @@ +package cn.tianbaotong.fill.module.audit.service; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.exception.BizException; +import cn.tianbaotong.fill.module.audit.mapper.AuditStatMapper; +import cn.tianbaotong.fill.module.task.entity.CollectTask; +import cn.tianbaotong.fill.module.task.mapper.TaskMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 审核统计(《03-接口设计》§11.6)。 + * + *

这个界面的用途决定了三个数字的口径

+ * + *

它不是"看看热闹"的仪表盘,而是**管理动作的输入**: + *

    + *
  • {@code pendingCount} + {@code timeoutCount} → "要不要催审核人/加人";
  • + *
  • {@code byAuditor} → "谁手上积压了"(handled 少而 avgHours 高的那个);
  • + *
  • {@code byReason} → "**哪种问题最多**",用来反过来优化材料引导语 + * (契约里的原话)。如果 60% 的驳回都是"工作证明没盖章", + * 那就该在填报页把它写成大红字 —— 所以 {@code reasonText} 必须可读, + * 只给码(REASON-03)管理员看不懂,这个统计就白做了。
  • + *
+ * + *

为什么"待审/已通过/已驳回"按填报单**当前状态**数

+ * + *

不按审核记录数:一份单子被驳回 3 次又通过,它在"已通过"里算 1、 + * "已驳回"里算 0。按记录数会让三个数字加起来**不等于人数**, + * 而客户第一眼就会去加它们(加起来对不上,他就不信整页数字了)。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AuditStatisticsService { + + private final AuditStatMapper statMapper; + private final TaskMapper taskMapper; + + /** PRD 默认时限(与 `AuditService` 同一口径,读不到任务配置时用它)。 */ + private static final int DEFAULT_CLASS_TIMEOUT_HOURS = 48; + private static final int DEFAULT_ADMIN_TIMEOUT_HOURS = 72; + + public StatVO statistics(Long taskId, Long orgId, String from, String to) { + if (taskId == null) { + // ⚠️ 契约把 taskId 列为 Query 参数但没写必填;而"全平台审核统计"是另一个 + // 功能(要跨任务聚合,还要考虑每个任务自己的时限),一期不做。 + // 明确报错比返回一个"看起来是全平台、其实是随机某个任务"的数字安全得多 + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "请指定 taskId(一期只做单任务审核统计)"); + } + CollectTask task = taskMapper.selectById(taskId); + if (task == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除"); + } + + Map byStatus = new LinkedHashMap<>(); + for (Map row : statMapper.countByStatus(taskId, orgId, from, to)) { + Integer st = intOf(row, "st"); + Integer cnt = intOf(row, "cnt"); + if (st != null && cnt != null) { + byStatus.merge(st, cnt, Integer::sum); + } + } + + int[] hours = timeoutHours(task); + StatVO vo = new StatVO(); + vo.setPendingCount(byStatus.getOrDefault(1, 0) + byStatus.getOrDefault(2, 0)); + vo.setApprovedCount(byStatus.getOrDefault(3, 0)); + vo.setRejectedCount(byStatus.getOrDefault(4, 0)); + // ⚠️ 四舍五入到 2 位:直接透出 PostgreSQL 的双精度结果会变成 + // `0.00038888888888888887` 这种 17 位小数 —— 那不是"精确",是"没处理", + // 既不可读又让人怀疑算错了。而且这个字段**开放接口也看得到**(§14)。 + // null 保持 null:「没有样本」与「0 小时」是两件事。 + vo.setAvgAuditHours(round2(statMapper.avgAuditHours(taskId, orgId, from, to))); + vo.setTimeoutCount(statMapper.timeoutCount(taskId, orgId, hours[0], hours[1])); + vo.setClassTimeoutHours(hours[0]); + vo.setAdminTimeoutHours(hours[1]); + + for (Map r : statMapper.byAuditor(taskId, orgId, from, to)) { + AuditorRow a = new AuditorRow(); + a.setAuditorName(strOf(r, "auditorName")); + a.setHandled(nz(intOf(r, "handled"))); + // 同上:byAuditor 的 avgHours 也是双精度结果,要求同样的口径 + a.setAvgHours(round2(doubleOf(r, "avgHours"))); + vo.getByAuditor().add(a); + } + for (Map r : statMapper.byReason(taskId, orgId, from, to)) { + ReasonRow a = new ReasonRow(); + a.setReasonCode(strOf(r, "reasonCode")); + a.setReasonText(strOf(r, "reasonText")); + a.setCount(nz(intOf(r, "cnt"))); + vo.getByReason().add(a); + } + log.debug("审核统计 taskId={} orgId={} 待审 {} 超时 {}", + taskId, orgId, vo.getPendingCount(), vo.getTimeoutCount()); + return vo; + } + + /** 时限从任务配置读(客户可能改过),读不到才用 PRD 默认值。 */ + private int[] timeoutHours(CollectTask task) { + int classH = DEFAULT_CLASS_TIMEOUT_HOURS; + int adminH = DEFAULT_ADMIN_TIMEOUT_HOURS; + String json = task.getAuditConfig(); + if (json != null && !json.isBlank()) { + try { + JSONObject o = JSONUtil.parseObj(json); + Integer c = o.getInt("classAuditTimeoutHours"); + Integer a = o.getInt("adminAuditTimeoutHours"); + if (c != null && c > 0) { + classH = c; + } + if (a != null && a > 0) { + adminH = a; + } + } catch (RuntimeException e) { + // 任务配置坏了不该让统计整个失败 —— 用默认值并说清, + // 因为"超时数"这个数字的价值在于趋势,而不在于精确到小时 + log.warn("任务 {} 的 audit_config 解析失败,超时统计改用默认时限", + task.getId(), e); + } + } + return new int[]{classH, adminH}; + } + + private static Object col(Map row, String name) { + for (Map.Entry e : row.entrySet()) { + if (e.getKey().equalsIgnoreCase(name)) { + return e.getValue(); + } + } + return null; + } + + private static Integer intOf(Map row, String name) { + Object v = col(row, name); + return v == null ? null : ((Number) v).intValue(); + } + + /** + * 四舍五入到 2 位小数(`null` 原样返回)。 + * + *

用 {@code Math.round(v * 100) / 100.0} 而不是 {@code BigDecimal}: + * 这里要的是"给显示用的短数字",不是金额那种必须精确的舍入。 + */ + private static Double round2(Double v) { + return v == null ? null : Math.round(v * 100) / 100.0; + } + + private static Double doubleOf(Map row, String name) { + Object v = col(row, name); + if (v == null) { + return null; + } + return ((Number) v).doubleValue(); + } + + private static String strOf(Map row, String name) { + Object v = col(row, name); + return v == null ? null : String.valueOf(v); + } + + private static int nz(Integer v) { + return v == null ? 0 : v; + } + + // ── 响应结构(契约 §11.6 的字段逐个对应)── + + @lombok.Getter + @lombok.Setter + public static class StatVO { + private int pendingCount; + private int approvedCount; + private int rejectedCount; + /** 平均处理时长(小时);没有审核记录时为 null("还没人审" ≠ "审得飞快")。 */ + private Double avgAuditHours; + private long timeoutCount; + private List byAuditor = new ArrayList<>(); + private List byReason = new ArrayList<>(); + /** 本次统计用的时限(来自任务配置),让界面能说明"超过多少小时算超时"。 */ + private int classTimeoutHours; + private int adminTimeoutHours; + } + + @lombok.Getter + @lombok.Setter + public static class AuditorRow { + private String auditorName; + private int handled; + private Double avgHours; + } + + @lombok.Getter + @lombok.Setter + public static class ReasonRow { + private String reasonCode; + private String reasonText; + private int count; + } +} diff --git a/frontend-pc/src/apis/xt/audit.ts b/frontend-pc/src/apis/xt/audit.ts new file mode 100644 index 0000000..c26094a --- /dev/null +++ b/frontend-pc/src/apis/xt/audit.ts @@ -0,0 +1,427 @@ +/** + * 审核接口(《03-接口设计》§11)。 + * + * ## 三件界面上必须守住的规则(都由服务端强制,前端只做呈现) + * + * 1. **权限 ≠ 范围**。全校几十个班主任都有 `audit:first`,但只有**这个班的**班主任 + * 能审这个学生。待办列表由服务端按范围过滤(看不见的单子不会出现), + * 所以界面上**不要**自己写"按 orgId 过滤"的逻辑 —— 前端过滤不了越权,只会掩盖问题。 + * + * 2. **红问题不许通过(40032)**,而且服务端的校验是**现场重算**的: + * 提交时落库的 `redCount` 只是快照(OCR 是异步的、名册和材料规则都可能被改)。 + * 所以详情页每打开一次就重新拉一次,**不要缓存计数**。 + * + * 3. **批量通过只带绿色项**(`onlyClean` 服务端强制 true)。 + * 被跳过的每一条都会在 `details` 里说明原因 —— 必须逐条展示, + * 否则用户看着"选了 5 个过了 3 个"只会反复点那个按钮。 + * + * ## 状态与分级(不要按直觉写分支) + * + * - 填报单状态:`1` 草稿 / `2` 待初审 / `3` 待终审(初审已过)/ `4` 已驳回 / `5` 已通过 + * - 卡片分级 `level`:**只有 0 / 2 / 3**,没有 1(写了"信息级"的分支永远不会触发) + * + * ⚠️ 上面这两个数字口径**以服务端返回的 `statusText` 为准**:界面优先显示 `statusText`, + * 数字只用于选色。这样即便枚举有调整,界面也不会说错话。 + */ +import http from '@/utils/http' + +/** 带业务码的错误(`@/utils/http` 的拦截器会把 `code` 挂到 error 上)。 */ +export type BizError = Error & { bizCode?: number, bizData?: unknown } + +/** 提取业务码:调用方用它做分支(如 40032 红问题、40033 已导出不可撤)。 */ +export function bizCodeOf(e: unknown): number | undefined { + return (e as BizError)?.bizCode +} + +// ── 待办列表(§11.1 / §10.2)──────────────────── + +export interface AuditTodo { + submissionId: number + rosterId: number | null + taskId: number + taskName: string + realName: string + avatarUrl: string | null + orgId: number | null + orgName: string | null + submitTime: string | null + /** 已等待小时数(保留 1 位小数);列表按它正序,等最久的排最前 */ + waitHours: number | null + /** `0` 无问题 / `2` 仅警告 / `3` 有红色(**没有 1**) */ + level: number + redCount: number + yellowCount: number + /** 红问题的第一句;无红时为 null */ + redSummary: string | null + materialThumbs: string[] | null + materialCount: number + changedFields: string[] | null + rejectCount: number + /** 能不能批量通过 —— **服务端算好的,前端不许自己判 */ + batchApprovable: boolean + status: number + statusText: string +} + +export interface AuditCounts { + all: number + clean: number + abnormal: number + overTime: number +} + +export interface AuditTodoPage { + list: AuditTodo[] + total: number + counts: AuditCounts +} + +export interface AuditTodoQuery { + /** `1` 初审(只看到自己带的班)/ `2` 终审(全租户)。默认 1 */ + node?: number + taskId?: number + orgId?: number + /** `0` 无问题 / `2` 仅警告 / `3` 有红色 */ + level?: number + waitingOverHours?: number + keyword?: string + pageNum?: number + pageSize?: number +} + +export function getAuditTodos(params: AuditTodoQuery = {}) { + return http.get('/audit/todos', params) +} + +// ── 详情(§11.2)────────────────────────────── + +export interface AuditChangedItem { + field: string + label: string | null + /** 名册里老师填的值 */ + rosterValue: string | null + /** 学生提交的值 */ + studentValue: string | null + /** 一期恒为 null(`changed_fields` 只有字段名,没有逐字段时间) */ + changedAt: string | null +} + +export interface AuditIssue { + ruleCode: string + /** `1` 提示(蓝)/ `2` 警告(黄)/ `3` 阻断(红)—— 见下方 ISSUE_SEVERITY 的说明 */ + severity: number + title: string + detail: string + materialCode?: string | null + field?: string | null +} + +export interface AuditDetect { + materialCode: string | null + confidence: number | null + source: string | null + sourceText: string | null + conflict: boolean | null +} + +export interface AuditFileItem { + id: number + originalName: string | null + standardName: string | null + /** 签名链接(默认 30 分钟)—— **不要存库、不要转发 */ + previewUrl: string | null + thumbUrl: string | null + finalSizeKb: number | null + maxSizeKb: number | null + width: number | null + height: number | null + dpi: number | null + /** `0` 未校验 / `1` 通过 / `2` 警告 / `3` 错误 */ + validateStatus: number | null + /** `1` 本人 / `2` 班主任代传 / `3` 管理员代传 / `4` 整理器导入 */ + source: number | null + /** 识别结果(M4 未做时为 null,界面要能接受) */ + detect: AuditDetect | null + issues: AuditIssue[] | null +} + +export interface AuditMaterialGroup { + materialCode: string + materialName: string + required: boolean + done: boolean + /** 条件材料是否对学生可见(不可见的不该在审核页上要求他交) */ + visible: boolean + files: AuditFileItem[] | null +} + +export interface AuditApprovePolicy { + canApprove: boolean + canBatch: boolean + reason: string | null + redTitles: string[] | null +} + +export interface AuditRevokePolicy { + canRevoke: boolean + reason: string | null + /** 能撤的话撤哪条记录(原样回传即可) */ + auditRecordId: number | null +} + +export interface AuditTimelineItem { + id: number | null + /** `0` 学生提交 / `1` 初审 / `2` 终审 / `3` 材料 */ + node: number + nodeText: string + action: number | null + actionText: string | null + auditorId: number | null + auditorName: string | null + reasonCode: string | null + reasonText: string | null + materialCodes: string[] | null + time: string | null + durationSec: number | null +} + +export interface AuditDetail { + id: number + status: number + statusText: string + taskId: number + taskName: string + orgId: number | null + orgName: string | null + realName: string + /** 只给脱敏号;完整号要走单独接口并留痕 */ + idCardMask: string | null + submitTime: string | null + occupation: string | null + worktype: string | null + jobLevel: string | null + /** 表单值(内置 + 扩展),键是 snake_case */ + formData: Record | null + changedFields: string[] | null + changedDetail: AuditChangedItem[] | null + materials: AuditMaterialGroup[] | null + /** 现场校验的问题清单(**不是提交那一刻的快照**) */ + issues: AuditIssue[] | null + redCount: number + yellowCount: number + rejectCount: number + lastRejectCode: string | null + lastRejectText: string | null + approvePolicy: AuditApprovePolicy | null + revokePolicy: AuditRevokePolicy | null + timeline: AuditTimelineItem[] | null +} + +export function getAuditDetail(id: number) { + return http.get(`/audit/submissions/${id}`) +} + +export function getAuditTimeline(id: number) { + return http.get(`/audit/submissions/${id}/timeline`) +} + +// ── 动作 ────────────────────────────────────── + +export interface AuditApproveReq { + remark?: string + /** `SWIPE` 右滑 / `BUTTON` 按钮 —— 只用于埋点分析误触率 */ + actionSource?: string + /** 终审意见 */ + finalOpinion?: string +} + +export interface AuditApproveResult { + status: number + statusText: string + nextNode: string | null + nextNodeText: string | null + /** true = 重复请求的幂等回放(结果与第一次相同),不是真的又审了一次 */ + replayed: boolean +} + +export function approveSubmission(id: number, data: AuditApproveReq = {}) { + return http.post(`/audit/submissions/${id}/approve`, data) +} + +export interface AuditRejectReq { + /** PRD 附录 C 的原因码,如 `R001` */ + reasonCode: string + /** 补充说明;选 `R999` 时必填 */ + reasonText?: string + /** 是哪几项材料的问题 */ + materialCodes?: string[] + /** 语音转文字结果(可选输入方式) */ + voiceText?: string + suggestReupload?: boolean +} + +export interface AuditRejectResult { + status: number + statusText: string + rejectCount: number + /** ⚠️ 一期恒为 false(订阅消息 M5-12 没做)——界面要照着显示"请口头告知学生" */ + notifySent: boolean + needManualNotice: boolean + escalated: boolean +} + +export function rejectSubmission(id: number, data: AuditRejectReq) { + return http.post(`/audit/submissions/${id}/reject`, data) +} + +export interface AuditBatchApproveResult { + approved: number + skipped: number + details: Array<{ + submissionId: number + realName: string | null + skipped: boolean + reason: string | null + redCount: number | null + yellowCount: number | null + }> | null +} + +/** + * 批量通过(§11.3)。 + * + * `onlyClean` 不传 —— 服务端**强制 true**。传 false 不报错也不生效, + * 那反而会让读代码的人以为客户端能决定这件事。 + */ +export function batchApprove(submissionIds: number[], actionSource = 'BUTTON') { + return http.post('/audit/submissions/batch-approve', { + submissionIds, + actionSource + }) +} + +export interface AuditRevokeReq { + auditRecordId?: number + reason?: string +} + +export interface AuditRevokeResult { + status: number + statusText: string + revokedRecordId: number | null +} + +export function revokeSubmission(id: number, data: AuditRevokeReq = {}) { + return http.post(`/audit/submissions/${id}/revoke`, data) +} + +// ── 字典与文件 ───────────────────────────────── + +export interface RejectReasonCode { + code: string + text: string + /** true = 选中它必须填补充说明(只有 `R999`) */ + requiresText: boolean +} + +export function getReasonCodes() { + return http.get('/audit/reason-codes') +} + +/** + * 取材料的访问链接(审核端专用通道)。 + * + * ⚠️ **不要为了"让管理员也能看"而改去调学生端的 `/mp/files/*`** —— + * 那条通道校验的是"这份是不是你传的",改它等于把学生端弄漏。 + * 也**不要把返回的链接存库或转发**:七牛是私有空间,链接有有效期。 + */ +export function getAuditFileUrl(id: number, original = false) { + return http.get<{ url: string, expireSeconds: number }>(`/audit/files/${id}/url`, { original }) +} + +// ── 展示口径 ─────────────────────────────────── + +/** 卡片分级 → 标签色。**只有 0 / 2 / 3**。 */ +export const LEVEL_TAG: Record = { + 0: { text: '无问题', color: 'green' }, + 2: { text: '有警告', color: 'orange' }, + 3: { text: '有红问题', color: 'red' } +} + +/** + * 问题级别 → 颜色。 + * + * ⚠️ **口径是 `1` 提示(蓝)/ `2` 警告(黄)/ `3` 阻断(红)**,由服务端 + * `SubmissionService` 数红黄时用的常量(`Issue.INFO=1 / WARN=2 / ERROR=3`)定死。 + * 服务端 DTO 的类注释把顺序写反过一次 —— 以**常量与计数逻辑**为准。 + * 写反的后果是**把"提示"显示成红色**,于是所有人都在追一个不存在的问题。 + */ +export const ISSUE_SEVERITY: Record = { + 1: { text: '提示', color: 'blue' }, + 2: { text: '警告', color: 'orange' }, + 3: { text: '阻断', color: 'red' } +} + +/** 文件校验状态。 */ +export const FILE_VALIDATE: Record = { + 0: { text: '未校验', color: 'gray' }, + 1: { text: '通过', color: 'green' }, + 2: { text: '警告', color: 'orange' }, + 3: { text: '错误', color: 'red' } +} + +/** 文件来源。 */ +export const FILE_SOURCE: Record = { + 1: '本人上传', + 2: '班主任代传', + 3: '管理员代传', + 4: '整理器导入' +} + +/** 时间轴节点类型。 */ +export const TIMELINE_NODE: Record = { + 0: { text: '学生提交', color: 'blue' }, + 1: { text: '初审', color: 'arcoblue' }, + 2: { text: '终审', color: 'green' }, + 3: { text: '材料', color: 'gray' } +} + +/** 关键错误码 → 一句"接下来怎么办",避免用户只看到一句报错。 */ +export const AUDIT_ERROR_HINT: Record = { + 40032: '这一单有红问题(阻断级),必须先让问题消失才能通过。', + 40033: '该任务已经导出打包,结论不能再撤(导出包已经交出去了)。', + 40034: '该任务已归档,整体只读,不能撤回审核结论。', + 40301: '这一单不在你的审核范围里(或你不是原审核人)。', + 40011: '一次提交的单子太多了(上限 200 条),请分批操作。' +} + +// ── 审核统计(§11.6,工作台看板用)───────────────── + +export interface AuditStatisticsVO { + pendingCount: number + approvedCount: number + rejectedCount: number + /** 平均审核时长(小时);没有样本时是 null(**不是 0**)。 */ + avgAuditHours: number | null + timeoutCount: number + byAuditor: Array<{ auditorName: string, handled: number, avgHours: number | null }> + byReason: Array<{ reasonCode: string, reasonText: string, count: number }> + /** 契约 §11.6 的时效口径(班级 48h / 后台 72h),界面要显示出来。 */ + classTimeoutHours: number + adminTimeoutHours: number +} + +/** + * 审核统计。 + * + * ⚠️ `avgAuditHours` 为 null 时**不要显示成 0 小时** —— + * 「平均 0 小时」看起来像"秒过",而真相是"还没有样本"。 + */ +export function getAuditStatistics(params: { + taskId: number + orgId?: number + from?: string + to?: string +}) { + return http.get('/audit/statistics', params) +} diff --git a/frontend-pc/src/views/audit/AuditDetailDrawer.vue b/frontend-pc/src/views/audit/AuditDetailDrawer.vue new file mode 100644 index 0000000..34534fc --- /dev/null +++ b/frontend-pc/src/views/audit/AuditDetailDrawer.vue @@ -0,0 +1,589 @@ + + + + + diff --git a/frontend-pc/src/views/audit/AuditRejectModal.vue b/frontend-pc/src/views/audit/AuditRejectModal.vue new file mode 100644 index 0000000..0bb09c0 --- /dev/null +++ b/frontend-pc/src/views/audit/AuditRejectModal.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/frontend-pc/src/views/audit/FilePreviewImage.vue b/frontend-pc/src/views/audit/FilePreviewImage.vue new file mode 100644 index 0000000..378b5a4 --- /dev/null +++ b/frontend-pc/src/views/audit/FilePreviewImage.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/frontend-pc/src/views/audit/index.vue b/frontend-pc/src/views/audit/index.vue new file mode 100644 index 0000000..578a0df --- /dev/null +++ b/frontend-pc/src/views/audit/index.vue @@ -0,0 +1,515 @@ + + + + +