package com.kaidi.oa.web; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.kaidi.oa.common.ApiException; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.DeclCrypto; import com.kaidi.oa.common.NotFoundException; import com.kaidi.oa.domain.DeclSubmission; import com.kaidi.oa.domain.SysUser; import com.kaidi.oa.repository.DeclSubmissionRepository; import com.kaidi.oa.repository.DeclarationRepository; import com.kaidi.oa.service.AuthorizationService; import com.kaidi.oa.service.NotificationService; import jakarta.servlet.http.HttpServletRequest; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; 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.RestController; import java.time.Instant; import java.time.LocalDate; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; /** * 申报外部提交工作流(创新研发中心·申报服务部,需求 §2 内部审核流程 + 外部申报提交)。 * * 补齐审计点名的多处缺口(PARTIAL · high): * * * 受 AuthInterceptor 默认保护;含凭据,归入敏感读写门禁(见 sharedFileSnippets)。 */ @RestController @RequestMapping("/api/oa/decl-submissions") public class DeclSubmissionController { /** 会签链(草稿为起点,已提交为终点)。 */ static final List CHAIN = List.of( "草稿", "申报专员", "部门负责人", "技术会签", "财务会签", "法务会签", "高管核准", "已提交"); private static final String ST_DRAFT = "草稿"; private static final String ST_APPROVED = "高管核准"; private static final String ST_SUBMITTED = "已提交"; private static final String ESIGN_SIGNED = "已签章"; private static final Set REVEAL_ROLES = Set.of("ADMIN", "APPROVER"); private final DeclSubmissionRepository repo; private final DeclarationRepository declRepo; private final CurrentUserResolver currentUser; private final AuthorizationService authz; private final NotificationService notifications; private final ObjectMapper objectMapper; public DeclSubmissionController(DeclSubmissionRepository repo, DeclarationRepository declRepo, CurrentUserResolver currentUser, AuthorizationService authz, NotificationService notifications, ObjectMapper objectMapper) { this.repo = repo; this.declRepo = declRepo; this.currentUser = currentUser; this.authz = authz; this.notifications = notifications; this.objectMapper = objectMapper; } // ---------- 列表/详情(口令永不出现在响应中) ---------- public record SubmissionView(Long id, String code, Long declarationId, String projectName, String declType, String reviewStage, String govPlatform, String portalAccount, String passwordMask, String esignStatus, String receiptNo, String submittedDate, String owner, List> reviewTrail, String remark) { } private SubmissionView toView(DeclSubmission s) { return new SubmissionView(s.getId(), s.getCode(), s.getDeclarationId(), s.getProjectName(), s.getDeclType(), s.getReviewStage(), s.getGovPlatform(), s.getPortalAccount(), DeclCrypto.mask(s.getPortalPasswordEnc()), s.getEsignStatus(), s.getReceiptNo(), s.getSubmittedDate(), s.getOwner(), readTrail(s.getReviewTrailJson()), s.getRemark()); } @GetMapping public ApiResp> list(@RequestParam(required = false) String stage, @RequestParam(required = false) Long declarationId) { List rows; if (stage != null && !stage.isBlank()) { rows = repo.findByReviewStage(stage); } else if (declarationId != null) { rows = repo.findByDeclarationId(declarationId); } else { rows = repo.findAll(); } List out = new ArrayList<>(); for (DeclSubmission s : rows) { out.add(toView(s)); } return ApiResp.ok(out); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(toView(load(id))); } public record CreateRequest(Long declarationId, String projectName, String declType, String govPlatform, String portalAccount, String portalPassword, String owner, String remark) { } @PostMapping @Transactional public ApiResp create(@RequestBody CreateRequest req, HttpServletRequest request) { if (req.projectName() == null || req.projectName().isBlank()) { throw new ApiException(400, "申报项目名称(projectName) 不能为空"); } if (req.declarationId() != null && !declRepo.existsById(req.declarationId())) { throw new ApiException(400, "关联的申报项目不存在:" + req.declarationId()); } DeclSubmission s = new DeclSubmission(); s.setCode("TJ-" + (repo.count() + 1)); s.setDeclarationId(req.declarationId()); s.setProjectName(req.projectName().trim()); s.setDeclType(req.declType() == null || req.declType().isBlank() ? "首次" : req.declType()); s.setGovPlatform(req.govPlatform()); s.setPortalAccount(req.portalAccount()); // 口令加密落库(永不明文入库)。 s.setPortalPasswordEnc(DeclCrypto.encrypt(req.portalPassword())); s.setEsignStatus("未签"); s.setReviewStage(ST_DRAFT); s.setOwner(req.owner() == null || req.owner().isBlank() ? currentUser.resolveLabel(request) : req.owner()); s.setRemark(req.remark()); s.setReviewTrailJson(writeTrail(new ArrayList<>())); s.setCreatedAt(Instant.now()); return ApiResp.ok(toView(repo.save(s))); } public record UpdateRequest(String govPlatform, String portalAccount, String portalPassword, String declType, String owner, String remark) { } @PatchMapping("/{id}") @Transactional public ApiResp update(@PathVariable Long id, @RequestBody UpdateRequest req) { DeclSubmission s = load(id); if (req.govPlatform() != null) s.setGovPlatform(req.govPlatform()); if (req.portalAccount() != null) s.setPortalAccount(req.portalAccount()); // 仅当传入非空新口令才重新加密覆盖(空串=不改),避免误清空。 if (req.portalPassword() != null && !req.portalPassword().isBlank()) { s.setPortalPasswordEnc(DeclCrypto.encrypt(req.portalPassword())); } if (req.declType() != null) s.setDeclType(req.declType()); if (req.owner() != null) s.setOwner(req.owner()); if (req.remark() != null) s.setRemark(req.remark()); // reviewStage / esignStatus / receiptNo 仅由业务动作推进,禁止客户端直写。 return ApiResp.ok(toView(repo.save(s))); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { if (!repo.existsById(id)) { throw new NotFoundException("decl submission not found: " + id); } repo.deleteById(id); return ApiResp.ok(null); } // ---------- 会签链状态机 ---------- public record ApproveRequest(String note) { } /** 会签通过:沿 CHAIN 前进一步(到「高管核准」止,再要外部提交走 /submit)。每步留痕。 */ @PostMapping("/{id}/approve") @Transactional public ApiResp approve(@PathVariable Long id, @RequestBody(required = false) ApproveRequest req, HttpServletRequest request) { DeclSubmission s = load(id); int idx = CHAIN.indexOf(s.getReviewStage()); if (idx < 0) { throw new ApiException(400, "未知会签阶段:" + s.getReviewStage()); } if (ST_SUBMITTED.equals(s.getReviewStage())) { throw new ApiException(409, "已外部提交,无需再会签"); } if (CHAIN.get(idx).equals(ST_APPROVED)) { throw new ApiException(400, "已至「高管核准」,请走外部提交(/submit)"); } String next = CHAIN.get(idx + 1); // 跳过「已提交」末节点:会签链只到高管核准为止。 if (ST_SUBMITTED.equals(next)) { throw new ApiException(400, "会签已到「高管核准」,请走外部提交(/submit)"); } s.setReviewStage(next); appendTrail(s, next, "通过", note(req == null ? null : req.note(), "会签通过"), currentUser.resolveLabel(request)); return ApiResp.ok(toView(repo.save(s))); } public record RejectRequest(String note) { } /** 会签驳回:任一会签节点驳回,整单退回「草稿」重新发起。留痕记录驳回意见。 */ @PostMapping("/{id}/reject") @Transactional public ApiResp reject(@PathVariable Long id, @RequestBody(required = false) RejectRequest req, HttpServletRequest request) { DeclSubmission s = load(id); if (ST_SUBMITTED.equals(s.getReviewStage())) { throw new ApiException(409, "已外部提交,无法驳回"); } if (ST_DRAFT.equals(s.getReviewStage())) { throw new ApiException(400, "尚在草稿,无可驳回的会签"); } String from = s.getReviewStage(); s.setReviewStage(ST_DRAFT); String operator = currentUser.resolveLabel(request); appendTrail(s, from, "驳回", note(req == null ? null : req.note(), "会签驳回,退回草稿"), operator); notifications.notify(s.getOwner(), "申报", "申报会签被驳回:" + s.getProjectName(), "「" + s.getProjectName() + "」在「" + from + "」节点被 " + operator + " 驳回,请修订后重新发起会签。", "decl_submission", s.getId()); return ApiResp.ok(toView(repo.save(s))); } /** 电子签章:在外部提交前对申报书电子签章。仅「高管核准」后允许签章。 */ @PostMapping("/{id}/esign") @Transactional public ApiResp esign(@PathVariable Long id) { DeclSubmission s = load(id); if (!ST_APPROVED.equals(s.getReviewStage())) { throw new ApiException(400, "需会签至「高管核准」后方可电子签章(当前:" + s.getReviewStage() + ")"); } s.setEsignStatus(ESIGN_SIGNED); return ApiResp.ok(toView(repo.save(s))); } public record SubmitRequest(String receiptNo, String submittedDate) { } /** 外部提交至政务平台:要求「高管核准 + 已签章」,回填受理回执与提交日,置「已提交」,通知发起人。 */ @PostMapping("/{id}/submit") @Transactional public ApiResp submit(@PathVariable Long id, @RequestBody(required = false) SubmitRequest req, HttpServletRequest request) { DeclSubmission s = load(id); if (ST_SUBMITTED.equals(s.getReviewStage())) { throw new ApiException(409, "已提交,请勿重复"); } if (!ST_APPROVED.equals(s.getReviewStage())) { throw new ApiException(400, "需会签至「高管核准」后方可外部提交(当前:" + s.getReviewStage() + ")"); } if (!ESIGN_SIGNED.equals(s.getEsignStatus())) { throw new ApiException(400, "外部提交前需先完成电子签章"); } s.setReviewStage(ST_SUBMITTED); s.setReceiptNo(req == null ? null : req.receiptNo()); s.setSubmittedDate(req != null && req.submittedDate() != null && !req.submittedDate().isBlank() ? req.submittedDate() : LocalDate.now().toString()); appendTrail(s, ST_SUBMITTED, "提交", "已向「" + (s.getGovPlatform() == null ? "政务平台" : s.getGovPlatform()) + "」提交,受理回执:" + (s.getReceiptNo() == null ? "(待回填)" : s.getReceiptNo()), currentUser.resolveLabel(request)); notifications.notify(s.getOwner(), "申报", "申报已外部提交:" + s.getProjectName(), "「" + s.getProjectName() + "」已提交至政务平台,受理回执:" + s.getReceiptNo(), "decl_submission", s.getId()); return ApiResp.ok(toView(repo.save(s))); } // ---------- 凭据解密(仅 ADMIN/APPROVER) ---------- public record CredentialView(Long id, String govPlatform, String portalAccount, String portalPassword) { } /** 解密返回政务平台明文口令,供真实登录代填。仅 ADMIN/APPROVER 可读,否则 403。 */ @PostMapping("/{id}/reveal-credential") public ApiResp reveal(@PathVariable Long id, HttpServletRequest request) { SysUser su = currentUser.resolve(request); if (su == null || !authz.hasAnyRole(su.getId(), REVEAL_ROLES)) { throw new ApiException(403, "无权查看政务平台登录口令(需 ADMIN/APPROVER)"); } DeclSubmission s = load(id); return ApiResp.ok(new CredentialView(s.getId(), s.getGovPlatform(), s.getPortalAccount(), DeclCrypto.decrypt(s.getPortalPasswordEnc()))); } // ---------- helpers ---------- private DeclSubmission load(Long id) { return repo.findById(id) .orElseThrow(() -> new NotFoundException("decl submission not found: " + id)); } private static String note(String raw, String fallback) { return raw == null || raw.isBlank() ? fallback : raw; } private void appendTrail(DeclSubmission s, String stage, String decision, String note, String operator) { List> trail = readTrail(s.getReviewTrailJson()); Map e = new LinkedHashMap<>(); e.put("stage", stage); e.put("decision", decision); e.put("note", note); e.put("operator", operator); e.put("at", Instant.now().toString()); trail.add(e); s.setReviewTrailJson(writeTrail(trail)); } private List> readTrail(String json) { if (json == null || json.isBlank()) { return new ArrayList<>(); } try { List> parsed = objectMapper.readValue( json, new TypeReference>>() { }); return parsed == null ? new ArrayList<>() : new ArrayList<>(parsed); } catch (Exception e) { return new ArrayList<>(); } } private String writeTrail(List> trail) { try { return objectMapper.writeValueAsString(trail); } catch (Exception e) { return "[]"; } } }