attList(BoaUser authUserWithDept,
+ @RequestParam("siteCode") String siteCode,
+ @RequestParam("id") Integer id) {
+ return itemService.itemAttachments(authUserWithDept, siteCode, id);
+ }
+}
diff --git a/src/main/java/com/yunhai/svchall/controller/callback/FlowCallbackController.java b/src/main/java/com/yunhai/svchall/controller/callback/FlowCallbackController.java
new file mode 100644
index 0000000..09d8081
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/controller/callback/FlowCallbackController.java
@@ -0,0 +1,47 @@
+package com.yunhai.svchall.controller.callback;
+
+import com.yunhai.svchall.service.impl.FlowCallbackService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+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.RestController;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 流程中心回调接收端点(/callback/flow;不在 CAS pattern(/api/*)内、也不在 /oapi/*
+ * (appKey 签名)内——独立走 FlowCallbackVerifyFilter 的 X-Flw-* 四头验签后才到达这里)。
+ *
+ * 返回语义(契约冻结 D2,免对账前提):状态推进成功 / 重复投递(去重命中)/
+ * 协议毒消息 / 乱序丢弃 / 办件不属本应用 → 200(毒消息回 200 防无限重放);
+ * 瞬时失败(DB 异常/锁冲突,{@link FlowCallbackService.TransientCallbackException})/
+ * 意外 RuntimeException → 5xx——flow-center 持续重试直至送达,消费方恢复自动追平
+ * (替代旧"恒 200 + 每日对账"架构,验收报告 §五遗留批次)。
+ */
+@Slf4j
+@RestController
+@RequestMapping("/callback")
+@RequiredArgsConstructor
+public class FlowCallbackController {
+
+ private final FlowCallbackService callbackService;
+
+ @PostMapping("/flow")
+ public ResponseEntity flow(@RequestBody byte[] body) {
+ try {
+ callbackService.process(new String(body, StandardCharsets.UTF_8));
+ return ResponseEntity.ok("{\"ok\":true}");
+ } catch (FlowCallbackService.TransientCallbackException e) {
+ log.error("[svc-hall] 回调瞬时失败(回 5xx 触发流程中心重试): {}", e.getMessage(), e);
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}");
+ } catch (Exception e) {
+ // 意外异常同样按瞬时失败处理(宁可多重试一次,不可丢事件)
+ log.error("[svc-hall] 回调意外异常(回 5xx 触发流程中心重试)", e);
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}");
+ }
+ }
+}
diff --git a/src/main/java/com/yunhai/svchall/controller/oapi/svc/SvcOapiController.java b/src/main/java/com/yunhai/svchall/controller/oapi/svc/SvcOapiController.java
new file mode 100644
index 0000000..ffe9f11
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/controller/oapi/svc/SvcOapiController.java
@@ -0,0 +1,136 @@
+package com.yunhai.svchall.controller.oapi.svc;
+
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import com.yunhai.svchall.model.oapi.OapiModels;
+import com.yunhai.svchall.service.SiteDirectory;
+import com.yunhai.svchall.service.impl.ServiceItemService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.StringUtils;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 门户供数开放接口(/oapi/svc/**,appKey 签名访问,消费方=CMS;V2.0 Part C.2 契约冻结件)。
+ * 错误纪律:未签名 401(RequireAppSignFilter);缺参/站点或事项不存在/未发布 →
+ * HTTP 200 + 信封 code=2(BizException);detail 支持 itemId/itemCode 双键。
+ * 站点解析 fail-closed:站点目录不可达按"站点不存在"报 code=2,不降级全局查询。
+ */
+@Slf4j
+@RestController
+@RequestMapping("/oapi/svc")
+@RequiredArgsConstructor
+public class SvcOapiController {
+
+ private final ServiceItemService itemService;
+ private final SiteDirectory siteDirectory;
+
+ /** 服务中心列表(分页 + 三维筛选 + 关键词;URL 由 CMS 生成,接口不返回 url) */
+ @GetMapping("/items")
+ public OapiModels.ListData items(@RequestParam(value = "siteCode", required = false) String siteCode,
+ @RequestParam(value = "objectKey", required = false) String objectKey,
+ @RequestParam(value = "categoryKey", required = false) String categoryKey,
+ @RequestParam(value = "deptGroupId", required = false) Integer deptGroupId,
+ @RequestParam(value = "keyword", required = false) String keyword,
+ @RequestParam(value = "page", required = false) Integer page,
+ @RequestParam(value = "pageSize", required = false) Integer pageSize) {
+ SiteDirectory.SiteInfo site = requireSite(siteCode);
+ return itemService.oapiItems(site.getSchId(), site.getSiteCode(), emptyToNull(objectKey),
+ emptyToNull(categoryKey), deptGroupId, emptyToNull(keyword), page, pageSize);
+ }
+
+ /** 三组筛选项 + 计数(已发布口径) */
+ @GetMapping("/filters")
+ public OapiModels.Filters filters(@RequestParam(value = "siteCode", required = false) String siteCode) {
+ SiteDirectory.SiteInfo site = requireSite(siteCode);
+ return itemService.oapiFilters(site.getSchId(), site.getSiteCode());
+ }
+
+ /** 指南页详情(itemId 或 itemCode 二选一;三段 HTML/封面/附件已绝对化;含关联事项与 applyUrl) */
+ @GetMapping("/item/detail")
+ public OapiModels.Detail detail(@RequestParam(value = "siteCode", required = false) String siteCode,
+ @RequestParam(value = "itemId", required = false) Integer itemId,
+ @RequestParam(value = "itemCode", required = false) String itemCode) {
+ SiteDirectory.SiteInfo site = requireSite(siteCode);
+ if (itemId == null && !StringUtils.hasText(itemCode)) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "itemId/itemCode 不得同时为空");
+ }
+ OapiModels.Detail detail = itemService.oapiDetail(site.getSchId(), site.getSiteCode(),
+ itemId, emptyToNull(itemCode));
+ if (detail == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "事项不存在或未发布");
+ }
+ return detail;
+ }
+
+ /** 浏览量累加(CMS fire-and-forget;支持 itemId 直取;找不到事项静默忽略,保持信封 code=0) */
+ @PostMapping("/item/view")
+ public void view(@RequestParam(value = "siteCode", required = false) String siteCode,
+ @RequestParam(value = "itemCode", required = false) String itemCode,
+ @RequestParam(value = "itemId", required = false) Integer itemId) {
+ if (itemId != null) {
+ itemService.countViewByItemId(itemId);
+ return;
+ }
+ SiteDirectory.SiteInfo site = requireSite(siteCode);
+ if (!StringUtils.hasText(itemCode)) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "itemCode/itemId 不得同时为空");
+ }
+ itemService.countView(site.getSchId(), site.getSiteCode(), itemCode.trim());
+ }
+
+ /** 指南附件(按 itemId 直取;fileUrl 已绝对化) */
+ @GetMapping("/item/attachments")
+ public java.util.List attachments(
+ @RequestParam(value = "itemId", required = false) Integer itemId) {
+ if (itemId == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "itemId 不得为空");
+ }
+ return itemService.attachmentsByItemId(itemId);
+ }
+
+ /** 关联事项(按 itemId 直取:同类别已发布 Top N,排除自身) */
+ @GetMapping("/item/related")
+ public java.util.List related(
+ @RequestParam(value = "itemId", required = false) Integer itemId,
+ @RequestParam(value = "limit", required = false) Integer limit) {
+ if (itemId == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "itemId 不得为空");
+ }
+ return itemService.relatedByItemId(itemId, limit);
+ }
+
+ /** 跨服务要点快照(docs/17 §1.1 契约平移;原消费方流程中心已取消,保留为对外标准口) */
+ @GetMapping("/item/snapshot")
+ public OapiModels.Snapshot snapshot(@RequestParam(value = "siteCode", required = false) String siteCode,
+ @RequestParam(value = "itemCode", required = false) String itemCode) {
+ if (!StringUtils.hasText(siteCode) || !StringUtils.hasText(itemCode)) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "siteCode/itemCode 不得为空");
+ }
+ OapiModels.Snapshot vo = itemService.oapiSnapshot(siteCode.trim(), itemCode.trim());
+ if (vo == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "事项不存在或未发布");
+ }
+ return vo;
+ }
+
+ /** 站点解析 fail-closed:不可达或不存在一律 code=2(门户侧不应渲染未知站点) */
+ private SiteDirectory.SiteInfo requireSite(String siteCode) {
+ if (!StringUtils.hasText(siteCode)) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "siteCode 不得为空");
+ }
+ SiteDirectory.SiteInfo site = siteDirectory.resolveSite(siteCode.trim());
+ if (site == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "站点不存在或站点服务不可达:" + siteCode);
+ }
+ return site;
+ }
+
+ private static String emptyToNull(String value) {
+ return StringUtils.hasText(value) ? value.trim() : null;
+ }
+}
diff --git a/src/main/java/com/yunhai/svchall/entity/BaseEntity.java b/src/main/java/com/yunhai/svchall/entity/BaseEntity.java
new file mode 100644
index 0000000..912df32
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/entity/BaseEntity.java
@@ -0,0 +1,43 @@
+package com.yunhai.svchall.entity;
+
+import com.yunhai.svchall.common.SvcHallAuditListener;
+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;
+
+/**
+ * 所有实体的公共基类:自增主键 + 审计四件套(SvcHallAuditListener 自动填充,CmsAuditListener 同款)。
+ * 多租户键 sch_id 不在基类——各业务实体自带列(Notice 先例,语义不同不混入)。
+ */
+@MappedSuperclass
+@Getter
+@Setter
+@EntityListeners(SvcHallAuditListener.class)
+public abstract class BaseEntity implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Integer 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/svchall/entity/ServiceApply.java b/src/main/java/com/yunhai/svchall/entity/ServiceApply.java
new file mode 100644
index 0000000..dcd6282
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/entity/ServiceApply.java
@@ -0,0 +1,104 @@
+package com.yunhai.svchall.entity;
+
+import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
+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;
+
+/**
+ * 办件申请记录(V2.0 Part B.2.4)——办事大厅侧业务对象,对应 Notice 首接的 notice 行加列形态。
+ * 与流程中心的隔离约定:本表保存提交快照(事项要点/表单数据/附件清单),事项后续改名/下架不影响
+ * 在途与历史办件(跨库不 join);审批唯一真相在流程中心 flw_instance,本表 flow_status 为
+ * 回调驱动的镜像,按 lastInstanceVersion 单调丢弃乱序旧事件,每日对账兜底。
+ *
+ * 幂等键:flow-center 侧 uk(sch_id, biz_type, biz_id),本表 bizType 固定 "svc_item"、bizId=id——
+ * 重试/重提复用同一行即自动幂等。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "service_apply",
+ indexes = {
+ @Index(name = "idx_svc_apply_submitter", columnList = "sch_id, submitter_id, flow_status"),
+ @Index(name = "idx_svc_apply_site", columnList = "sch_id, site_code, flow_status"),
+ @Index(name = "idx_svc_apply_instance", columnList = "flow_instance_id"),
+ @Index(name = "idx_svc_apply_item", columnList = "sch_id, item_id")})
+public class ServiceApply extends BaseEntity {
+
+ /** 租户键 */
+ @Column(name = "sch_id", nullable = false)
+ private Long schId;
+
+ /** 站点编码(冗余,管理端筛选) */
+ @Column(name = "site_code", nullable = false, length = 64)
+ private String siteCode;
+
+ /** 归属事项(逻辑外键 → service_item.id) */
+ @Column(name = "item_id", nullable = false)
+ private Integer itemId;
+
+ /** 事项编码(冗余) */
+ @Column(name = "item_code", nullable = false, length = 64)
+ private String itemCode;
+
+ /** 事项标题快照(发起时) */
+ @Column(name = "item_title", nullable = false, length = 200)
+ private String itemTitle;
+
+ /** 事项要点快照 JSON(= biz_snapshot 内容源:title/summary/deptName/handleTimeLimit/contactPhone/attachmentNames) */
+ @Column(name = "item_snapshot_json", columnDefinition = "LONGTEXT")
+ private String itemSnapshotJson;
+
+ /** 提交时表单版本(form_code 的已发布版本号;无表单事项为 null) */
+ @Column(name = "form_version")
+ private Integer formVersion;
+
+ /** 表单提交快照 JSON({key:value};无表单模式 NULL) */
+ @Column(name = "form_data_json", columnDefinition = "LONGTEXT")
+ private String formDataJson;
+
+ /** 申请级材料清单 JSON(无表单事项的附件;[{fieldKey,fileUrl,fileName,fileSize,mimeType}]) */
+ @Column(name = "attachments_json", columnDefinition = "LONGTEXT")
+ private String attachmentsJson;
+
+ /** 提交人 id(CAS 当前登录人快照,传给 flow-center 作 submitterId) */
+ @Column(name = "submitter_id", nullable = false)
+ private Integer submitterId;
+
+ /** 提交人姓名快照 */
+ @Column(name = "submitter_name", nullable = false, length = 128)
+ private String submitterName;
+
+ /** 提交时的流程绑定 */
+ @Column(name = "flow_guid", length = 32)
+ private String flowGuid;
+
+ /** 流程中心办件实例 id(代发起成功后回填) */
+ @Column(name = "flow_instance_id")
+ private Long flowInstanceId;
+
+ /** 镜像状态(ApplyFlowStatusEnum:0 待提交/10 审批中/20 完成/30 拒绝/40 撤回) */
+ @Column(name = "flow_status", nullable = false)
+ private Integer flowStatus = ApplyFlowStatusEnum.PENDING.getCode();
+
+ /** 已消费的最大回调 instanceVersion(乱序防护:小于等于它的回调丢弃) */
+ @Column(name = "last_instance_version")
+ private Integer lastInstanceVersion = 0;
+
+ /** 回调明细 JSON(终态事件 result/opinion、拒绝节点等,详情页展示) */
+ @Column(name = "flow_result_json", columnDefinition = "LONGTEXT")
+ private String flowResultJson;
+
+ /** 提交时间(首次代发起成功时间) */
+ @Column(name = "submit_time")
+ private LocalDateTime submitTime;
+
+ /** 终态时间 */
+ @Column(name = "finish_time")
+ private LocalDateTime finishTime;
+}
diff --git a/src/main/java/com/yunhai/svchall/entity/ServiceAttachment.java b/src/main/java/com/yunhai/svchall/entity/ServiceAttachment.java
new file mode 100644
index 0000000..5558e90
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/entity/ServiceAttachment.java
@@ -0,0 +1,56 @@
+package com.yunhai.svchall.entity;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+
+/**
+ * 附件(Notice up_file + 业务绑定形态,V2.0 Part B.2.5)。
+ * 文件本体走 FILE_PATH 上传通道(FileUtil),本表存相对 URL 与可选业务绑定:
+ * topic_type=svc_item_guide(指南附件,topic_id=itemId)/ svc_apply_field(办件材料,topic_id=applyId);
+ * 封面与富文本内嵌图只存 URL 不建行(无"半绑定"归属问题——docs/19 平移教训 1 的取简解)。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "service_attachment",
+ indexes = {
+ @Index(name = "idx_svc_att_topic", columnList = "sch_id, topic_type, topic_id")})
+public class ServiceAttachment extends BaseEntity {
+
+ /** 租户键 */
+ @Column(name = "sch_id", nullable = false)
+ private Long schId;
+
+ /** 业务绑定类型(空=未绑定,仅上传) */
+ @Column(name = "topic_type", length = 32)
+ private String topicType;
+
+ /** 业务绑定 id(事项 id 或办件 id) */
+ @Column(name = "topic_id")
+ private Integer topicId;
+
+ /** 表单字段 key(svc_apply_field 场景:材料归属的表单字段) */
+ @Column(name = "field_key", length = 64)
+ private String fieldKey;
+
+ /** 展示文件名 */
+ @Column(name = "file_name", nullable = false, length = 255)
+ private String fileName;
+
+ /** 相对 URL(/upload/{type}/{uuid}.{ext};出参经 FileUtil.buildUrl 拼完整地址) */
+ @Column(name = "file_url", nullable = false, length = 255)
+ private String fileUrl;
+
+ /** 字节大小 */
+ @Column(name = "file_size")
+ private Long fileSize;
+
+ /** MIME 类型 */
+ @Column(name = "mime_type", length = 128)
+ private String mimeType;
+}
diff --git a/src/main/java/com/yunhai/svchall/entity/ServiceDict.java b/src/main/java/com/yunhai/svchall/entity/ServiceDict.java
new file mode 100644
index 0000000..14e2dc9
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/entity/ServiceDict.java
@@ -0,0 +1,56 @@
+package com.yunhai.svchall.entity;
+
+import com.yunhai.svchall.enums.ServiceDictTypeEnum;
+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;
+
+/**
+ * 事项字典表(承袭 CMS cms_service_dict):dict_key 为稳定键——被事项引用后不可改,只允许改名。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "service_dict",
+ uniqueConstraints = @UniqueConstraint(name = "uk_svc_dict",
+ columnNames = {"sch_id", "site_code", "dict_type", "dict_key"}),
+ indexes = @Index(name = "idx_svc_dict_list", columnList = "sch_id, site_code, dict_type, status, sort_no"))
+public class ServiceDict extends BaseEntity {
+
+ /** 租户键 */
+ @Column(name = "sch_id", nullable = false)
+ private Long schId;
+
+ /** 所属站点编码 */
+ @Column(name = "site_code", nullable = false, length = 64)
+ private String siteCode;
+
+ /** object=服务对象 / category=事项类别(ServiceDictTypeEnum 收口) */
+ @Column(name = "dict_type", nullable = false, length = 32)
+ private String dictType;
+
+ /** 稳定键(站内同类型唯一;被事项引用后不可改) */
+ @Column(name = "dict_key", nullable = false, length = 64)
+ private String dictKey;
+
+ /** 展示名 */
+ @Column(name = "dict_name", nullable = false, length = 128)
+ private String dictName;
+
+ /** 排序(升序) */
+ @Column(name = "sort_no", nullable = false)
+ private Integer sortNo = 0;
+
+ /** 0 停用 / 1 启用(停用后不出现在筛选与选择器,存量引用仍可渲染) */
+ @Column(name = "status", nullable = false)
+ private Integer status = 1;
+
+ /** 预留扩展 */
+ @Column(name = "ext_json", columnDefinition = "JSON")
+ private String extJson;
+}
diff --git a/src/main/java/com/yunhai/svchall/entity/ServiceFlowEvent.java b/src/main/java/com/yunhai/svchall/entity/ServiceFlowEvent.java
new file mode 100644
index 0000000..9bc0133
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/entity/ServiceFlowEvent.java
@@ -0,0 +1,49 @@
+package com.yunhai.svchall.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;
+import java.time.LocalDateTime;
+
+/**
+ * 回调事件接收日志(eventId 去重表,Notice 方案 §8.6"eventId 去重表"选型;
+ * 本应用无 Redis,落库去重 + 事件留档排查)。处理幂等另有 service_apply.last_instance_version
+ * 单调兜底,本表是第一道闸:同 eventId 重投直接 200 跳过。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "service_flow_event",
+ uniqueConstraints = @UniqueConstraint(name = "uk_svc_flow_event", columnNames = "event_id"),
+ indexes = @Index(name = "idx_svc_flow_event_instance", columnList = "instance_id"))
+public class ServiceFlowEvent extends BaseEntity {
+
+ /** 事件唯一键(32 位 uuid,重试重放不变) */
+ @Column(name = "event_id", nullable = false, length = 32)
+ private String eventId;
+
+ /** 事件类型(flow.completed 等) */
+ @Column(name = "event", nullable = false, length = 32)
+ private String event;
+
+ /** 租户键(payload.schId) */
+ @Column(name = "sch_id", nullable = false)
+ private Long schId;
+
+ /** 流程中心办件实例 */
+ @Column(name = "instance_id", nullable = false)
+ private Long instanceId;
+
+ /** 接收时间 */
+ @Column(name = "receive_time", nullable = false)
+ private LocalDateTime receiveTime;
+
+ /** 完整推送体(排障留档) */
+ @Column(name = "payload_json", columnDefinition = "LONGTEXT")
+ private String payloadJson;
+}
diff --git a/src/main/java/com/yunhai/svchall/entity/ServiceFlowReg.java b/src/main/java/com/yunhai/svchall/entity/ServiceFlowReg.java
new file mode 100644
index 0000000..8920397
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/entity/ServiceFlowReg.java
@@ -0,0 +1,49 @@
+package com.yunhai.svchall.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;
+
+/**
+ * 流程回调注册(V2.0 Part B.2.6):管理端在事项绑定 flow_guid 时经
+ * POST /oapi/flow/callback/reg 向流程中心注册并保存下发的 secret(回调签名密钥,
+ * 与应用级 appSecret 分离、粒度到"应用×流程"——流程中心概览 §4.8)。
+ * 同 flow_guid 多事项共享一条注册。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "service_flow_reg",
+ uniqueConstraints = @UniqueConstraint(name = "uk_svc_flow_reg", columnNames = {"sch_id", "flow_guid"}),
+ indexes = @Index(name = "idx_svc_flow_reg_guid", columnList = "flow_guid"))
+public class ServiceFlowReg extends BaseEntity {
+
+ /** 租户键 */
+ @Column(name = "sch_id", nullable = false)
+ private Long schId;
+
+ /** 订阅的流程 */
+ @Column(name = "flow_guid", nullable = false, length = 32)
+ private String flowGuid;
+
+ /** 回调接收端点(= 本应用对外可达地址 + /callback/flow) */
+ @Column(name = "callback_url", nullable = false, length = 500)
+ private String callbackUrl;
+
+ /** 订阅事件逗号分隔(空=全部;flow.submitted/task.created/task.handled/completed/rejected/withdrawn) */
+ @Column(name = "event_types", length = 200)
+ private String eventTypes;
+
+ /** reg 接口下发的回调签名密钥(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/svchall/entity/ServiceForm.java b/src/main/java/com/yunhai/svchall/entity/ServiceForm.java
new file mode 100644
index 0000000..6d78757
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/entity/ServiceForm.java
@@ -0,0 +1,56 @@
+package com.yunhai.svchall.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;
+import java.time.LocalDateTime;
+
+/**
+ * 自定义表单定义(V2.0 Part B.2.3,自流程中心 flw_form 平移——表单能力归办事大厅)。
+ * 行不可变原则:已发布版本不可改,编辑已发布表单 = 克隆出新草稿版本(version+1)。
+ * 事项经 form_code 绑定,发起时取**最新已发布版本**并快照 form_version。
+ * schema_json 契约见 FormSchemaValidator(14 类命名冻结、声明式校验白名单、groups 分组)。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "service_form",
+ uniqueConstraints = @UniqueConstraint(name = "uk_svc_form", columnNames = {"sch_id", "code", "version"}),
+ indexes = {
+ @Index(name = "idx_svc_form_code", columnList = "sch_id, code, status, version"),
+ @Index(name = "idx_svc_form_status", columnList = "sch_id, status")})
+public class ServiceForm extends BaseEntity {
+
+ /** 租户键(表单为 sch_id 级:校内跨站点共享) */
+ @Column(name = "sch_id", nullable = false)
+ private Long schId;
+
+ /** 表单编码(稳定键,事项经它绑定) */
+ @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 = 1;
+
+ /** 0 草稿 / 1 已发布 / 2 停用 */
+ @Column(name = "status", nullable = false)
+ private Integer status = 0;
+
+ /** 表单结构 JSON({"groups":[{title,fields:[…]}]};M1 仅放行 7 类控件) */
+ @Column(name = "schema_json", nullable = false, columnDefinition = "LONGTEXT")
+ private String schemaJson;
+
+ /** 发布时间 */
+ @Column(name = "publish_time")
+ private LocalDateTime publishTime;
+}
diff --git a/src/main/java/com/yunhai/svchall/entity/ServiceItem.java b/src/main/java/com/yunhai/svchall/entity/ServiceItem.java
new file mode 100644
index 0000000..fcf440f
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/entity/ServiceItem.java
@@ -0,0 +1,171 @@
+package com.yunhai.svchall.entity;
+
+import com.yunhai.svchall.enums.ServiceItemStatusEnum;
+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;
+
+/**
+ * 事项主表(承袭 CMS cms_service_item 全字段,V2.0 Part B.2.1)。
+ * 变化点:site_id → site_code(跨应用后 CMS 自增 id 无意义,sch_id 定租户);
+ * 新增 form_code(绑定自定义表单)/ flow_guid(绑定流程中心流程,NULL=纯指南事项);
+ * apply_url 由 ext_json 提升为独立列。
+ *
+ *
主键 id 语义(V2.0 C.2"id 语义"):CMS 迁移时**原值 identity insert**——CMS 门户指南页
+ * 路由 /sv/{itemId} 直接携带本表主键;本应用自增起点须大于迁移 max(id)。
+ *
+ *
索引声明口径:带排序方向的门户列表索引(sch_id, site_code, status, sort_no ASC,
+ * publish_time DESC, id DESC)含 DESC 列,Hibernate {@code @Index} 无法表达,由 sql/ 升级脚本补建。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "service_item",
+ uniqueConstraints = @UniqueConstraint(name = "uk_svc_item_code",
+ columnNames = {"sch_id", "site_code", "item_code"}),
+ indexes = {
+ @Index(name = "idx_svc_item_list", columnList = "sch_id, site_code, status, online_flag, sort_no"),
+ @Index(name = "idx_svc_item_obj", columnList = "sch_id, site_code, status, object_key"),
+ @Index(name = "idx_svc_item_cat", columnList = "sch_id, site_code, status, category_key"),
+ @Index(name = "idx_svc_item_dept", columnList = "sch_id, site_code, status, dept_group_id"),
+ @Index(name = "idx_svc_item_flow", columnList = "flow_guid"),
+ @Index(name = "idx_svc_item_form", columnList = "form_code")})
+public class ServiceItem extends BaseEntity {
+
+ /** 租户键(= UserBase 学校 id,承袭 CMS site.sch_id 口径) */
+ @Column(name = "sch_id", nullable = false)
+ private Long schId;
+
+ /** 站点编码(CMS cms_site.code 引用;办事大厅不建 site 表,站点目录经 CMS oapi 解析) */
+ @Column(name = "site_code", nullable = false, length = 64)
+ private String siteCode;
+
+ /** 事项编码,站点内唯一;跨服务键的一半(另一半是 siteCode) */
+ @Column(name = "item_code", nullable = false, length = 64)
+ private String itemCode;
+
+ /** 事项名称 */
+ @Column(name = "title", nullable = false, length = 200)
+ private String title;
+
+ /** 一句话简介(列表卡片副标题、办件 biz_snapshot 摘要) */
+ @Column(name = "summary", length = 500)
+ private String summary;
+
+ /** 服务对象,指向 service_dict(dict_type=object) 的 dict_key */
+ @Column(name = "object_key", length = 64)
+ private String objectKey;
+
+ /** 事项类别,指向 service_dict(dict_type=category) 的 dict_key */
+ @Column(name = "category_key", length = 64)
+ private String categoryKey;
+
+ /** 服务部门 = UserBase 组树节点 id 快照(不建字典表) */
+ @Column(name = "dept_group_id")
+ private Integer deptGroupId;
+
+ /** 部门名快照(列表/指南免跨服务查名;改名不自动同步,靠 syncDeptName) */
+ @Column(name = "dept_name", length = 128)
+ private String deptName;
+
+ /** 办理方式:1 线上 / 2 线下 / 3 线上线上线下(HandleModeEnum) */
+ @Column(name = "handle_mode", nullable = false)
+ private Integer handleMode = 1;
+
+ /** 是否可在线办理:1 是 / 0 否。一致性校验:=1 时 flowGuid 必须非空(V2.0 B.2.1) */
+ @Column(name = "online_flag", nullable = false)
+ private Integer onlineFlag = 1;
+
+ /** 办理地点 */
+ @Column(name = "handle_place", length = 200)
+ private String handlePlace;
+
+ /** 办理时限(文本,如"3 个工作日") */
+ @Column(name = "handle_time_limit", length = 64)
+ private String handleTimeLimit;
+
+ /** 承诺时限(文本) */
+ @Column(name = "promise_time_limit", length = 64)
+ private String promiseTimeLimit;
+
+ /** 咨询电话 */
+ @Column(name = "contact_phone", length = 64)
+ private String contactPhone;
+
+ /** 监督投诉电话 */
+ @Column(name = "supervise_phone", length = 64)
+ private String supervisePhone;
+
+ /** 办理流程(保存时 jsoup 白名单清洗;库内相对 /upload/ 路径,oapi 出参绝对化) */
+ @Column(name = "process_html", columnDefinition = "LONGTEXT")
+ private String processHtml;
+
+ /** 所需材料(同上) */
+ @Column(name = "material_html", columnDefinition = "LONGTEXT")
+ private String materialHtml;
+
+ /** 注意事项(同上) */
+ @Column(name = "notice_html", columnDefinition = "LONGTEXT")
+ private String noticeHtml;
+
+ /**
+ * 关联 CMS 栏目:常见问题(**不透明引用** → cms_category.id,本应用不校验存在性,
+ * CMS detail 转调时解析为 URL;V2.0 Part C.2/R2)
+ */
+ @Column(name = "faq_category_id")
+ private Integer faqCategoryId;
+
+ /** 关联 CMS 栏目:公告信息 */
+ @Column(name = "notice_category_id")
+ private Integer noticeCategoryId;
+
+ /** 关联 CMS 栏目:文件规定 */
+ @Column(name = "rule_category_id")
+ private Integer ruleCategoryId;
+
+ /** 事项封面(库内相对 /upload/ 路径,出参绝对化) */
+ @Column(name = "cover_image", length = 500)
+ private String coverImage;
+
+ /** 绑定自定义表单 code(service_form.code;NULL=发起无需表单);发起时取最新已发布版本 */
+ @Column(name = "form_code", length = 64)
+ private String formCode;
+
+ /** 绑定流程中心流程(flw_def.flow_guid;NULL=纯指南事项不可在线办理) */
+ @Column(name = "flow_guid", length = 32)
+ private String flowGuid;
+
+ /** "我要办理"手填外链(http(s)://;三态优先级最高,承袭 CMS ext_json.apply_url 语义) */
+ @Column(name = "apply_url", length = 500)
+ private String applyUrl;
+
+ /** 管理端自定义排序(列表默认排序第一优先级,升序) */
+ @Column(name = "sort_no", nullable = false)
+ private Integer sortNo = 0;
+
+ /** 状态:0 草稿 / 1 已发布 / 2 已下架(ServiceItemStatusEnum) */
+ @Column(name = "status", nullable = false)
+ private Integer status = ServiceItemStatusEnum.DRAFT.getCode();
+
+ /** 发布时间 */
+ @Column(name = "publish_time")
+ private LocalDateTime publishTime;
+
+ /** 下架时间 */
+ @Column(name = "offline_time")
+ private LocalDateTime offlineTime;
+
+ /** 指南页浏览量(CMS 经 /oapi/svc/item/view 异步累加,不承诺精确) */
+ @Column(name = "view_count", nullable = false)
+ private Integer viewCount = 0;
+
+ /** 扩展字段 JSON(预留,一期不写) */
+ @Column(name = "ext_json", columnDefinition = "JSON")
+ private String extJson;
+}
diff --git a/src/main/java/com/yunhai/svchall/enums/ApplyFlowStatusEnum.java b/src/main/java/com/yunhai/svchall/enums/ApplyFlowStatusEnum.java
new file mode 100644
index 0000000..55fb65f
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/enums/ApplyFlowStatusEnum.java
@@ -0,0 +1,47 @@
+package com.yunhai.svchall.enums;
+
+/**
+ * 办件在办事大厅侧的镜像状态(V2.0 Part B.2.4)。
+ * 由流程中心回调驱动**单向推进**(按 payload.instanceVersion 丢弃乱序旧事件);
+ * 数值对齐 flw_instance.status:10 审批中 / 20 已完成 / 30 已拒绝 / 40 已撤回,
+ * 另有本地态 0=待提交(已落库但代发起尚未成功,可重试)。
+ */
+public enum ApplyFlowStatusEnum {
+
+ /** 待提交(本地落库,FlowClient 代发起未成功;重试复用同一 bizId) */
+ PENDING(0),
+ /** 审批中 */
+ APPROVING(10),
+ /** 已完成 */
+ COMPLETED(20),
+ /** 已拒绝 */
+ REJECTED(30),
+ /** 已撤回 */
+ WITHDRAWN(40);
+
+ private final int code;
+
+ ApplyFlowStatusEnum(int code) {
+ this.code = code;
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ public static ApplyFlowStatusEnum byCode(Integer code) {
+ if (code != null) {
+ for (ApplyFlowStatusEnum e : values()) {
+ if (e.code == code) {
+ return e;
+ }
+ }
+ }
+ return PENDING;
+ }
+
+ /** 是否为终态(终态只接受例外性回退——对账以 flow-center 为准修正) */
+ public boolean terminal() {
+ return this == COMPLETED || this == REJECTED || this == WITHDRAWN;
+ }
+}
diff --git a/src/main/java/com/yunhai/svchall/enums/HandleModeEnum.java b/src/main/java/com/yunhai/svchall/enums/HandleModeEnum.java
new file mode 100644
index 0000000..650c037
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/enums/HandleModeEnum.java
@@ -0,0 +1,37 @@
+package com.yunhai.svchall.enums;
+
+/**
+ * 办理方式(承袭 CMS HandleModeEnum)。与 onlineFlag 语义不同、允许不一致:
+ * 如「线上线下」但本期未开通在线入口时 online_flag=0。
+ */
+public enum HandleModeEnum {
+
+ /** 线上办理 */
+ ONLINE(1),
+ /** 线下办理 */
+ OFFLINE(2),
+ /** 线上线下均可 */
+ BOTH(3);
+
+ private final int code;
+
+ HandleModeEnum(int code) {
+ this.code = code;
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ public static boolean isValid(Integer code) {
+ if (code == null) {
+ return false;
+ }
+ for (HandleModeEnum e : values()) {
+ if (e.code == code) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/com/yunhai/svchall/enums/ServiceDictTypeEnum.java b/src/main/java/com/yunhai/svchall/enums/ServiceDictTypeEnum.java
new file mode 100644
index 0000000..46df0ff
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/enums/ServiceDictTypeEnum.java
@@ -0,0 +1,34 @@
+package com.yunhai.svchall.enums;
+
+/**
+ * 事项字典类型(承袭 CMS ServiceDictTypeEnum):object=服务对象、category=事项类别。
+ */
+public enum ServiceDictTypeEnum {
+
+ /** 服务对象 */
+ OBJECT("object"),
+ /** 事项类别 */
+ CATEGORY("category");
+
+ private final String key;
+
+ ServiceDictTypeEnum(String key) {
+ this.key = key;
+ }
+
+ public String getKey() {
+ return key;
+ }
+
+ /** 非法值(含 null)返回 null,由调用方决定报错口径 */
+ public static ServiceDictTypeEnum byKey(String key) {
+ if (key != null) {
+ for (ServiceDictTypeEnum e : values()) {
+ if (e.key.equals(key)) {
+ return e;
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/yunhai/svchall/enums/ServiceItemStatusEnum.java b/src/main/java/com/yunhai/svchall/enums/ServiceItemStatusEnum.java
new file mode 100644
index 0000000..193119d
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/enums/ServiceItemStatusEnum.java
@@ -0,0 +1,36 @@
+package com.yunhai.svchall.enums;
+
+/**
+ * 事项状态(承袭 CMS ServiceItemStatusEnum):草稿→发布→下架;
+ * 门户可见性:仅已发布。
+ */
+public enum ServiceItemStatusEnum {
+
+ /** 草稿 */
+ DRAFT(0),
+ /** 已发布 */
+ PUBLISHED(1),
+ /** 已下架 */
+ OFFLINE(2);
+
+ private final int code;
+
+ ServiceItemStatusEnum(int code) {
+ this.code = code;
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ public static ServiceItemStatusEnum byCode(Integer code) {
+ if (code != null) {
+ for (ServiceItemStatusEnum e : values()) {
+ if (e.code == code) {
+ return e;
+ }
+ }
+ }
+ return DRAFT;
+ }
+}
diff --git a/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java b/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java
new file mode 100644
index 0000000..20d94f8
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java
@@ -0,0 +1,101 @@
+package com.yunhai.svchall.job;
+
+import com.yunhai.svchall.entity.ServiceApply;
+import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
+import com.yunhai.svchall.repository.ServiceApplyRepository;
+import com.yunhai.svchall.service.FlowClient;
+import com.yunhai.svchall.service.impl.FlowCallbackService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 对账兜底 Job(降级为应急闸,V2.0 Part B.4#5 → 契约冻结 v1.4):
+ * 升级后 flow-center 回调为持续重试(1m→5m→30m→2h→6h→60m 封顶)+ retryCount=6 挂起并
+ * UserMsg 告警恰一次 + 死信管理端手工处理——消费方免每日对账(本应用 5xx 语义已对齐),
+ * 故本 Job 默认停用(svchall.flow.reconcile-enabled=false);仅在大面积漂移事故应急时置 true
+ * 手工开闸。开启时语义不变:拉在途办件(审批中)→ 按 biz 键查 flow-center 真实状态 →
+ * 漂移以 flow-center 为准修正(对账直改状态,不写 service_flow_event 去重表与结果明细——
+ * 与回调链路的差异,仅应急用不混淆镜像口径)。
+ */
+@Slf4j
+@Component
+public class FlowReconcileJob {
+
+ private final ServiceApplyRepository applyRepository;
+ private final FlowClient flowClient;
+ private final boolean reconcileEnabled;
+
+ public FlowReconcileJob(ServiceApplyRepository applyRepository, FlowClient flowClient,
+ @Value("${svchall.flow.reconcile-enabled:false}") boolean reconcileEnabled) {
+ this.applyRepository = applyRepository;
+ this.flowClient = flowClient;
+ this.reconcileEnabled = reconcileEnabled;
+ }
+
+ @Scheduled(cron = "0 0 4 * * ?")
+ public void reconcile() {
+ if (!reconcileEnabled) {
+ return; // 默认停用:免对账契约下推拉主链路由回调持续重试承担
+ }
+ if (!flowClient.enabled()) {
+ return;
+ }
+ // 全租户口径:审批中的在途办件(分页上限 500,量级评估:全校审批 << 千/周)
+ List pending = applyRepository.findByFlowStatus(ApplyFlowStatusEnum.APPROVING.getCode(),
+ org.springframework.data.domain.PageRequest.of(0, 500));
+ int fixed = 0;
+ for (ServiceApply apply : pending) {
+ if (apply.getFlowInstanceId() == null) {
+ continue;
+ }
+ try {
+ Map remote = flowClient.getInstanceByBiz(
+ apply.getSchId(), FlowCallbackService.BIZ_TYPE, String.valueOf(apply.getId()));
+ if (remote == null) {
+ continue;
+ }
+ Integer remoteStatus = toInt(remote.get("status"));
+ Integer remoteVersion = toInt(remote.get("version"));
+ if (remoteStatus != null && !remoteStatus.equals(apply.getFlowStatus())
+ && (remoteVersion == null || remoteVersion > apply.getLastInstanceVersion())) {
+ apply.setFlowStatus(remoteStatus);
+ if (remoteVersion != null) {
+ apply.setLastInstanceVersion(remoteVersion);
+ }
+ if (remoteStatus.equals(ApplyFlowStatusEnum.COMPLETED.getCode())
+ || remoteStatus.equals(ApplyFlowStatusEnum.REJECTED.getCode())
+ || remoteStatus.equals(ApplyFlowStatusEnum.WITHDRAWN.getCode())) {
+ apply.setFinishTime(LocalDateTime.now());
+ }
+ applyRepository.save(apply);
+ fixed++;
+ }
+ } catch (Exception e) {
+ log.warn("[svc-hall] 对账跳过 applyId={}:{}", apply.getId(), e.toString());
+ }
+ }
+ if (fixed > 0 || !pending.isEmpty()) {
+ log.info("[svc-hall] 对账完成(应急闸开启中):在途 {} 条,修正 {} 条", pending.size(), fixed);
+ }
+ }
+
+ private static Integer toInt(Object value) {
+ if (value == null) {
+ return null;
+ }
+ if (value instanceof Number) {
+ return ((Number) value).intValue();
+ }
+ try {
+ return Integer.parseInt(String.valueOf(value));
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/com/yunhai/svchall/model/oapi/OapiModels.java b/src/main/java/com/yunhai/svchall/model/oapi/OapiModels.java
new file mode 100644
index 0000000..54c5f9d
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/model/oapi/OapiModels.java
@@ -0,0 +1,133 @@
+package com.yunhai.svchall.model.oapi;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 开放接口(供 CMS)出参模型收口(V2.0 Part C.2 契约冻结件)。
+ * 平台统一响应信封 {code,msg,data} 由 starter 包装,本类只定义 data 部分。
+ * 签名/信封纪律:未签名 401(RequireAppSignFilter);缺参/不存在/未发布 → HTTP 200 + code=2。
+ */
+public final class OapiModels {
+
+ private OapiModels() {
+ }
+
+ /** GET /oapi/svc/items 行(id 为办事大厅主键——CMS 门户 /sv/{id} 路由直接携带) */
+ @Data
+ public static class ItemRow {
+ private Integer id;
+ private String itemCode;
+ private String title;
+ private String summary;
+ private String coverImage;
+ private String deptName;
+ private String categoryName;
+ private Integer handleMode;
+ private Integer onlineFlag;
+ private LocalDateTime publishTime;
+ }
+
+ /** GET /oapi/svc/items 出参 */
+ @Data
+ public static class ListData {
+ private Long total = 0L;
+ private Integer page = 1;
+ private Integer pageSize = 10;
+ private Integer totalPages = 0;
+ private List items = new ArrayList<>();
+ }
+
+ /** 筛选项(key 或 id 二选一:object/category 用 key,dept 用组 id) */
+ @Data
+ public static class FilterOption {
+ private final String key;
+ private final Integer id;
+ private final String name;
+ private final long count;
+ }
+
+ /** GET /oapi/svc/filters 出参 */
+ @Data
+ public static class Filters {
+ private List objects = new ArrayList<>();
+ private List categories = new ArrayList<>();
+ private List depts = new ArrayList<>();
+ }
+
+ /** 指南附件(fileUrl 为完整可达地址) */
+ @Data
+ public static class AttachmentRow {
+ private Integer id;
+ private String fileName;
+ private String fileUrl;
+ private Long fileSize;
+ }
+
+ /** 关联事项行 */
+ @Data
+ public static class RelatedItemRow {
+ private Integer id;
+ private String itemCode;
+ private String title;
+ private String deptName;
+ private Integer handleMode;
+ }
+
+ /** GET /oapi/svc/item/detail 出参(三段 HTML 与封面已绝对化) */
+ @Data
+ public static class Detail {
+ private Integer id;
+ private String itemCode;
+ private String title;
+ private String summary;
+ private String objectKey;
+ private String objectName;
+ private String categoryKey;
+ private String categoryName;
+ private Integer deptGroupId;
+ private String deptName;
+ private Integer handleMode;
+ private Integer onlineFlag;
+ private String handlePlace;
+ private String handleTimeLimit;
+ private String promiseTimeLimit;
+ private String contactPhone;
+ private String supervisePhone;
+ private String processHtml;
+ private String materialHtml;
+ private String noticeHtml;
+ private String coverImage;
+ private LocalDateTime publishTime;
+ private Integer viewCount;
+ /** 关联 CMS 栏目(不透明引用,CMS 侧解析为 URL) */
+ private Integer faqCategoryId;
+ private Integer noticeCategoryId;
+ private Integer ruleCategoryId;
+ private List attachments = new ArrayList<>();
+ private List relatedItems = new ArrayList<>();
+ /** 办事大厅发起页地址({front-base}/#/apply?siteCode=&itemCode=;CMS 三态兜底用) */
+ private String applyUrl;
+ /** 绑定流程 GUID(§3.7:CMS"自动生成"分支在线条件=onlineFlag==1 且 flowGuid 非空;null=未绑流程) */
+ private String flowGuid;
+ }
+
+ /** GET /oapi/svc/item/snapshot 出参(docs/17 §1.1 契约原样平移,11 字段) */
+ @Data
+ public static class Snapshot {
+ private String siteCode;
+ private String itemCode;
+ private String title;
+ private String summary;
+ private String deptName;
+ private Integer handleMode;
+ private Integer onlineFlag;
+ private String handleTimeLimit;
+ private String contactPhone;
+ private String applyUrl;
+ private LocalDateTime publishTime;
+ }
+}
diff --git a/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java b/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java
new file mode 100644
index 0000000..f883217
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java
@@ -0,0 +1,461 @@
+package com.yunhai.svchall.model.svc;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Pattern;
+import javax.validation.constraints.Size;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 办事大厅业务模型收口(管理端/用户面 DTO 与 VO;嵌套静态类收口减少文件数)。
+ * oapi(供 CMS)模型另见 model/oapi/OapiModels。
+ */
+public final class SvcModels {
+
+ private SvcModels() {
+ }
+
+ // ===== 通用 =====
+
+ /** 删除入参(siteCode 定位站点 + id) */
+ @Data
+ public static class DeleteRequest {
+ @NotBlank(message = "siteCode 不得为空")
+ private String siteCode;
+ @NotNull(message = "id 不得为空")
+ private Integer id;
+ }
+
+ /** 排序项 */
+ @Data
+ public static class IdSortItem {
+ @NotNull(message = "id 不得为空")
+ private Integer id;
+ @NotNull(message = "sortNo 不得为空")
+ private Integer sortNo;
+ }
+
+ /** 站点选项(CMS 站点目录代理,管理端站点选择器) */
+ @Data
+ public static class SiteOptionVo {
+ private String siteCode;
+ private String name;
+ private Long schId;
+ }
+
+ // ===== 字典 =====
+
+ @Data
+ public static class SvcDictSaveRequest {
+ private Integer id;
+ @NotBlank(message = "siteCode 不得为空")
+ private String siteCode;
+ @NotBlank(message = "字典类型不得为空")
+ private String dictType;
+ @NotBlank(message = "字典键不得为空")
+ @Pattern(regexp = "^[A-Za-z0-9_-]{1,64}$", message = "字典键仅限字母/数字/下划线/中划线")
+ private String dictKey;
+ @NotBlank(message = "字典名称不得为空")
+ @Size(max = 128, message = "字典名称过长")
+ private String dictName;
+ private Integer sortNo;
+ private Integer status;
+ }
+
+ @Data
+ public static class SvcDictVo {
+ private Integer id;
+ private String siteCode;
+ private String dictType;
+ private String dictKey;
+ private String dictName;
+ private Integer sortNo;
+ private Integer status;
+ private Long referenced;
+
+ public static SvcDictVo of(com.yunhai.svchall.entity.ServiceDict d, Long referenced) {
+ SvcDictVo vo = new SvcDictVo();
+ vo.setId(d.getId());
+ vo.setSiteCode(d.getSiteCode());
+ vo.setDictType(d.getDictType());
+ vo.setDictKey(d.getDictKey());
+ vo.setDictName(d.getDictName());
+ vo.setSortNo(d.getSortNo());
+ vo.setStatus(d.getStatus());
+ vo.setReferenced(referenced == null ? 0L : referenced);
+ return vo;
+ }
+ }
+
+ /** 服务部门选项(UserBase 组树嵌套下发,承袭 CMS SvcDeptVo) */
+ @Data
+ public static class SvcDeptVo {
+ private Integer id;
+ private String name;
+ private List children = new ArrayList<>();
+
+ public SvcDeptVo() {
+ }
+
+ public SvcDeptVo(Integer id, String name) {
+ this.id = id;
+ this.name = name;
+ }
+ }
+
+ // ===== 事项 =====
+
+ @Data
+ public static class SvcItemPageQuery {
+ @NotBlank(message = "siteCode 不得为空")
+ private String siteCode;
+ private Integer status;
+ private String keyword;
+ private String objectKey;
+ private String categoryKey;
+ private Integer deptGroupId;
+ private Integer page = 1;
+ private Integer pageSize = 10;
+ }
+
+ @Data
+ public static class SvcItemSaveRequest {
+ private Integer id;
+ @NotBlank(message = "siteCode 不得为空")
+ private String siteCode;
+ @NotBlank(message = "事项编码不得为空")
+ @Pattern(regexp = "^[A-Za-z0-9_-]{2,64}$", message = "事项编码仅限字母/数字/下划线/中划线,2~64 位")
+ private String itemCode;
+ @NotBlank(message = "事项名称不得为空")
+ @Size(max = 200, message = "事项名称过长")
+ private String title;
+ @Size(max = 500, message = "简介过长")
+ private String summary;
+ @Size(max = 64)
+ private String objectKey;
+ @Size(max = 64)
+ private String categoryKey;
+ private Integer deptGroupId;
+ private Integer handleMode;
+ private Integer onlineFlag;
+ @Size(max = 200)
+ private String handlePlace;
+ @Size(max = 64)
+ private String handleTimeLimit;
+ @Size(max = 64)
+ private String promiseTimeLimit;
+ @Size(max = 64)
+ private String contactPhone;
+ @Size(max = 64)
+ private String supervisePhone;
+ private String processHtml;
+ private String materialHtml;
+ private String noticeHtml;
+ private Integer faqCategoryId;
+ private Integer noticeCategoryId;
+ private Integer ruleCategoryId;
+ @Size(max = 500)
+ private String coverImage;
+ /** 绑定自定义表单 code(可空;发起无需表单) */
+ @Size(max = 64)
+ private String formCode;
+ /** 绑定流程中心流程 flow_guid(可空;onlineFlag=1 时必填) */
+ @Size(max = 32)
+ private String flowGuid;
+ @Size(max = 500)
+ private String applyUrl;
+ private Integer sortNo;
+ /** 指南附件 id 列表(保存时全量重绑 topic_type=svc_item_guide) */
+ private List attachmentIds;
+ }
+
+ @Data
+ public static class SvcItemEditVo {
+ private Integer id;
+ private String siteCode;
+ private String itemCode;
+ private String title;
+ private String summary;
+ private String objectKey;
+ private String categoryKey;
+ private Integer deptGroupId;
+ private String deptName;
+ private Integer handleMode;
+ private Integer onlineFlag;
+ private String handlePlace;
+ private String handleTimeLimit;
+ private String promiseTimeLimit;
+ private String contactPhone;
+ private String supervisePhone;
+ private String processHtml;
+ private String materialHtml;
+ private String noticeHtml;
+ private Integer faqCategoryId;
+ private Integer noticeCategoryId;
+ private Integer ruleCategoryId;
+ private String coverImage;
+ private String formCode;
+ private String flowGuid;
+ private String applyUrl;
+ private Integer sortNo;
+ private Integer status;
+ private LocalDateTime publishTime;
+ private LocalDateTime offlineTime;
+ private Integer viewCount;
+
+ public static SvcItemEditVo of(com.yunhai.svchall.entity.ServiceItem item) {
+ SvcItemEditVo vo = new SvcItemEditVo();
+ vo.setId(item.getId());
+ vo.setSiteCode(item.getSiteCode());
+ vo.setItemCode(item.getItemCode());
+ vo.setTitle(item.getTitle());
+ vo.setSummary(item.getSummary());
+ vo.setObjectKey(item.getObjectKey());
+ vo.setCategoryKey(item.getCategoryKey());
+ vo.setDeptGroupId(item.getDeptGroupId());
+ vo.setDeptName(item.getDeptName());
+ vo.setHandleMode(item.getHandleMode());
+ vo.setOnlineFlag(item.getOnlineFlag());
+ vo.setHandlePlace(item.getHandlePlace());
+ vo.setHandleTimeLimit(item.getHandleTimeLimit());
+ vo.setPromiseTimeLimit(item.getPromiseTimeLimit());
+ vo.setContactPhone(item.getContactPhone());
+ vo.setSupervisePhone(item.getSupervisePhone());
+ vo.setProcessHtml(item.getProcessHtml());
+ vo.setMaterialHtml(item.getMaterialHtml());
+ vo.setNoticeHtml(item.getNoticeHtml());
+ vo.setFaqCategoryId(item.getFaqCategoryId());
+ vo.setNoticeCategoryId(item.getNoticeCategoryId());
+ vo.setRuleCategoryId(item.getRuleCategoryId());
+ vo.setCoverImage(item.getCoverImage());
+ vo.setFormCode(item.getFormCode());
+ vo.setFlowGuid(item.getFlowGuid());
+ vo.setApplyUrl(item.getApplyUrl());
+ vo.setSortNo(item.getSortNo());
+ vo.setStatus(item.getStatus());
+ vo.setPublishTime(item.getPublishTime());
+ vo.setOfflineTime(item.getOfflineTime());
+ vo.setViewCount(item.getViewCount());
+ return vo;
+ }
+ }
+
+ @Data
+ public static class SvcItemPageItem {
+ private Integer id;
+ private String siteCode;
+ private String itemCode;
+ private String title;
+ private String summary;
+ private String objectKey;
+ private String objectName;
+ private String categoryKey;
+ private String categoryName;
+ private Integer deptGroupId;
+ private String deptName;
+ private Integer handleMode;
+ private Integer onlineFlag;
+ private String formCode;
+ private String flowGuid;
+ private Integer status;
+ private Integer sortNo;
+ private LocalDateTime publishTime;
+ private Integer viewCount;
+ private LocalDateTime createTime;
+ private String createBy;
+
+ public static SvcItemPageItem of(com.yunhai.svchall.entity.ServiceItem item, String objectName,
+ String categoryName) {
+ SvcItemPageItem vo = new SvcItemPageItem();
+ vo.setId(item.getId());
+ vo.setSiteCode(item.getSiteCode());
+ vo.setItemCode(item.getItemCode());
+ vo.setTitle(item.getTitle());
+ vo.setSummary(item.getSummary());
+ vo.setObjectKey(item.getObjectKey());
+ vo.setObjectName(objectName);
+ vo.setCategoryKey(item.getCategoryKey());
+ vo.setCategoryName(categoryName);
+ vo.setDeptGroupId(item.getDeptGroupId());
+ vo.setDeptName(item.getDeptName());
+ vo.setHandleMode(item.getHandleMode());
+ vo.setOnlineFlag(item.getOnlineFlag());
+ vo.setFormCode(item.getFormCode());
+ vo.setFlowGuid(item.getFlowGuid());
+ vo.setStatus(item.getStatus());
+ vo.setSortNo(item.getSortNo());
+ vo.setPublishTime(item.getPublishTime());
+ vo.setViewCount(item.getViewCount());
+ vo.setCreateTime(item.getCreateTime());
+ vo.setCreateBy(item.getCreateBy());
+ return vo;
+ }
+ }
+
+ @Data
+ public static class SvcItemSimpleVo {
+ private Integer id;
+ private String itemCode;
+ private String title;
+ private Integer status;
+
+ public static SvcItemSimpleVo of(com.yunhai.svchall.entity.ServiceItem item) {
+ SvcItemSimpleVo vo = new SvcItemSimpleVo();
+ vo.setId(item.getId());
+ vo.setItemCode(item.getItemCode());
+ vo.setTitle(item.getTitle());
+ vo.setStatus(item.getStatus());
+ return vo;
+ }
+ }
+
+ @Data
+ public static class SvcItemAttachmentVo {
+ private Integer id;
+ private String fileName;
+ private String fileUrl;
+ private Long fileSize;
+
+ public static SvcItemAttachmentVo of(com.yunhai.svchall.entity.ServiceAttachment a) {
+ SvcItemAttachmentVo vo = new SvcItemAttachmentVo();
+ vo.setId(a.getId());
+ vo.setFileName(a.getFileName());
+ vo.setFileUrl(com.yunhai.svchall.utils.FileUtil.buildUrl(a.getFileUrl()));
+ vo.setFileSize(a.getFileSize());
+ return vo;
+ }
+ }
+
+ // ===== 表单 =====
+
+ @Data
+ public static class SvcFormSaveRequest {
+ private Integer id;
+ @NotBlank(message = "表单编码不得为空")
+ @Pattern(regexp = "^[A-Za-z0-9_-]{2,64}$", message = "表单编码仅限字母/数字/下划线/中划线,2~64 位")
+ private String code;
+ @NotBlank(message = "表单名称不得为空")
+ @Size(max = 128, message = "表单名称过长")
+ private String name;
+ @NotBlank(message = "表单结构不得为空")
+ private String schemaJson;
+ }
+
+ @Data
+ public static class SvcFormVo {
+ private Integer id;
+ private String code;
+ private String name;
+ private Integer version;
+ private Integer status;
+ private String schemaJson;
+ private LocalDateTime publishTime;
+ private Boolean referenced;
+
+ public static SvcFormVo of(com.yunhai.svchall.entity.ServiceForm form, Boolean referenced) {
+ SvcFormVo vo = new SvcFormVo();
+ vo.setId(form.getId());
+ vo.setCode(form.getCode());
+ vo.setName(form.getName());
+ vo.setVersion(form.getVersion());
+ vo.setStatus(form.getStatus());
+ vo.setSchemaJson(form.getSchemaJson());
+ vo.setPublishTime(form.getPublishTime());
+ vo.setReferenced(referenced);
+ return vo;
+ }
+ }
+
+ // ===== 办件 =====
+
+ /** 申请级材料项(无表单事项的附件;先经 /api/file/upload 上传,再携 attachmentId 绑定) */
+ @Data
+ public static class ApplyAttachmentItem {
+ /** 表单/材料字段标识(可空) */
+ private String fieldKey;
+ /** 上传接口返回的附件行 id */
+ @NotNull(message = "attachmentId 不得为空")
+ private Integer attachmentId;
+ }
+
+ @Data
+ public static class ApplySubmitRequest {
+ @NotBlank(message = "siteCode 不得为空")
+ private String siteCode;
+ @NotBlank(message = "itemCode 不得为空")
+ private String itemCode;
+ /** 表单数据 JSON 字符串({key:value};无表单事项传空) */
+ private String formData;
+ /** 申请级材料(可空) */
+ private List attachments;
+ }
+
+ @Data
+ public static class ApplyVo {
+ private Integer id;
+ private String siteCode;
+ private Integer itemId;
+ private String itemCode;
+ private String itemTitle;
+ private String itemSnapshotJson;
+ private Integer formVersion;
+ private String formDataJson;
+ private String attachmentsJson;
+ private Integer submitterId;
+ private String submitterName;
+ private String flowGuid;
+ private Long flowInstanceId;
+ private Integer flowStatus;
+ private String flowResultJson;
+ private LocalDateTime submitTime;
+ private LocalDateTime finishTime;
+ private LocalDateTime createTime;
+
+ public static ApplyVo of(com.yunhai.svchall.entity.ServiceApply a) {
+ ApplyVo vo = new ApplyVo();
+ vo.setId(a.getId());
+ vo.setSiteCode(a.getSiteCode());
+ vo.setItemId(a.getItemId());
+ vo.setItemCode(a.getItemCode());
+ vo.setItemTitle(a.getItemTitle());
+ vo.setItemSnapshotJson(a.getItemSnapshotJson());
+ vo.setFormVersion(a.getFormVersion());
+ vo.setFormDataJson(a.getFormDataJson());
+ vo.setAttachmentsJson(a.getAttachmentsJson());
+ vo.setSubmitterId(a.getSubmitterId());
+ vo.setSubmitterName(a.getSubmitterName());
+ vo.setFlowGuid(a.getFlowGuid());
+ vo.setFlowInstanceId(a.getFlowInstanceId());
+ vo.setFlowStatus(a.getFlowStatus());
+ vo.setFlowResultJson(a.getFlowResultJson());
+ vo.setSubmitTime(a.getSubmitTime());
+ vo.setFinishTime(a.getFinishTime());
+ vo.setCreateTime(a.getCreateTime());
+ return vo;
+ }
+ }
+
+ @Data
+ public static class ApplyPageQuery {
+ private String siteCode;
+ private String itemCode;
+ private Integer flowStatus;
+ private String keyword;
+ private Integer page = 1;
+ private Integer pageSize = 10;
+ }
+
+ // ===== 流程(管理端回显) =====
+
+ @Data
+ public static class FlowSummaryVo {
+ private String flowGuid;
+ private String name;
+ private Integer version;
+ private String nodeSummary;
+ }
+}
diff --git a/src/main/java/com/yunhai/svchall/repository/ServiceApplyRepository.java b/src/main/java/com/yunhai/svchall/repository/ServiceApplyRepository.java
new file mode 100644
index 0000000..eee3d49
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/repository/ServiceApplyRepository.java
@@ -0,0 +1,66 @@
+package com.yunhai.svchall.repository;
+
+import com.yunhai.svchall.entity.ServiceApply;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+import org.springframework.data.jpa.repository.Lock;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import javax.persistence.LockModeType;
+import java.util.List;
+import java.util.Optional;
+
+public interface ServiceApplyRepository extends JpaRepository,
+ JpaSpecificationExecutor {
+
+ List findBySchIdAndSubmitterIdOrderByCreateTimeDesc(Long schId, Integer submitterId, Pageable pageable);
+
+ Optional findByIdAndSchId(Integer id, Long schId);
+
+ Optional findBySchIdAndFlowInstanceId(Long schId, Long flowInstanceId);
+
+ /** 回调侧行锁串行化:并发推送(flow-center 推送池)对同一办件的处理排队,防丢更新 */
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("select a from ServiceApply a where a.id = :id")
+ Optional findByIdForUpdate(@Param("id") Integer id);
+
+ /** 对账:在途办件(待提交/审批中) */
+ List findBySchIdAndFlowStatusIn(Long schId, List statuses);
+
+ /** 全租户对账:审批中的在途办件(分页上限防御) */
+ List findByFlowStatus(Integer flowStatus, Pageable pageable);
+
+ /** 待提交且已产生 flowInstanceId 之外的挂起单(代发起未返回的重试候选) */
+ List findBySchIdAndFlowStatus(Long schId, Integer flowStatus, Pageable pageable);
+
+ /**
+ * 代发起成功回填(窄列原子更新,Notice markApproving 同构):
+ * 置 flow_instance_id + flow_status=10;若回调已并发先行落定终态(20 完成/30 拒绝/40 撤回,
+ * code 对齐 ApplyFlowStatusEnum)则保持终态不回退(验收报告 §四.2:旧快照盲写会把状态打回);
+ * submitTime 仅首次回填(coalesce 保留既有值)。
+ */
+ @Modifying
+ @Query("update ServiceApply a set a.flowInstanceId = :instanceId,"
+ + " a.flowStatus = (case when a.flowStatus in (20, 30, 40) then a.flowStatus else 10 end),"
+ + " a.submitTime = coalesce(a.submitTime, :submitTime)"
+ + " where a.id = :id")
+ int markApproving(@Param("id") Integer id, @Param("instanceId") Long instanceId,
+ @Param("submitTime") java.time.LocalDateTime submitTime);
+
+ /**
+ * 一键重提回填(P2-5/R4):驳回/撤回(30/40)行经 flow-center R4 重启推进后回开为 10。
+ * 与 {@link #markApproving} 的差别:30/40 → 10 是合法回开(markApproving 的终态守卫
+ * 语义是"回调已落定终态不被提交侧盲写打回",重提是用户显式动作);重提是新的提交动作,
+ * submitTime 覆盖为当下、finishTime 清空。WHERE 限定 30/40,并发窗口内若状态已被推进
+ * (如新一轮回调先置 10)则本次为幂等空更新。
+ */
+ @Modifying
+ @Query("update ServiceApply a set a.flowInstanceId = :instanceId, a.flowStatus = 10,"
+ + " a.submitTime = :submitTime, a.finishTime = null"
+ + " where a.id = :id and a.flowStatus in (30, 40)")
+ int markResubmitted(@Param("id") Integer id, @Param("instanceId") Long instanceId,
+ @Param("submitTime") java.time.LocalDateTime submitTime);
+}
diff --git a/src/main/java/com/yunhai/svchall/repository/ServiceAttachmentRepository.java b/src/main/java/com/yunhai/svchall/repository/ServiceAttachmentRepository.java
new file mode 100644
index 0000000..a0591d5
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/repository/ServiceAttachmentRepository.java
@@ -0,0 +1,18 @@
+package com.yunhai.svchall.repository;
+
+import com.yunhai.svchall.entity.ServiceAttachment;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface ServiceAttachmentRepository extends JpaRepository {
+
+ List findBySchIdAndTopicTypeAndTopicIdOrderByIdAsc(Long schId, String topicType,
+ Integer topicId);
+
+ List findBySchIdAndTopicTypeAndTopicId(Long schId, String topicType, Integer topicId);
+
+ List findAllById(Iterable ids);
+
+ void deleteBySchIdAndTopicTypeAndTopicId(Long schId, String topicType, Integer topicId);
+}
diff --git a/src/main/java/com/yunhai/svchall/repository/ServiceDictRepository.java b/src/main/java/com/yunhai/svchall/repository/ServiceDictRepository.java
new file mode 100644
index 0000000..04a11ac
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/repository/ServiceDictRepository.java
@@ -0,0 +1,26 @@
+package com.yunhai.svchall.repository;
+
+import com.yunhai.svchall.entity.ServiceDict;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface ServiceDictRepository extends JpaRepository {
+
+ List findBySchIdAndSiteCodeAndDictTypeOrderBySortNoAscIdAsc(Long schId, String siteCode,
+ String dictType);
+
+ List findBySchIdAndSiteCodeOrderBySortNoAscIdAsc(Long schId, String siteCode);
+
+ Optional findByIdAndSchIdAndSiteCode(Integer id, Long schId, String siteCode);
+
+ Optional findBySchIdAndSiteCodeAndDictTypeAndDictKey(Long schId, String siteCode, String dictType,
+ String dictKey);
+
+ boolean existsBySchIdAndSiteCodeAndDictTypeAndDictKey(Long schId, String siteCode, String dictType,
+ String dictKey);
+
+ boolean existsBySchIdAndSiteCodeAndDictTypeAndDictKeyAndIdNot(Long schId, String siteCode, String dictType,
+ String dictKey, Integer id);
+}
diff --git a/src/main/java/com/yunhai/svchall/repository/ServiceFlowEventRepository.java b/src/main/java/com/yunhai/svchall/repository/ServiceFlowEventRepository.java
new file mode 100644
index 0000000..964f285
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/repository/ServiceFlowEventRepository.java
@@ -0,0 +1,9 @@
+package com.yunhai.svchall.repository;
+
+import com.yunhai.svchall.entity.ServiceFlowEvent;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface ServiceFlowEventRepository extends JpaRepository {
+
+ boolean existsByEventId(String eventId);
+}
diff --git a/src/main/java/com/yunhai/svchall/repository/ServiceFlowRegRepository.java b/src/main/java/com/yunhai/svchall/repository/ServiceFlowRegRepository.java
new file mode 100644
index 0000000..113d8be
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/repository/ServiceFlowRegRepository.java
@@ -0,0 +1,15 @@
+package com.yunhai.svchall.repository;
+
+import com.yunhai.svchall.entity.ServiceFlowReg;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface ServiceFlowRegRepository extends JpaRepository {
+
+ Optional findBySchIdAndFlowGuid(Long schId, String flowGuid);
+
+ Optional findByFlowGuid(String flowGuid);
+
+ void deleteBySchIdAndFlowGuid(Long schId, String flowGuid);
+}
diff --git a/src/main/java/com/yunhai/svchall/repository/ServiceFormRepository.java b/src/main/java/com/yunhai/svchall/repository/ServiceFormRepository.java
new file mode 100644
index 0000000..bfa066f
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/repository/ServiceFormRepository.java
@@ -0,0 +1,26 @@
+package com.yunhai.svchall.repository;
+
+import com.yunhai.svchall.entity.ServiceForm;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface ServiceFormRepository extends JpaRepository {
+
+ List findBySchIdOrderByCodeAscVersionDescIdAsc(Long schId);
+
+ List findBySchIdAndCodeOrderByVersionDesc(Long schId, String code);
+
+ Optional findByIdAndSchId(Integer id, Long schId);
+
+ Optional findBySchIdAndCodeAndVersion(Long schId, String code, Integer version);
+
+ /** 事项绑定校验/发起取数:code 的最新已发布版本 */
+ Optional findFirstBySchIdAndCodeAndStatusOrderByVersionDesc(Long schId, String code, Integer status);
+
+ boolean existsBySchIdAndCode(Long schId, String code);
+
+ /** 事项引用计数(删除守护:任一版本被绑定即算被引用,按 code 判) */
+ long countBySchIdAndCode(Long schId, String code);
+}
diff --git a/src/main/java/com/yunhai/svchall/repository/ServiceItemRepository.java b/src/main/java/com/yunhai/svchall/repository/ServiceItemRepository.java
new file mode 100644
index 0000000..2ff26e9
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/repository/ServiceItemRepository.java
@@ -0,0 +1,74 @@
+package com.yunhai.svchall.repository;
+
+import com.yunhai.svchall.entity.ServiceItem;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface ServiceItemRepository extends JpaRepository,
+ JpaSpecificationExecutor {
+
+ Optional findByIdAndSchIdAndSiteCode(Integer id, Long schId, String siteCode);
+
+ Optional findBySchIdAndSiteCodeAndItemCode(Long schId, String siteCode, String itemCode);
+
+ boolean existsBySchIdAndSiteCodeAndItemCode(Long schId, String siteCode, String itemCode);
+
+ boolean existsBySchIdAndSiteCodeAndItemCodeAndIdNot(Long schId, String siteCode, String itemCode, Integer id);
+
+ /** 字典键被事项引用计数(字典改键/停用守护用) */
+ long countBySchIdAndSiteCodeAndObjectKey(Long schId, String siteCode, String objectKey);
+
+ long countBySchIdAndSiteCodeAndCategoryKey(Long schId, String siteCode, String categoryKey);
+
+ /** 关联事项:同类别已发布,排除自身(Pageable 控制条数与排序) */
+ List findBySchIdAndSiteCodeAndStatusAndCategoryKeyAndIdNot(Long schId, String siteCode,
+ Integer status, String categoryKey,
+ Integer id, Pageable pageable);
+
+ /** 管理端选择器/部门名同步:全状态按排序取(条数由 Pageable 控制) */
+ List findBySchIdAndSiteCodeOrderBySortNoAscIdAsc(Long schId, String siteCode, Pageable pageable);
+
+ List findBySchIdAndFlowGuid(Long schId, String flowGuid);
+
+ /** 表单被事项绑定计数(表单删除守护,按 form_code 判) */
+ long countBySchIdAndFormCode(Long schId, String formCode);
+
+ // ===== 门户筛选项计数(已发布口径,group by 一次往返/维度,承袭 CMS 口径)=====
+
+ @Query("select i.objectKey, count(i) from ServiceItem i"
+ + " where i.schId = :schId and i.siteCode = :siteCode and i.status = 1 and i.objectKey is not null"
+ + " group by i.objectKey")
+ List