SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。 == 此快照内容 == - 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091) - 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static) - 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB) - 交接文档 go.md + go-code-reference/endpoints/entities/database.md - 多代理建设脚本 .claude/wf-*.js == 状态 == - 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板) - 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用 - W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成) - 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456 == 排除(gitignore, 可再生) == node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui) 完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules) 时间戳: 20260615-191517 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
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):
|
||||
* <ul>
|
||||
* <li>真会签链状态机(非单纯线性):草稿 → 申报专员 → 部门负责人 → 技术会签 → 财务会签 → 法务会签
|
||||
* → 高管核准 → 已提交;每一步 /{id}/approve 通过、/{id}/reject 驳回回草稿,意见全程留痕;</li>
|
||||
* <li>外部账号密码加密存储:portalPassword 落库前经 {@link DeclCrypto} 可逆加密,列表/详情仅回掩码,
|
||||
* 仅 /{id}/reveal-credential(ADMIN/APPROVER)才解密返回明文,杜绝明文入库/越权读取;</li>
|
||||
* <li>电子签章 + 外部提交:/{id}/esign 标记已签章,/{id}/submit 在「高管核准且已签章」后置「已提交」、
|
||||
* 回填政务平台受理回执 receiptNo 与提交日,并通知发起人。</li>
|
||||
* </ul>
|
||||
*
|
||||
* 受 AuthInterceptor 默认保护;含凭据,归入敏感读写门禁(见 sharedFileSnippets)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/oa/decl-submissions")
|
||||
public class DeclSubmissionController {
|
||||
|
||||
/** 会签链(草稿为起点,已提交为终点)。 */
|
||||
static final List<String> 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<String> 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<Map<String, Object>> 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<SubmissionView>> list(@RequestParam(required = false) String stage,
|
||||
@RequestParam(required = false) Long declarationId) {
|
||||
List<DeclSubmission> rows;
|
||||
if (stage != null && !stage.isBlank()) {
|
||||
rows = repo.findByReviewStage(stage);
|
||||
} else if (declarationId != null) {
|
||||
rows = repo.findByDeclarationId(declarationId);
|
||||
} else {
|
||||
rows = repo.findAll();
|
||||
}
|
||||
List<SubmissionView> out = new ArrayList<>();
|
||||
for (DeclSubmission s : rows) {
|
||||
out.add(toView(s));
|
||||
}
|
||||
return ApiResp.ok(out);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResp<SubmissionView> 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<SubmissionView> 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<SubmissionView> 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<Void> 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<SubmissionView> 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<SubmissionView> 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<SubmissionView> 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<SubmissionView> 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<CredentialView> 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<Map<String, Object>> trail = readTrail(s.getReviewTrailJson());
|
||||
Map<String, Object> 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<Map<String, Object>> readTrail(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
try {
|
||||
List<Map<String, Object>> parsed = objectMapper.readValue(
|
||||
json, new TypeReference<List<Map<String, Object>>>() {
|
||||
});
|
||||
return parsed == null ? new ArrayList<>() : new ArrayList<>(parsed);
|
||||
} catch (Exception e) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
private String writeTrail(List<Map<String, Object>> trail) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(trail);
|
||||
} catch (Exception e) {
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user