in = new java.util.ArrayList<>();
+ for (Edge e : edges) {
+ if (nodeId != null && nodeId.equals(e.to)) {
+ in.add(e);
+ }
+ }
+ return in;
+ }
+}
diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java b/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java
new file mode 100644
index 0000000..eeb615a
--- /dev/null
+++ b/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java
@@ -0,0 +1,252 @@
+package com.yunhai.flowcenter.engine;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yunhai.flowcenter.entity.FlwBranch;
+import com.yunhai.flowcenter.entity.FlwDef;
+import com.yunhai.flowcenter.entity.FlwInstance;
+import com.yunhai.flowcenter.entity.FlwTask;
+import com.yunhai.flowcenter.enums.FlwEnums;
+import com.yunhai.flowcenter.repository.FlwBranchRepository;
+import com.yunhai.flowcenter.repository.FlwTaskRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 流程引擎推进核心(概览 §5.2 激活规则):由调用方事务驱动——
+ * 发起后 startAdvance 从 START 推进;任务全同意后 continueAdvance 推进下一节点。
+ * 只做状态迁移与事件发布(AFTER_COMMIT 解耦通知/回调),不感知任何业务方。
+ *
+ * 推进算法(递归激活,branchKey 携带分支上下文):START→下节点;APPROVAL→建任务等待;
+ * PARALLEL_SPLIT→逐出边建分支行并递归;PARALLEL_JOIN→标记到达、全到达才放行;END→办件完成。
+ * 拒绝/撤回的终止清理在 FlowTaskService(终点语义与任务抢占耦合)。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class FlowEngine {
+
+ private final ObjectMapper objectMapper;
+ private final FlwTaskRepository taskRepository;
+ private final FlwBranchRepository branchRepository;
+ private final AssigneeResolver assigneeResolver;
+ private final ApplicationEventPublisher eventPublisher;
+
+ /** 发起后首推:从 START 激活(instance 已建好、status=10) */
+ public void startAdvance(FlwInstance instance, FlwDef def) {
+ FlowDefModel model = parseModel(def);
+ setCurrentNodes(instance, null);
+ advance(instance, model, model.start, null);
+ }
+
+ /**
+ * 某节点全部同意后的放行推进:移出活跃节点集并激活唯一出边目标。
+ * 分支上下文由 {@link #branchKeyOf} 从图上回溯推导(分支内串行链的中间节点无任务级分支标记)。
+ */
+ public void continueAdvance(FlwInstance instance, FlwDef def, String fromNodeId) {
+ FlowDefModel model = parseModel(def);
+ FlowDefModel.Node node = model.nodeById(fromNodeId);
+ List outs = model.outEdges(fromNodeId);
+ if (outs.isEmpty()) {
+ return;
+ }
+ removeCurrentNode(instance, fromNodeId);
+ String branchKey = branchKeyOf(model, fromNodeId);
+ String next = outs.get(0).to;
+ FlowDefModel.Node nextNode = model.nodeById(next);
+ if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(nextNode.type)) {
+ // 会签合并守门:标记本分支到达、全部到达才放行(直通无分支键时 JOIN 直通放行)
+ advance(instance, model, next, branchKey);
+ } else {
+ activate(instance, model, nextNode, branchKey);
+ }
+ }
+
+ /**
+ * 回溯 fromNodeId 所在分支的 branchKey(= PARALLEL_SPLIT 出边目标节点 id):
+ * 沿入边反向走到 split,split→N 边的 N 即分支键;遇 START/PARALLEL_JOIN 边界即停
+ * (join 之后属汇聚链,branchKey 已消耗,返回 null)。
+ */
+ private String branchKeyOf(FlowDefModel model, String fromNodeId) {
+ Set visited = new HashSet<>();
+ Deque queue = new ArrayDeque<>();
+ queue.add(fromNodeId);
+ visited.add(fromNodeId);
+ while (!queue.isEmpty()) {
+ String cur = queue.poll();
+ for (FlowDefModel.Edge e : model.inEdges(cur)) {
+ FlowDefModel.Node from = model.node(e.from);
+ if (from == null) {
+ continue;
+ }
+ if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(from.type)) {
+ return e.to;
+ }
+ if (FlowDefModel.TYPE_START.equals(from.type)
+ || FlowDefModel.TYPE_PARALLEL_JOIN.equals(from.type)
+ || !visited.add(e.from)) {
+ continue;
+ }
+ queue.add(e.from);
+ }
+ }
+ return null;
+ }
+
+ /** 统一激活入口:含 JOIN 的到达标记与放行判断 */
+ private void advance(FlwInstance instance, FlowDefModel model, String nodeId, String branchKey) {
+ FlowDefModel.Node node = model.nodeById(nodeId);
+ if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(node.type)) {
+ if (branchKey != null) {
+ FlwBranch branch = branchRepository.findByInstanceIdAndBranchKey(instance.getId(), branchKey)
+ .orElse(null);
+ if (branch != null) {
+ branch.setStatus(FlwEnums.BranchStatus.ARRIVED.getCode());
+ branchRepository.save(branch);
+ }
+ }
+ long running = branchRepository.countByInstanceIdAndStatus(
+ instance.getId(), FlwEnums.BranchStatus.RUNNING.getCode());
+ if (running > 0) {
+ log.debug("[flow-center] JOIN {} 等待分支汇聚:instance={} remaining={}",
+ nodeId, instance.getId(), running);
+ return;
+ }
+ List outs = model.outEdges(nodeId);
+ if (outs.isEmpty()) {
+ return;
+ }
+ activate(instance, model, model.nodeById(outs.get(0).to), null);
+ return;
+ }
+ activate(instance, model, node, branchKey);
+ }
+
+ /** 非 JOIN 节点激活 */
+ private void activate(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node, String branchKey) {
+ switch (node.type) {
+ case FlowDefModel.TYPE_START: {
+ List outs = model.outEdges(node.id);
+ if (!outs.isEmpty()) {
+ advance(instance, model, outs.get(0).to, branchKey);
+ }
+ break;
+ }
+ case FlowDefModel.TYPE_APPROVAL:
+ createTasks(instance, model, node);
+ addCurrentNode(instance, node.id);
+ break;
+ case FlowDefModel.TYPE_PARALLEL_SPLIT: {
+ for (FlowDefModel.Edge edge : model.outEdges(node.id)) {
+ FlwBranch branch = new FlwBranch();
+ branch.setInstanceId(instance.getId());
+ branch.setBranchKey(edge.to);
+ branch.setStatus(FlwEnums.BranchStatus.RUNNING.getCode());
+ branchRepository.save(branch);
+ advance(instance, model, edge.to, edge.to);
+ }
+ break;
+ }
+ case FlowDefModel.TYPE_END:
+ complete(instance);
+ break;
+ default:
+ log.warn("[flow-center] 未知节点类型 {}(节点 {}),推进中止", node.type, node.id);
+ }
+ }
+
+ /** 建任务(ALL=每人一条;ANY=每人一条、任一人办理即放行)+ 到达事件 */
+ private void createTasks(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node) {
+ List assignees = assigneeResolver.resolve(instance.getSchId(), node);
+ LocalDateTime now = LocalDateTime.now();
+ LocalDateTime deadline = node.deadlineHours == null ? null : now.plusHours(node.deadlineHours);
+ for (AssigneeRef ref : assignees) {
+ FlwTask task = new FlwTask();
+ task.setInstanceId(instance.getId());
+ task.setSchId(instance.getSchId());
+ task.setNodeId(node.id);
+ task.setNodeName(node.name);
+ task.setAssigneeId(ref.getUserId());
+ task.setAssigneeName(ref.getUserName());
+ task.setStatus(FlwEnums.TaskStatus.PENDING.getCode());
+ task.setArriveTime(now);
+ task.setDeadline(deadline);
+ task.setUrgeCount(0);
+ taskRepository.save(task);
+ Map data = new HashMap<>();
+ data.put("nodeId", node.id);
+ data.put("nodeName", node.name);
+ data.put("nodeSummary", nodeSummary(model));
+ data.put("totalTasks", assignees.size());
+ data.put("doneTasks", 0);
+ eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.TASK_CREATED,
+ instance.getId(), task.getId(), data));
+ }
+ }
+
+ /** END 激活:办件完成、残留任务/分支失效、完成事件 */
+ private void complete(FlwInstance instance) {
+ instance.setStatus(FlwEnums.InstanceStatus.COMPLETED.getCode());
+ instance.setFinishTime(LocalDateTime.now());
+ setCurrentNodes(instance, null);
+ taskRepository.invalidatePendingByInstance(instance.getId());
+ branchRepository.invalidateRunningByInstance(instance.getId());
+ Map data = new HashMap<>();
+ data.put("result", "APPROVED");
+ eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.COMPLETED, instance.getId(), null, data));
+ }
+
+ public FlowDefModel parseModel(FlwDef def) {
+ return FlowDefModel.parse(objectMapper, def.getContentJson());
+ }
+
+ /** 节点链摘要(APPROVAL 节点名串联,oapi list/detail 与回调 payload 共用) */
+ public static String nodeSummary(FlowDefModel model) {
+ StringBuilder sb = new StringBuilder();
+ for (FlowDefModel.Node n : model.nodes) {
+ if (FlowDefModel.TYPE_APPROVAL.equals(n.type)) {
+ if (sb.length() > 0) {
+ sb.append("→");
+ }
+ sb.append(n.name == null || n.name.isEmpty() ? n.id : n.name);
+ }
+ }
+ return sb.toString();
+ }
+
+ private void addCurrentNode(FlwInstance instance, String nodeId) {
+ String cur = instance.getCurrentNodes();
+ instance.setCurrentNodes(cur == null || cur.isEmpty() ? nodeId : cur + "," + nodeId);
+ }
+
+ private void removeCurrentNode(FlwInstance instance, String nodeId) {
+ String cur = instance.getCurrentNodes();
+ if (cur == null || cur.isEmpty()) {
+ return;
+ }
+ StringBuilder sb = new StringBuilder();
+ for (String part : cur.split(",")) {
+ if (!part.equals(nodeId) && !part.isEmpty()) {
+ if (sb.length() > 0) {
+ sb.append(",");
+ }
+ sb.append(part);
+ }
+ }
+ instance.setCurrentNodes(sb.toString());
+ }
+
+ private void setCurrentNodes(FlwInstance instance, String value) {
+ instance.setCurrentNodes(value == null ? "" : value);
+ }
+}
diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java b/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java
new file mode 100644
index 0000000..8898ec5
--- /dev/null
+++ b/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java
@@ -0,0 +1,68 @@
+package com.yunhai.flowcenter.engine;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 引擎关键状态变迁事件(概览 §5.2 v1.1 增补):事务内发布,AFTER_COMMIT 后由
+ * CallbackDispatcher(回调给业务方)与 UserMsgNotifier(通知人)消费——引擎本身不知道
+ * 也不依赖任何业务方的存在。携带 instanceId 而非实体快照:监听方在提交后按需重读(含最新 version)。
+ */
+@Getter
+@RequiredArgsConstructor
+public class FlowEvent {
+
+ public enum Type {
+ /** 代发起/重提成功(instance=10) */
+ SUBMITTED("flow.submitted"),
+ /** 任一审批任务创建(并行会签每个任务各发一条,data 带 n/m 汇总) */
+ TASK_CREATED("flow.task.created"),
+ /** 任一任务被办理(同意/拒绝各发一条,进度同步用) */
+ TASK_HANDLED("flow.task.handled"),
+ /** END 激活,instance=20 */
+ COMPLETED("flow.completed"),
+ /** END_REJECTED,instance=30 */
+ REJECTED("flow.rejected"),
+ /** 提交人撤回,instance=40 */
+ WITHDRAWN("flow.withdrawn"),
+ /** 催办(仅 UserMsg 通道,非回调事件枚举) */
+ URGE("flow.urge");
+
+ private final String code;
+
+ Type(String code) {
+ this.code = code;
+ }
+
+ public String getCode() {
+ return code;
+ }
+
+ public static Type ofCode(String code) {
+ for (Type t : values()) {
+ if (t.code.equals(code)) {
+ return t;
+ }
+ }
+ return null;
+ }
+ }
+
+ private final Type type;
+ private final Long instanceId;
+ /** 任务事件带 taskId(SUBMITTED 等办件级事件为 null) */
+ private final Long taskId;
+ /** 事件明细(result/opinion/rejectNode/task.created 的 n/m 等),可为空 */
+ private final Map data;
+
+ public static FlowEvent of(Type type, Long instanceId) {
+ return new FlowEvent(type, instanceId, null, new HashMap<>());
+ }
+
+ public static FlowEvent of(Type type, Long instanceId, Long taskId, Map data) {
+ return new FlowEvent(type, instanceId, taskId, data == null ? new HashMap<>() : data);
+ }
+}
diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java b/src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java
new file mode 100644
index 0000000..6efb90f
--- /dev/null
+++ b/src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java
@@ -0,0 +1,45 @@
+package com.yunhai.flowcenter.engine;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.orm.ObjectOptimisticLockingFailureException;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+/**
+ * 节点放行推进监听器:TASK_HANDLED 事件 AFTER_COMMIT 后在新事务里重查——
+ * 该节点待办是否清零、办件是否仍在途,全部成立才继续推进(下一节点/JOIN 合并/END)。
+ * 会签并发办理时各方提交的快照互相看不到对方未提交的办理行,事务内判断
+ * "节点是否全部办结"必假,提交后重查才是唯一可靠时机;推进串行化由办件乐观锁兜底
+ * (重试循环在监听器侧,跨 bean 调用 {@link FlowAdvanceService#tryAdvance} 保证 REQUIRES_NEW 生效)。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class FlowNodeAdvanceListener {
+
+ private final FlowAdvanceService advanceService;
+
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
+ public void onTaskHandled(FlowEvent event) {
+ if (event.getType() != FlowEvent.Type.TASK_HANDLED || event.getData() == null) {
+ return;
+ }
+ String nodeId = (String) event.getData().get("nodeId");
+ if (nodeId == null) {
+ return;
+ }
+ for (int attempt = 1; attempt <= 3; attempt++) {
+ try {
+ advanceService.tryAdvance(event.getInstanceId(), nodeId);
+ return;
+ } catch (ObjectOptimisticLockingFailureException e) {
+ log.warn("[flow-center] 推进冲突重试 {}/3:instance={} node={}",
+ attempt, event.getInstanceId(), nodeId);
+ }
+ }
+ log.error("[flow-center] 推进重试耗尽(请人工检查):instance={} node={}",
+ event.getInstanceId(), nodeId);
+ }
+}
diff --git a/src/main/java/com/yunhai/flowcenter/entity/BaseEntity.java b/src/main/java/com/yunhai/flowcenter/entity/BaseEntity.java
new file mode 100644
index 0000000..7953d3f
--- /dev/null
+++ b/src/main/java/com/yunhai/flowcenter/entity/BaseEntity.java
@@ -0,0 +1,44 @@
+package com.yunhai.flowcenter.entity;
+
+import com.yunhai.flowcenter.common.FlowCenterAuditListener;
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.EntityListeners;
+import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
+import javax.persistence.Id;
+import javax.persistence.MappedSuperclass;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 所有实体的公共基类:自增主键 + 审计四件套(FlowCenterAuditListener 自动填充,CmsAuditListener 同款)。
+ * 主键用 Long:flw_instance.id 即对外办件标识(svc-hall 侧 flow_instance_id 为 BIGINT,对齐)。
+ * 多租户键 sch_id 不在基类——各业务实体自带列(Notice 先例,语义不同不混入)。
+ */
+@MappedSuperclass
+@Getter
+@Setter
+@EntityListeners(FlowCenterAuditListener.class)
+public abstract class BaseEntity implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "create_by", updatable = false)
+ private String createBy;
+
+ @Column(name = "create_time", updatable = false)
+ private LocalDateTime createTime;
+
+ @Column(name = "update_by")
+ private String updateBy;
+
+ @Column(name = "update_time")
+ private LocalDateTime updateTime;
+}
diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwAssigneeMap.java b/src/main/java/com/yunhai/flowcenter/entity/FlwAssigneeMap.java
new file mode 100644
index 0000000..cd75141
--- /dev/null
+++ b/src/main/java/com/yunhai/flowcenter/entity/FlwAssigneeMap.java
@@ -0,0 +1,48 @@
+package com.yunhai.flowcenter.entity;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+
+/**
+ * 办理人映射(概览 §4.7,一期显式维护):dept_key 即流程 JSON 里 DEPT_KEY 引用的科室编码;
+ * user_name 为快照供任务列表免跨服务查名。外部托管模式复用同一张映射表——办理人解析与发起入口无关。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "flw_assignee_map",
+ uniqueConstraints = @UniqueConstraint(name = "uk_flw_map_sch_dept_user",
+ columnNames = {"sch_id", "dept_key", "user_id"}),
+ indexes = @Index(name = "idx_flw_map_sch_dept", columnList = "sch_id, dept_key, status"))
+public class FlwAssigneeMap extends BaseEntity {
+
+ /** 租户键 */
+ @Column(name = "sch_id", nullable = false)
+ private Long schId;
+
+ /** 科室编码(流程 JSON DEPT_KEY 引用,如 ybb=医保办) */
+ @Column(name = "dept_key", nullable = false, length = 64)
+ private String deptKey;
+
+ /** 科室名快照 */
+ @Column(name = "dept_name", length = 128)
+ private String deptName;
+
+ /** 办理人 id */
+ @Column(name = "user_id", nullable = false)
+ private Integer userId;
+
+ /** 办理人姓名快照(管理端成员检索时从 UserBase 补全) */
+ @Column(name = "user_name", length = 128)
+ private String userName;
+
+ /** 0 启用 */
+ @Column(name = "status", nullable = false)
+ private Integer status = 0;
+}
diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwAttachment.java b/src/main/java/com/yunhai/flowcenter/entity/FlwAttachment.java
new file mode 100644
index 0000000..e220fbb
--- /dev/null
+++ b/src/main/java/com/yunhai/flowcenter/entity/FlwAttachment.java
@@ -0,0 +1,50 @@
+package com.yunhai.flowcenter.entity;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+
+/**
+ * 申请材料(概览 §4.6):外部托管模式材料留在业务方(biz_snapshot 引用文件名清单),
+ * 本表仅服务显式上传场景(M1 不开发上传端点,表结构先行)。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "flw_attachment",
+ indexes = @Index(name = "idx_flw_att_inst", columnList = "instance_id, sch_id"))
+public class FlwAttachment extends BaseEntity {
+
+ /** 归属办件 */
+ @Column(name = "instance_id", nullable = false)
+ private Long instanceId;
+
+ /** 租户键 */
+ @Column(name = "sch_id", nullable = false)
+ private Long schId;
+
+ /** 表单字段键(无表单模式为 null) */
+ @Column(name = "field_key", length = 64)
+ private String fieldKey;
+
+ @Column(name = "file_name", length = 255)
+ private String fileName;
+
+ /** 磁盘相对路径({FILE_PATH} 下) */
+ @Column(name = "file_path", length = 500)
+ private String filePath;
+
+ /** 对外可访问 URL */
+ @Column(name = "web_url", length = 500)
+ private String webUrl;
+
+ @Column(name = "file_size")
+ private Long fileSize;
+
+ @Column(name = "mime_type", length = 128)
+ private String mimeType;
+}
diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwBranch.java b/src/main/java/com/yunhai/flowcenter/entity/FlwBranch.java
new file mode 100644
index 0000000..f36c694
--- /dev/null
+++ b/src/main/java/com/yunhai/flowcenter/entity/FlwBranch.java
@@ -0,0 +1,35 @@
+package com.yunhai.flowcenter.entity;
+
+import com.yunhai.flowcenter.enums.FlwEnums;
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+
+/**
+ * 并行分支进度(概览 §4.5):PARALLEL_SPLIT 为每条出边建一行,到达 PARALLEL_JOIN 时
+ * 标记已到达,全部到达才放行(waitFor=ALL);branch_key=出边目标节点 id。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "flw_branch",
+ uniqueConstraints = @UniqueConstraint(name = "uk_flw_branch_inst_key",
+ columnNames = {"instance_id", "branch_key"}))
+public class FlwBranch extends BaseEntity {
+
+ /** 归属办件 */
+ @Column(name = "instance_id", nullable = false)
+ private Long instanceId;
+
+ /** 分支键(split 出边目标节点 id) */
+ @Column(name = "branch_key", nullable = false, length = 64)
+ private String branchKey;
+
+ /** 0 进行中 / 1 已到达汇聚点 / 2 已失效 */
+ @Column(name = "status", nullable = false)
+ private Integer status = FlwEnums.BranchStatus.RUNNING.getCode();
+}
diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java b/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java
new file mode 100644
index 0000000..0b89667
--- /dev/null
+++ b/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java
@@ -0,0 +1,82 @@
+package com.yunhai.flowcenter.entity;
+
+import com.yunhai.flowcenter.enums.FlwEnums;
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+import java.time.LocalDateTime;
+
+/**
+ * 回调推送日志 = 重试队列(概览 §4.9):eventId 唯一(重试不变,业务方按它幂等去重);
+ * payload_json 完整快照可重放;索引 (status, next_retry_time) 是扫描主路径。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "flw_callback_log",
+ uniqueConstraints = @UniqueConstraint(name = "uk_flw_log_event", columnNames = "event_id"),
+ indexes = {
+ @Index(name = "idx_flw_log_scan", columnList = "status, next_retry_time"),
+ @Index(name = "idx_flw_log_inst", columnList = "instance_id")})
+public class FlwCallbackLog extends BaseEntity {
+
+ /** 租户键 */
+ @Column(name = "sch_id", nullable = false)
+ private Long schId;
+
+ /** 归属注册 */
+ @Column(name = "reg_id", nullable = false)
+ private Long regId;
+
+ /** 事件唯一键(UUID 去横线 32 位),重试不变 */
+ @Column(name = "event_id", nullable = false, length = 32)
+ private String eventId;
+
+ /** 事件类型(flow.submitted/task.created/task.handled/completed/rejected/withdrawn) */
+ @Column(name = "event", nullable = false, length = 32)
+ private String event;
+
+ /** 办件 */
+ @Column(name = "instance_id", nullable = false)
+ private Long instanceId;
+
+ /** 业务键冗余(业务方消费主键) */
+ @Column(name = "biz_type", length = 32)
+ private String bizType;
+
+ @Column(name = "biz_id", length = 64)
+ private String bizId;
+
+ /** 完整推送体快照(含签名原文,可重放) */
+ @Column(name = "payload_json", nullable = false, columnDefinition = "LONGTEXT")
+ private String payloadJson;
+
+ /** 0 待推/重试中 / 1 成功 / 2 死信 */
+ @Column(name = "status", nullable = false)
+ private Integer status = FlwEnums.CallbackLogStatus.PENDING.getCode();
+
+ /** 已重试次数 */
+ @Column(name = "retry_count", nullable = false)
+ private Integer retryCount = 0;
+
+ /** 下次推送时间 */
+ @Column(name = "next_retry_time")
+ private LocalDateTime nextRetryTime;
+
+ /** 末次 HTTP 状态(管理端排障用) */
+ @Column(name = "last_http_status", length = 32)
+ private String lastHttpStatus;
+
+ /** 末次错误 */
+ @Column(name = "last_error", length = 1000)
+ private String lastError;
+
+ /** 成功时间 */
+ @Column(name = "push_time")
+ private LocalDateTime pushTime;
+}
diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java b/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java
new file mode 100644
index 0000000..76a18b0
--- /dev/null
+++ b/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java
@@ -0,0 +1,52 @@
+package com.yunhai.flowcenter.entity;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+
+/**
+ * 回调注册(概览 §4.8):同一应用同一流程一条注册;secret 注册时生成本地下发,
+ * 与应用级 appSecret 分离、粒度到流程(泄露可单独重置)。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "flw_callback_reg",
+ uniqueConstraints = @UniqueConstraint(name = "uk_flw_reg_sch_app_flow",
+ columnNames = {"sch_id", "app_key", "flow_guid"}),
+ indexes = @Index(name = "idx_flw_reg_flow", columnList = "sch_id, flow_guid, status"))
+public class FlwCallbackReg extends BaseEntity {
+
+ /** 租户键 */
+ @Column(name = "sch_id", nullable = false)
+ private Long schId;
+
+ /** 业务方应用标识(调用方 appKey,来自验签上下文) */
+ @Column(name = "app_key", nullable = false, length = 64)
+ private String appKey;
+
+ /** 订阅的流程 */
+ @Column(name = "flow_guid", nullable = false, length = 32)
+ private String flowGuid;
+
+ /** 业务方接收端点 */
+ @Column(name = "callback_url", nullable = false, length = 500)
+ private String callbackUrl;
+
+ /** 订阅事件逗号分隔(概览 §8.3 枚举),空=全部 */
+ @Column(name = "event_types", length = 200)
+ private String eventTypes;
+
+ /** 回调签名密钥(HmacSHA256,注册时生成下发) */
+ @Column(name = "secret", nullable = false, length = 64)
+ private String secret;
+
+ /** 0 启用 / 1 停用 */
+ @Column(name = "status", nullable = false)
+ private Integer status = 0;
+}
diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java b/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java
new file mode 100644
index 0000000..871fc5c
--- /dev/null
+++ b/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java
@@ -0,0 +1,67 @@
+package com.yunhai.flowcenter.entity;
+
+import com.yunhai.flowcenter.enums.FlwEnums;
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+import java.time.LocalDateTime;
+
+/**
+ * 流程定义(概览 §4.1 + V2.0 Part D #2/#3 修订)。
+ * flow_guid 绑定"这条流程线"而非某个版本:升版后业务方绑定关系零改动,新发起自动走
+ * 当前已发布最高版本;item_code/form_id 留可空列(M1 不启用,原生模式/表单二期)。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "flw_def",
+ uniqueConstraints = @UniqueConstraint(name = "uk_flw_def_sch_code_ver",
+ columnNames = {"sch_id", "code", "version"}),
+ indexes = @Index(name = "idx_flw_def_guid", columnList = "sch_id, flow_guid, status, version"))
+public class FlwDef extends BaseEntity {
+
+ /** 租户键 */
+ @Column(name = "sch_id", nullable = false)
+ private Long schId;
+
+ /** 对外稳定流程标识:创建草稿时生成(UUID 去横线小写),同一 code 跨版本不变 */
+ @Column(name = "flow_guid", nullable = false, length = 32)
+ private String flowGuid;
+
+ /** 流程编码(本应用内部管理键) */
+ @Column(name = "code", nullable = false, length = 64)
+ private String code;
+
+ /** 流程名 */
+ @Column(name = "name", nullable = false, length = 128)
+ private String name;
+
+ /** 版本,发布后不可改,改定义=升版本 */
+ @Column(name = "version", nullable = false)
+ private Integer version;
+
+ /** 关联 CMS 事项业务键(原生模式用,M1 不启用;与外部模式的 biz_* 互斥) */
+ @Column(name = "item_code", length = 64)
+ private String itemCode;
+
+ /** 关联表单定义(外部托管模式可无表单,M1 恒 null;二期通用表单预留) */
+ @Column(name = "form_id")
+ private Long formId;
+
+ /** §5.1 流程定义 JSON */
+ @Column(name = "content_json", nullable = false, columnDefinition = "LONGTEXT")
+ private String contentJson;
+
+ /** 0 草稿 / 1 已发布 / 2 停用(停用=不可新发起,存量跑完) */
+ @Column(name = "status", nullable = false)
+ private Integer status = FlwEnums.DefStatus.DRAFT.getCode();
+
+ /** 发布时间 */
+ @Column(name = "publish_time")
+ private LocalDateTime publishTime;
+}
diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java
new file mode 100644
index 0000000..5542d37
--- /dev/null
+++ b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java
@@ -0,0 +1,111 @@
+package com.yunhai.flowcenter.entity;
+
+import com.yunhai.flowcenter.enums.FlwEnums;
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+import javax.persistence.Version;
+import java.time.LocalDateTime;
+
+/**
+ * 办件实例(概览 §4.3 + V2.0 Part D):托管模式下业务对象留在业务方,本表只存
+ * biz_title/biz_url/biz_snapshot 快照与关联键;uk(sch_id, biz_type, biz_id) 幂等——
+ * 同一业务对象一条办件,重复代发起返回原办件;驳回/撤回后重提复用同一行(R4 重启语义)。
+ *
+ * version 是乐观锁(推进串行化)兼回调 payload 的 instanceVersion(业务方按它丢弃乱序旧事件),
+ * 由 JPA @Version 在 flush 时自增。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "flw_instance",
+ uniqueConstraints = @UniqueConstraint(name = "uk_flw_instance_biz",
+ columnNames = {"sch_id", "biz_type", "biz_id"}),
+ indexes = {
+ @Index(name = "idx_flw_inst_submitter", columnList = "sch_id, submitter_id, status"),
+ @Index(name = "idx_flw_inst_status_time", columnList = "sch_id, status, submit_time")})
+public class FlwInstance extends BaseEntity {
+
+ /** 租户键 */
+ @Column(name = "sch_id", nullable = false)
+ private Long schId;
+
+ /** 定义快照(发布版本 id;重提重启时刷新为当前已发布版本) */
+ @Column(name = "def_id", nullable = false)
+ private Long defId;
+
+ /** 定义编码快照 */
+ @Column(name = "def_code", nullable = false, length = 64)
+ private String defCode;
+
+ /** 定义版本快照 */
+ @Column(name = "def_version", nullable = false)
+ private Integer defVersion;
+
+ /** 表单提交快照(无表单托管模式为 NULL,透传存业务方 formData) */
+ @Column(name = "form_data_json", columnDefinition = "LONGTEXT")
+ private String formDataJson;
+
+ /** CMS 事项快照(原生模式,M1 不启用恒 null) */
+ @Column(name = "item_code", length = 64)
+ private String itemCode;
+
+ @Column(name = "item_title", length = 200)
+ private String itemTitle;
+
+ /** 业务类型键(外部托管模式,如 svc_item) */
+ @Column(name = "biz_type", length = 32)
+ private String bizType;
+
+ /** 业务主键(如 svc-hall applyId) */
+ @Column(name = "biz_id", length = 64)
+ private String bizId;
+
+ /** 办件标题快照:待办列表/消息/回调 payload 统一携带 */
+ @Column(name = "biz_title", length = 200)
+ private String bizTitle;
+
+ /** 业务方详情页链接:办理页"查看业务详情"跳转入口 */
+ @Column(name = "biz_url", length = 500)
+ private String bizUrl;
+
+ /** 业务摘要 JSON 快照:办理页展示用(只收摘要,不收富文本正文,概览 §十边界) */
+ @Column(name = "biz_snapshot", columnDefinition = "LONGTEXT")
+ private String bizSnapshot;
+
+ /** 提交人 id(外部模式=业务方当前登录人快照,本校校验在入口做) */
+ @Column(name = "submitter_id", nullable = false)
+ private Integer submitterId;
+
+ /** 提交人姓名快照 */
+ @Column(name = "submitter_name", nullable = false, length = 128)
+ private String submitterName;
+
+ /** 10 审批中 / 20 已完成 / 30 已拒绝 / 40 已撤回 */
+ @Column(name = "status", nullable = false)
+ private Integer status = FlwEnums.InstanceStatus.APPROVING.getCode();
+
+ /** 当前活跃节点 id 集合(冗余逗号分隔,列表页免展开) */
+ @Column(name = "current_nodes", length = 200)
+ private String currentNodes;
+
+ @Column(name = "submit_time")
+ private LocalDateTime submitTime;
+
+ @Column(name = "finish_time")
+ private LocalDateTime finishTime;
+
+ /**
+ * 乐观锁版本:推进串行化 + 回调 instanceVersion 单调递增。
+ * 注意不可初始化(=0 会让 Spring Data 判定非新实体走 merge,id 不回填);
+ * insert 时由 Hibernate 写入 0。
+ */
+ @Version
+ @Column(name = "version", nullable = false)
+ private Integer version;
+}
diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwTask.java b/src/main/java/com/yunhai/flowcenter/entity/FlwTask.java
new file mode 100644
index 0000000..6012684
--- /dev/null
+++ b/src/main/java/com/yunhai/flowcenter/entity/FlwTask.java
@@ -0,0 +1,71 @@
+package com.yunhai.flowcenter.entity;
+
+import com.yunhai.flowcenter.enums.FlwEnums;
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import java.time.LocalDateTime;
+
+/**
+ * 审批任务(概览 §4.4):会签 ALL 模式下每办理人一条,全部同意才放行;
+ * 办理抢占靠 UPDATE ... WHERE status=0 的原子语义(服务层用乐观锁 version 兜底并发推进)。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "flw_task",
+ indexes = {
+ @Index(name = "idx_flw_task_inst_status", columnList = "instance_id, status"),
+ @Index(name = "idx_flw_task_assignee", columnList = "assignee_id, status, arrive_time")})
+public class FlwTask extends BaseEntity {
+
+ /** 归属办件 */
+ @Column(name = "instance_id", nullable = false)
+ private Long instanceId;
+
+ /** 租户键(冗余,待办列表主路径免 join) */
+ @Column(name = "sch_id", nullable = false)
+ private Long schId;
+
+ /** 定义节点 id(JSON 里的 node.id) */
+ @Column(name = "node_id", nullable = false, length = 64)
+ private String nodeId;
+
+ /** 节点名快照 */
+ @Column(name = "node_name", length = 128)
+ private String nodeName;
+
+ /** 办理人 id(DEPT_KEY 映射/USERS 直配解析结果) */
+ @Column(name = "assignee_id", nullable = false)
+ private Integer assigneeId;
+
+ /** 办理人姓名快照(免跨服务查名) */
+ @Column(name = "assignee_name", length = 128)
+ private String assigneeName;
+
+ /** 0 待办 / 1 同意 / 2 拒绝 / 3 失效 */
+ @Column(name = "status", nullable = false)
+ private Integer status = FlwEnums.TaskStatus.PENDING.getCode();
+
+ /** 办理意见(reject 必填,纯文本不收 HTML) */
+ @Column(name = "opinion", length = 500)
+ private String opinion;
+
+ @Column(name = "arrive_time", nullable = false)
+ private LocalDateTime arriveTime;
+
+ @Column(name = "handle_time")
+ private LocalDateTime handleTime;
+
+ /** 办理时限(一期自然时间,定义方自行折算工作日——拍板项#4) */
+ @Column(name = "deadline")
+ private LocalDateTime deadline;
+
+ /** 已催办次数 */
+ @Column(name = "urge_count", nullable = false)
+ private Integer urgeCount = 0;
+}
diff --git a/src/main/java/com/yunhai/flowcenter/enums/FlwEnums.java b/src/main/java/com/yunhai/flowcenter/enums/FlwEnums.java
new file mode 100644
index 0000000..1f52ef7
--- /dev/null
+++ b/src/main/java/com/yunhai/flowcenter/enums/FlwEnums.java
@@ -0,0 +1,85 @@
+package com.yunhai.flowcenter.enums;
+
+/**
+ * 流程中心状态枚举(概览 §4.3/§4.4/§4.5/§4.1/§4.9 口径,一期冻结)。
+ */
+public final class FlwEnums {
+
+ private FlwEnums() {
+ }
+
+ /** 办件状态:10 审批中 / 20 已完成 / 30 已拒绝 / 40 已撤回 */
+ public enum InstanceStatus {
+ APPROVING(10), COMPLETED(20), REJECTED(30), WITHDRAWN(40);
+
+ private final int code;
+
+ InstanceStatus(int code) {
+ this.code = code;
+ }
+
+ public int getCode() {
+ return code;
+ }
+ }
+
+ /** 任务状态:0 待办 / 1 同意 / 2 拒绝 / 3 失效(拒绝/撤回/重启重提时的清理目标) */
+ public enum TaskStatus {
+ PENDING(0), APPROVED(1), REJECTED(2), INVALIDATED(3);
+
+ private final int code;
+
+ TaskStatus(int code) {
+ this.code = code;
+ }
+
+ public int getCode() {
+ return code;
+ }
+ }
+
+ /** 并行分支状态:0 进行中 / 1 已到达汇聚点 / 2 已失效 */
+ public enum BranchStatus {
+ RUNNING(0), ARRIVED(1), INVALIDATED(2);
+
+ private final int code;
+
+ BranchStatus(int code) {
+ this.code = code;
+ }
+
+ public int getCode() {
+ return code;
+ }
+ }
+
+ /** 流程定义状态:0 草稿 / 1 已发布 / 2 停用(停用=不可新发起,存量跑完) */
+ public enum DefStatus {
+ DRAFT(0), PUBLISHED(1), DISABLED(2);
+
+ private final int code;
+
+ DefStatus(int code) {
+ this.code = code;
+ }
+
+ public int getCode() {
+ return code;
+ }
+ }
+
+ /** 回调推送日志状态:0 待推/重试中 / 1 成功 / 2 死信 */
+ public enum CallbackLogStatus {
+ PENDING(0), SUCCESS(1), DEAD(2);
+
+ private final int code;
+
+ CallbackLogStatus(int code) {
+ this.code = code;
+ }
+
+ public int getCode() {
+ return code;
+ }
+ }
+}
diff --git a/src/main/java/com/yunhai/flowcenter/model/FlowModels.java b/src/main/java/com/yunhai/flowcenter/model/FlowModels.java
new file mode 100644
index 0000000..0a93751
--- /dev/null
+++ b/src/main/java/com/yunhai/flowcenter/model/FlowModels.java
@@ -0,0 +1,115 @@
+package com.yunhai.flowcenter.model;
+
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 用户面/管理面 VO(/api/flow/**)。
+ */
+public final class FlowModels {
+
+ private FlowModels() {
+ }
+
+ /** 我的办件列表行 */
+ @Data
+ public static class InstanceItem {
+ private Long instanceId;
+ private String bizType;
+ private String bizId;
+ private String bizTitle;
+ private Integer status;
+ private String currentNodes;
+ private String submitterName;
+ private String submitTime;
+ private String finishTime;
+ private String defName;
+ private Integer defVersion;
+ }
+
+ /** 待办/已办列表行 */
+ @Data
+ public static class TaskItem {
+ private Long taskId;
+ private Long instanceId;
+ private String bizTitle;
+ private String bizType;
+ private String bizId;
+ private Integer instanceStatus;
+ private String nodeId;
+ private String nodeName;
+ private String assigneeName;
+ private Integer status;
+ private String opinion;
+ private String arriveTime;
+ private String handleTime;
+ private String deadline;
+ private Integer urgeCount;
+ }
+
+ /** 办件详情(办理页/详情页数据源) */
+ @Data
+ public static class InstanceDetail {
+ private Long instanceId;
+ private Long schId;
+ private String defCode;
+ private Integer defVersion;
+ private String bizType;
+ private String bizId;
+ private String bizTitle;
+ private String bizUrl;
+ /** 业务摘要 JSON(办理页区块渲染;无富文本正文——概览 §十边界) */
+ private String bizSnapshot;
+ private String formDataJson;
+ private Integer status;
+ private String currentNodes;
+ private Integer submitterId;
+ private String submitterName;
+ private String submitTime;
+ private String finishTime;
+ private Integer version;
+ private String nodeSummary;
+ private List