diff --git a/REGION-API.md b/REGION-API.md
new file mode 100644
index 0000000..a8619b9
--- /dev/null
+++ b/REGION-API.md
@@ -0,0 +1,124 @@
+# 区域管理接口(REGION-01~05)
+
+## 公共约定
+
+- 内部接口前缀:`/edgeguard/regions`
+- 企业来自公共服务,`edgeguard_region` 只保存真实区域及 `corp_id`。
+- 所有接口先按公共企业服务返回的当前调用人可访问企业校验数据范围。
+- `nodeType=CORP` 表示企业虚拟根;`nodeType=REGION` 表示本地真实区域。
+- 菜单权限编码尚未分配,因此 Controller 暂不填写 `@PreAuthorize`;菜单资源确定后必须补齐。
+
+## REGION-01 区域树
+
+```http
+GET /edgeguard/regions/tree?corpId=1001
+```
+
+`corpId` 可省略。返回企业虚拟根,即使企业尚无区域也返回空 `children`。
+
+```json
+{
+ "success": true,
+ "data": [{
+ "nodeId": "C-1001",
+ "nodeType": "CORP",
+ "id": 1001,
+ "corpId": 1001,
+ "nodeName": "示例企业",
+ "treeLevel": 0,
+ "children": [{
+ "nodeId": "R-10",
+ "nodeType": "REGION",
+ "id": 10,
+ "corpId": 1001,
+ "parentId": 0,
+ "nodeName": "一号厂区",
+ "regionCode": "8f06f25cd7f84a1682ed86cdfa3da417",
+ "treeLevel": 1,
+ "sortNo": 0,
+ "regionStatus": "ENABLED",
+ "children": []
+ }]
+ }]
+}
+```
+
+## REGION-02 区域详情
+
+```http
+GET /edgeguard/regions/10
+```
+
+详情返回企业名称、负责人名称、有序围栏点和用于修改的 `version`。
+
+## REGION-03 新增区域
+
+```http
+POST /edgeguard/regions
+Content-Type: application/json
+
+{
+ "corpId": 1001,
+ "parentId": 0,
+ "regionName": "一号厂区",
+ "leaderUserId": 2001,
+ "coordinateSystem": "GCJ02",
+ "fencePoints": [
+ {"seq": 1, "longitude": 113.1, "latitude": 23.1},
+ {"seq": 2, "longitude": 113.2, "latitude": 23.1},
+ {"seq": 3, "longitude": 113.2, "latitude": 23.2},
+ {"seq": 4, "longitude": 113.1, "latitude": 23.2}
+ ],
+ "sortNo": 0,
+ "remarks": "示例"
+}
+```
+
+服务端生成 `regionCode`、`treePath`、`treeLevel`,初始状态为 `ENABLED`。
+
+## REGION-04 修改区域
+
+```http
+PUT /edgeguard/regions/10
+Content-Type: application/json
+
+{
+ "regionName": "一号厂区东区",
+ "leaderUserId": 2001,
+ "coordinateSystem": "GCJ02",
+ "fencePoints": [
+ {"seq": 1, "longitude": 113.1, "latitude": 23.1},
+ {"seq": 2, "longitude": 113.2, "latitude": 23.1},
+ {"seq": 3, "longitude": 113.2, "latitude": 23.2},
+ {"seq": 4, "longitude": 113.1, "latitude": 23.2}
+ ],
+ "sortNo": 10,
+ "regionStatus": "ENABLED",
+ "version": 1,
+ "remarks": "修改示例"
+}
+```
+
+一期不接受 `parentId`、`corpId`、`regionCode`、`treePath` 或 `treeLevel` 修改。
+
+## REGION-05 删除区域
+
+```http
+DELETE /edgeguard/regions/10
+```
+
+仅执行逻辑删除。存在子区域或 `BOUND` 摄像头绑定时拒绝删除。
+
+## 当前业务异常
+
+| 场景 | 信息 |
+| --- | --- |
+| 企业越权 | 无权访问该企业数据 |
+| 区域不存在 | 区域不存在 / 父区域不存在 |
+| 同级重名 | 同级区域名称已存在 |
+| 负责人无效 | 区域负责人不存在或已停用 |
+| 负责人跨企业 | 区域负责人不属于当前企业 |
+| 围栏无效 | 点数、序号、范围、重复点、自相交或零面积对应提示 |
+| 并发修改 | 区域已被其他用户修改,请刷新后重试 |
+| 删除受限 | 区域存在下级区域,不能删除 / 区域仍绑定有效摄像头,不能删除 |
+
diff --git a/start/pom.xml b/start/pom.xml
index 1a75b4d..8c81668 100644
--- a/start/pom.xml
+++ b/start/pom.xml
@@ -39,7 +39,7 @@
org.springframework.boot
spring-boot-maven-plugin
- com.zcloud.risk.Application
+ com.zcloud.gbs.edgeguard.Application
diff --git a/start/src/main/java/com/zcloud/gbs/edgeguard/Application.java b/start/src/main/java/com/zcloud/gbs/edgeguard/Application.java
index 1df7b74..7f417e8 100644
--- a/start/src/main/java/com/zcloud/gbs/edgeguard/Application.java
+++ b/start/src/main/java/com/zcloud/gbs/edgeguard/Application.java
@@ -1,6 +1,7 @@
package com.zcloud.gbs.edgeguard;
import com.jjb.saas.base.starter.bootstart.JJBSpringbootApplication;
+import com.jjb.saas.facade.starter.annotation.EnableFacadeRpcClient;
import org.springframework.boot.SpringApplication;
/**
@@ -11,6 +12,7 @@ import org.springframework.boot.SpringApplication;
scanBasePackages = {"com.zcloud","com.jjb"},
mapperPackages = {"com.zcloud.gbs.edgeguard.persistence.mapper"}
)
+@EnableFacadeRpcClient
public class Application {
public static void main(String[] args) {
diff --git a/web-adapter/src/main/java/com/zcloud/gbs/edgeguard/web/RegionController.java b/web-adapter/src/main/java/com/zcloud/gbs/edgeguard/web/RegionController.java
new file mode 100644
index 0000000..3c360f1
--- /dev/null
+++ b/web-adapter/src/main/java/com/zcloud/gbs/edgeguard/web/RegionController.java
@@ -0,0 +1,61 @@
+package com.zcloud.gbs.edgeguard.web;
+
+import com.alibaba.cola.dto.MultiResponse;
+import com.alibaba.cola.dto.Response;
+import com.alibaba.cola.dto.SingleResponse;
+import com.zcloud.gbs.edgeguard.api.RegionServiceI;
+import com.zcloud.gbs.edgeguard.dto.RegionAddCmd;
+import com.zcloud.gbs.edgeguard.dto.RegionUpdateCmd;
+import com.zcloud.gbs.edgeguard.dto.clientobject.RegionCo;
+import com.zcloud.gbs.edgeguard.dto.clientobject.RegionTreeNodeCo;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Api(tags = "边缘人脸区域安全-区域管理")
+@RestController
+@RequestMapping("/edgeguard/regions")
+@RequiredArgsConstructor
+public class RegionController {
+ private final RegionServiceI regionService;
+
+ @ApiOperation("加载企业及区域树")
+ @GetMapping("/tree")
+ public MultiResponse tree(@RequestParam(value = "corpId", required = false) Long corpId) {
+ return regionService.tree(corpId);
+ }
+
+ @ApiOperation("查询区域详情")
+ @GetMapping("/{id}")
+ public SingleResponse detail(@PathVariable("id") Long id) {
+ return regionService.getById(id);
+ }
+
+ @ApiOperation("新增顶级或下级区域")
+ @PostMapping
+ public SingleResponse add(@Validated @RequestBody RegionAddCmd cmd) {
+ return regionService.add(cmd);
+ }
+
+ @ApiOperation("修改区域")
+ @PutMapping("/{id}")
+ public Response update(@PathVariable("id") Long id, @Validated @RequestBody RegionUpdateCmd cmd) {
+ return regionService.update(id, cmd);
+ }
+
+ @ApiOperation("删除区域")
+ @DeleteMapping("/{id}")
+ public Response remove(@PathVariable("id") Long id) {
+ return regionService.remove(id);
+ }
+}
diff --git a/web-app/src/main/java/com/zcloud/gbs/edgeguard/command/RegionAccessService.java b/web-app/src/main/java/com/zcloud/gbs/edgeguard/command/RegionAccessService.java
new file mode 100644
index 0000000..6e6a4e2
--- /dev/null
+++ b/web-app/src/main/java/com/zcloud/gbs/edgeguard/command/RegionAccessService.java
@@ -0,0 +1,31 @@
+package com.zcloud.gbs.edgeguard.command;
+
+import com.alibaba.cola.exception.BizException;
+import com.zcloud.gbs.edgeguard.domain.gateway.CorpGateway;
+import com.zcloud.gbs.edgeguard.domain.model.CorpE;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Component
+@RequiredArgsConstructor
+public class RegionAccessService {
+ private final CorpGateway corpGateway;
+
+ public List accessibleCorps() {
+ return corpGateway.listAccessibleCorps();
+ }
+
+ public CorpE requireAccessibleCorp(Long corpId) {
+ if (corpId == null) {
+ throw new BizException("所属企业不能为空");
+ }
+ for (CorpE corp : accessibleCorps()) {
+ if (corpId.equals(corp.getId())) {
+ return corp;
+ }
+ }
+ throw new BizException("无权访问该企业数据");
+ }
+}
diff --git a/web-app/src/main/java/com/zcloud/gbs/edgeguard/command/RegionAddExe.java b/web-app/src/main/java/com/zcloud/gbs/edgeguard/command/RegionAddExe.java
new file mode 100644
index 0000000..0f74254
--- /dev/null
+++ b/web-app/src/main/java/com/zcloud/gbs/edgeguard/command/RegionAddExe.java
@@ -0,0 +1,61 @@
+package com.zcloud.gbs.edgeguard.command;
+
+import com.alibaba.cola.exception.BizException;
+import com.alibaba.fastjson.JSON;
+import com.zcloud.gbs.edgeguard.domain.gateway.RegionGateway;
+import com.zcloud.gbs.edgeguard.domain.model.RegionE;
+import com.zcloud.gbs.edgeguard.dto.RegionAddCmd;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.UUID;
+
+@Component
+@RequiredArgsConstructor
+public class RegionAddExe {
+ private final RegionGateway regionGateway;
+ private final RegionAccessService accessService;
+ private final RegionCommandSupport support;
+
+ @Transactional(rollbackFor = Exception.class)
+ public Long execute(RegionAddCmd cmd) {
+ accessService.requireAccessibleCorp(cmd.getCorpId());
+ Long parentId = cmd.getParentId() == null ? 0L : cmd.getParentId();
+ RegionE parent = null;
+ if (parentId > 0) {
+ parent = regionGateway.getById(parentId);
+ if (parent == null) {
+ throw new BizException("父区域不存在");
+ }
+ if (!cmd.getCorpId().equals(parent.getCorpId())) {
+ throw new BizException("父区域与新增区域必须属于同一企业");
+ }
+ }
+ String regionName = support.normalizeName(cmd.getRegionName());
+ if (regionGateway.existsSiblingName(cmd.getCorpId(), parentId, regionName, null)) {
+ throw new BizException("同级区域名称已存在");
+ }
+ support.validateLeader(cmd.getLeaderUserId(), cmd.getCorpId());
+ support.validateFence(cmd.getCoordinateSystem(), cmd.getFencePoints());
+
+ RegionE region = new RegionE();
+ region.setRegionCode(UUID.randomUUID().toString().replace("-", ""));
+ region.setRegionName(regionName);
+ region.setCorpId(cmd.getCorpId());
+ region.setParentId(parentId);
+ region.setTreePath(parent == null ? "/" : parent.getTreePath() + parent.getId() + "/");
+ region.setTreeLevel(parent == null ? 1 : parent.getTreeLevel() + 1);
+ region.setSortNo(cmd.getSortNo() == null ? 0 : cmd.getSortNo());
+ region.setLeaderUserId(cmd.getLeaderUserId());
+ region.setCoordinateSystem(support.normalizeCoordinateSystem(cmd.getCoordinateSystem()));
+ region.setFencePoints(JSON.toJSONString(cmd.getFencePoints()));
+ region.setRegionStatus("ENABLED");
+ region.setRemarks(cmd.getRemarks());
+ Long id = regionGateway.add(region);
+ if (id == null) {
+ throw new BizException("新增区域失败");
+ }
+ return id;
+ }
+}
diff --git a/web-app/src/main/java/com/zcloud/gbs/edgeguard/command/RegionCommandSupport.java b/web-app/src/main/java/com/zcloud/gbs/edgeguard/command/RegionCommandSupport.java
new file mode 100644
index 0000000..4db0bdc
--- /dev/null
+++ b/web-app/src/main/java/com/zcloud/gbs/edgeguard/command/RegionCommandSupport.java
@@ -0,0 +1,51 @@
+package com.zcloud.gbs.edgeguard.command;
+
+import com.alibaba.cola.exception.BizException;
+import com.zcloud.gbs.edgeguard.domain.gateway.UserGateway;
+import com.zcloud.gbs.edgeguard.domain.model.UserE;
+import com.zcloud.gbs.edgeguard.domain.service.RegionDomainService;
+import com.zcloud.gbs.edgeguard.dto.FencePointCmd;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+@Component
+@RequiredArgsConstructor
+public class RegionCommandSupport {
+ private final UserGateway userGateway;
+ private final RegionDomainService domainService;
+
+ public void validateLeader(Long leaderUserId, Long corpId) {
+ UserE user = userGateway.getById(leaderUserId);
+ if (user == null || !user.isActive()) {
+ throw new BizException("区域负责人不存在或已停用");
+ }
+ if (user.getCorpId() != null && !corpId.equals(user.getCorpId())) {
+ throw new BizException("区域负责人不属于当前企业");
+ }
+ }
+
+ public void validateFence(String coordinateSystem, List fencePoints) {
+ domainService.validateCoordinateSystem(normalizeCoordinateSystem(coordinateSystem));
+ List points = new ArrayList<>();
+ if (fencePoints != null) {
+ for (FencePointCmd point : fencePoints) {
+ points.add(point == null ? null : new RegionDomainService.Point(
+ point.getSeq(), point.getLongitude(), point.getLatitude()));
+ }
+ }
+ domainService.validateFence(points);
+ fencePoints.sort(Comparator.comparing(FencePointCmd::getSeq));
+ }
+
+ public String normalizeCoordinateSystem(String coordinateSystem) {
+ return coordinateSystem == null ? null : coordinateSystem.trim().toUpperCase();
+ }
+
+ public String normalizeName(String name) {
+ return name == null ? null : name.trim();
+ }
+}
diff --git a/web-app/src/main/java/com/zcloud/gbs/edgeguard/command/RegionRemoveExe.java b/web-app/src/main/java/com/zcloud/gbs/edgeguard/command/RegionRemoveExe.java
new file mode 100644
index 0000000..a976e0d
--- /dev/null
+++ b/web-app/src/main/java/com/zcloud/gbs/edgeguard/command/RegionRemoveExe.java
@@ -0,0 +1,35 @@
+package com.zcloud.gbs.edgeguard.command;
+
+import com.alibaba.cola.exception.BizException;
+import com.zcloud.gbs.edgeguard.domain.gateway.RegionCameraGateway;
+import com.zcloud.gbs.edgeguard.domain.gateway.RegionGateway;
+import com.zcloud.gbs.edgeguard.domain.model.RegionE;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+@Component
+@RequiredArgsConstructor
+public class RegionRemoveExe {
+ private final RegionGateway regionGateway;
+ private final RegionCameraGateway regionCameraGateway;
+ private final RegionAccessService accessService;
+
+ @Transactional(rollbackFor = Exception.class)
+ public void execute(Long id) {
+ RegionE region = regionGateway.getById(id);
+ if (region == null) {
+ throw new BizException("区域不存在");
+ }
+ accessService.requireAccessibleCorp(region.getCorpId());
+ if (regionGateway.existsChild(id)) {
+ throw new BizException("区域存在下级区域,不能删除");
+ }
+ if (regionCameraGateway.existsBoundByRegionId(id)) {
+ throw new BizException("区域仍绑定有效摄像头,不能删除");
+ }
+ if (!regionGateway.deleteById(id)) {
+ throw new BizException("删除区域失败");
+ }
+ }
+}
diff --git a/web-app/src/main/java/com/zcloud/gbs/edgeguard/command/RegionUpdateExe.java b/web-app/src/main/java/com/zcloud/gbs/edgeguard/command/RegionUpdateExe.java
new file mode 100644
index 0000000..d7d6604
--- /dev/null
+++ b/web-app/src/main/java/com/zcloud/gbs/edgeguard/command/RegionUpdateExe.java
@@ -0,0 +1,50 @@
+package com.zcloud.gbs.edgeguard.command;
+
+import com.alibaba.cola.exception.BizException;
+import com.alibaba.fastjson.JSON;
+import com.zcloud.gbs.edgeguard.domain.gateway.RegionGateway;
+import com.zcloud.gbs.edgeguard.domain.model.RegionE;
+import com.zcloud.gbs.edgeguard.domain.service.RegionDomainService;
+import com.zcloud.gbs.edgeguard.dto.RegionUpdateCmd;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+@Component
+@RequiredArgsConstructor
+public class RegionUpdateExe {
+ private final RegionGateway regionGateway;
+ private final RegionAccessService accessService;
+ private final RegionCommandSupport support;
+ private final RegionDomainService domainService;
+
+ @Transactional(rollbackFor = Exception.class)
+ public void execute(Long id, RegionUpdateCmd cmd) {
+ RegionE current = regionGateway.getById(id);
+ if (current == null) {
+ throw new BizException("区域不存在");
+ }
+ accessService.requireAccessibleCorp(current.getCorpId());
+ String regionName = support.normalizeName(cmd.getRegionName());
+ if (regionGateway.existsSiblingName(current.getCorpId(), current.getParentId(), regionName, id)) {
+ throw new BizException("同级区域名称已存在");
+ }
+ support.validateLeader(cmd.getLeaderUserId(), current.getCorpId());
+ support.validateFence(cmd.getCoordinateSystem(), cmd.getFencePoints());
+ domainService.validateStatus(cmd.getRegionStatus());
+
+ RegionE changed = new RegionE();
+ changed.setId(id);
+ changed.setVersion(cmd.getVersion());
+ changed.setRegionName(regionName);
+ changed.setLeaderUserId(cmd.getLeaderUserId());
+ changed.setCoordinateSystem(support.normalizeCoordinateSystem(cmd.getCoordinateSystem()));
+ changed.setFencePoints(JSON.toJSONString(cmd.getFencePoints()));
+ changed.setSortNo(cmd.getSortNo() == null ? 0 : cmd.getSortNo());
+ changed.setRegionStatus(cmd.getRegionStatus());
+ changed.setRemarks(cmd.getRemarks());
+ if (!regionGateway.update(changed)) {
+ throw new BizException("区域已被其他用户修改,请刷新后重试");
+ }
+ }
+}
diff --git a/web-app/src/main/java/com/zcloud/gbs/edgeguard/command/query/RegionQueryExe.java b/web-app/src/main/java/com/zcloud/gbs/edgeguard/command/query/RegionQueryExe.java
new file mode 100644
index 0000000..d4c6b12
--- /dev/null
+++ b/web-app/src/main/java/com/zcloud/gbs/edgeguard/command/query/RegionQueryExe.java
@@ -0,0 +1,142 @@
+package com.zcloud.gbs.edgeguard.command.query;
+
+import com.alibaba.cola.dto.MultiResponse;
+import com.alibaba.cola.dto.SingleResponse;
+import com.alibaba.cola.exception.BizException;
+import com.alibaba.fastjson.JSON;
+import com.zcloud.gbs.edgeguard.command.RegionAccessService;
+import com.zcloud.gbs.edgeguard.domain.gateway.RegionGateway;
+import com.zcloud.gbs.edgeguard.domain.gateway.UserGateway;
+import com.zcloud.gbs.edgeguard.domain.model.CorpE;
+import com.zcloud.gbs.edgeguard.domain.model.RegionE;
+import com.zcloud.gbs.edgeguard.domain.model.UserE;
+import com.zcloud.gbs.edgeguard.dto.clientobject.FencePointCo;
+import com.zcloud.gbs.edgeguard.dto.clientobject.RegionCo;
+import com.zcloud.gbs.edgeguard.dto.clientobject.RegionTreeNodeCo;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Component
+@RequiredArgsConstructor
+public class RegionQueryExe {
+ private final RegionGateway regionGateway;
+ private final UserGateway userGateway;
+ private final RegionAccessService accessService;
+
+ public MultiResponse tree(Long corpId) {
+ List corps;
+ if (corpId == null) {
+ corps = new ArrayList<>(accessService.accessibleCorps());
+ } else {
+ corps = new ArrayList<>(Collections.singletonList(accessService.requireAccessibleCorp(corpId)));
+ }
+ List corpIds = new ArrayList<>();
+ for (CorpE corp : corps) {
+ corpIds.add(corp.getId());
+ }
+ List regions = regionGateway.listByCorpIds(corpIds);
+ Map> byCorp = new HashMap<>();
+ for (RegionE region : regions) {
+ byCorp.computeIfAbsent(region.getCorpId(), key -> new ArrayList<>()).add(region);
+ }
+ List roots = new ArrayList<>();
+ corps.sort(Comparator.comparing(CorpE::getCorpName, Comparator.nullsLast(String::compareTo))
+ .thenComparing(CorpE::getId));
+ for (CorpE corp : corps) {
+ RegionTreeNodeCo root = corpNode(corp);
+ root.setChildren(buildRegionTree(byCorp.getOrDefault(corp.getId(), Collections.emptyList())));
+ roots.add(root);
+ }
+ return MultiResponse.of(roots);
+ }
+
+ public SingleResponse detail(Long id) {
+ RegionE region = regionGateway.getById(id);
+ if (region == null) {
+ throw new BizException("区域不存在");
+ }
+ CorpE corp = accessService.requireAccessibleCorp(region.getCorpId());
+ UserE leader = userGateway.getById(region.getLeaderUserId());
+ RegionCo result = new RegionCo();
+ result.setId(region.getId());
+ result.setRegionCode(region.getRegionCode());
+ result.setRegionName(region.getRegionName());
+ result.setCorpId(region.getCorpId());
+ result.setCorpName(corp.getCorpName());
+ result.setParentId(region.getParentId());
+ result.setTreePath(region.getTreePath());
+ result.setTreeLevel(region.getTreeLevel());
+ result.setSortNo(region.getSortNo());
+ result.setLeaderUserId(region.getLeaderUserId());
+ result.setLeaderUserName(leader == null ? null : leader.getName());
+ result.setCoordinateSystem(region.getCoordinateSystem());
+ result.setFencePoints(JSON.parseArray(region.getFencePoints(), FencePointCo.class));
+ result.setRegionStatus(region.getRegionStatus());
+ result.setVersion(region.getVersion());
+ result.setRemarks(region.getRemarks());
+ return SingleResponse.of(result);
+ }
+
+ private List buildRegionTree(List regions) {
+ Map nodes = new LinkedHashMap<>();
+ for (RegionE region : regions) {
+ nodes.put(region.getId(), regionNode(region));
+ }
+ List roots = new ArrayList<>();
+ for (RegionE region : regions) {
+ RegionTreeNodeCo node = nodes.get(region.getId());
+ Long parentId = region.getParentId();
+ RegionTreeNodeCo parent = parentId == null || parentId == 0 ? null : nodes.get(parentId);
+ if (parent == null) {
+ roots.add(node);
+ } else {
+ parent.getChildren().add(node);
+ }
+ }
+ sortRecursively(roots);
+ return roots;
+ }
+
+ private void sortRecursively(List nodes) {
+ nodes.sort(Comparator.comparing(RegionTreeNodeCo::getSortNo, Comparator.nullsFirst(Integer::compareTo))
+ .thenComparing(RegionTreeNodeCo::getId));
+ for (RegionTreeNodeCo node : nodes) {
+ sortRecursively(node.getChildren());
+ }
+ }
+
+ private RegionTreeNodeCo corpNode(CorpE corp) {
+ RegionTreeNodeCo node = new RegionTreeNodeCo();
+ node.setNodeId("C-" + corp.getId());
+ node.setNodeType("CORP");
+ node.setId(corp.getId());
+ node.setCorpId(corp.getId());
+ node.setNodeName(corp.getCorpName());
+ node.setTreeLevel(0);
+ node.setSortNo(0);
+ return node;
+ }
+
+ private RegionTreeNodeCo regionNode(RegionE region) {
+ RegionTreeNodeCo node = new RegionTreeNodeCo();
+ node.setNodeId("R-" + region.getId());
+ node.setNodeType("REGION");
+ node.setId(region.getId());
+ node.setCorpId(region.getCorpId());
+ node.setParentId(region.getParentId());
+ node.setNodeName(region.getRegionName());
+ node.setRegionCode(region.getRegionCode());
+ node.setTreeLevel(region.getTreeLevel());
+ node.setSortNo(region.getSortNo());
+ node.setRegionStatus(region.getRegionStatus());
+ return node;
+ }
+}
diff --git a/web-app/src/main/java/com/zcloud/gbs/edgeguard/service/RegionServiceImpl.java b/web-app/src/main/java/com/zcloud/gbs/edgeguard/service/RegionServiceImpl.java
new file mode 100644
index 0000000..a88e85e
--- /dev/null
+++ b/web-app/src/main/java/com/zcloud/gbs/edgeguard/service/RegionServiceImpl.java
@@ -0,0 +1,52 @@
+package com.zcloud.gbs.edgeguard.service;
+
+import com.alibaba.cola.dto.MultiResponse;
+import com.alibaba.cola.dto.Response;
+import com.alibaba.cola.dto.SingleResponse;
+import com.zcloud.gbs.edgeguard.api.RegionServiceI;
+import com.zcloud.gbs.edgeguard.command.RegionAddExe;
+import com.zcloud.gbs.edgeguard.command.RegionRemoveExe;
+import com.zcloud.gbs.edgeguard.command.RegionUpdateExe;
+import com.zcloud.gbs.edgeguard.command.query.RegionQueryExe;
+import com.zcloud.gbs.edgeguard.dto.RegionAddCmd;
+import com.zcloud.gbs.edgeguard.dto.RegionUpdateCmd;
+import com.zcloud.gbs.edgeguard.dto.clientobject.RegionCo;
+import com.zcloud.gbs.edgeguard.dto.clientobject.RegionTreeNodeCo;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class RegionServiceImpl implements RegionServiceI {
+ private final RegionQueryExe queryExe;
+ private final RegionAddExe addExe;
+ private final RegionUpdateExe updateExe;
+ private final RegionRemoveExe removeExe;
+
+ @Override
+ public MultiResponse tree(Long corpId) {
+ return queryExe.tree(corpId);
+ }
+
+ @Override
+ public SingleResponse getById(Long id) {
+ return queryExe.detail(id);
+ }
+
+ @Override
+ public SingleResponse add(RegionAddCmd cmd) {
+ return SingleResponse.of(addExe.execute(cmd));
+ }
+
+ @Override
+ public Response update(Long id, RegionUpdateCmd cmd) {
+ updateExe.execute(id, cmd);
+ return Response.buildSuccess();
+ }
+
+ @Override
+ public Response remove(Long id) {
+ removeExe.execute(id);
+ return Response.buildSuccess();
+ }
+}
diff --git a/web-client/src/main/java/com/zcloud/gbs/edgeguard/api/RegionServiceI.java b/web-client/src/main/java/com/zcloud/gbs/edgeguard/api/RegionServiceI.java
new file mode 100644
index 0000000..6d12764
--- /dev/null
+++ b/web-client/src/main/java/com/zcloud/gbs/edgeguard/api/RegionServiceI.java
@@ -0,0 +1,17 @@
+package com.zcloud.gbs.edgeguard.api;
+
+import com.alibaba.cola.dto.MultiResponse;
+import com.alibaba.cola.dto.Response;
+import com.alibaba.cola.dto.SingleResponse;
+import com.zcloud.gbs.edgeguard.dto.RegionAddCmd;
+import com.zcloud.gbs.edgeguard.dto.RegionUpdateCmd;
+import com.zcloud.gbs.edgeguard.dto.clientobject.RegionCo;
+import com.zcloud.gbs.edgeguard.dto.clientobject.RegionTreeNodeCo;
+
+public interface RegionServiceI {
+ MultiResponse tree(Long corpId);
+ SingleResponse getById(Long id);
+ SingleResponse add(RegionAddCmd cmd);
+ Response update(Long id, RegionUpdateCmd cmd);
+ Response remove(Long id);
+}
diff --git a/web-client/src/main/java/com/zcloud/gbs/edgeguard/dto/FencePointCmd.java b/web-client/src/main/java/com/zcloud/gbs/edgeguard/dto/FencePointCmd.java
new file mode 100644
index 0000000..272590a
--- /dev/null
+++ b/web-client/src/main/java/com/zcloud/gbs/edgeguard/dto/FencePointCmd.java
@@ -0,0 +1,29 @@
+package com.zcloud.gbs.edgeguard.dto;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.DecimalMax;
+import javax.validation.constraints.DecimalMin;
+import javax.validation.constraints.NotNull;
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+@Data
+public class FencePointCmd implements Serializable {
+ @NotNull(message = "围栏点序号不能为空")
+ @ApiModelProperty(value = "点序号,1至4", required = true)
+ private Integer seq;
+
+ @NotNull(message = "经度不能为空")
+ @DecimalMin(value = "-180", message = "经度不能小于-180")
+ @DecimalMax(value = "180", message = "经度不能大于180")
+ @ApiModelProperty(value = "经度", required = true)
+ private BigDecimal longitude;
+
+ @NotNull(message = "纬度不能为空")
+ @DecimalMin(value = "-90", message = "纬度不能小于-90")
+ @DecimalMax(value = "90", message = "纬度不能大于90")
+ @ApiModelProperty(value = "纬度", required = true)
+ private BigDecimal latitude;
+}
diff --git a/web-client/src/main/java/com/zcloud/gbs/edgeguard/dto/RegionAddCmd.java b/web-client/src/main/java/com/zcloud/gbs/edgeguard/dto/RegionAddCmd.java
new file mode 100644
index 0000000..06bf681
--- /dev/null
+++ b/web-client/src/main/java/com/zcloud/gbs/edgeguard/dto/RegionAddCmd.java
@@ -0,0 +1,49 @@
+package com.zcloud.gbs.edgeguard.dto;
+
+import com.alibaba.cola.dto.Command;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Size;
+import java.util.List;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class RegionAddCmd extends Command {
+ @NotNull(message = "所属企业不能为空")
+ @ApiModelProperty(value = "所属企业ID", required = true)
+ private Long corpId;
+
+ @ApiModelProperty("父区域ID,为空或0表示顶级区域")
+ private Long parentId;
+
+ @NotBlank(message = "区域名称不能为空")
+ @Size(max = 100, message = "区域名称不能超过100个字符")
+ @ApiModelProperty(value = "区域名称", required = true)
+ private String regionName;
+
+ @NotNull(message = "区域负责人不能为空")
+ @ApiModelProperty(value = "区域负责人用户ID", required = true)
+ private Long leaderUserId;
+
+ @NotBlank(message = "坐标系不能为空")
+ @ApiModelProperty(value = "坐标系:WGS84、GCJ02或BD09", required = true)
+ private String coordinateSystem;
+
+ @Valid
+ @NotNull(message = "电子围栏不能为空")
+ @Size(min = 4, max = 4, message = "电子围栏必须包含4个点")
+ @ApiModelProperty(value = "四个有序围栏点", required = true)
+ private List fencePoints;
+
+ @ApiModelProperty("同级排序号")
+ private Integer sortNo;
+
+ @Size(max = 255, message = "备注不能超过255个字符")
+ @ApiModelProperty("备注")
+ private String remarks;
+}
diff --git a/web-client/src/main/java/com/zcloud/gbs/edgeguard/dto/RegionUpdateCmd.java b/web-client/src/main/java/com/zcloud/gbs/edgeguard/dto/RegionUpdateCmd.java
new file mode 100644
index 0000000..c1c7119
--- /dev/null
+++ b/web-client/src/main/java/com/zcloud/gbs/edgeguard/dto/RegionUpdateCmd.java
@@ -0,0 +1,44 @@
+package com.zcloud.gbs.edgeguard.dto;
+
+import com.alibaba.cola.dto.Command;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Size;
+import java.util.List;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class RegionUpdateCmd extends Command {
+ @NotBlank(message = "区域名称不能为空")
+ @Size(max = 100, message = "区域名称不能超过100个字符")
+ private String regionName;
+
+ @NotNull(message = "区域负责人不能为空")
+ private Long leaderUserId;
+
+ @NotBlank(message = "坐标系不能为空")
+ private String coordinateSystem;
+
+ @Valid
+ @NotNull(message = "电子围栏不能为空")
+ @Size(min = 4, max = 4, message = "电子围栏必须包含4个点")
+ private List fencePoints;
+
+ private Integer sortNo;
+
+ @NotBlank(message = "区域状态不能为空")
+ @ApiModelProperty(value = "ENABLED或DISABLED", required = true)
+ private String regionStatus;
+
+ @NotNull(message = "版本号不能为空")
+ @ApiModelProperty(value = "乐观锁版本号", required = true)
+ private Integer version;
+
+ @Size(max = 255, message = "备注不能超过255个字符")
+ private String remarks;
+}
diff --git a/web-client/src/main/java/com/zcloud/gbs/edgeguard/dto/clientobject/FencePointCo.java b/web-client/src/main/java/com/zcloud/gbs/edgeguard/dto/clientobject/FencePointCo.java
new file mode 100644
index 0000000..59093aa
--- /dev/null
+++ b/web-client/src/main/java/com/zcloud/gbs/edgeguard/dto/clientobject/FencePointCo.java
@@ -0,0 +1,13 @@
+package com.zcloud.gbs.edgeguard.dto.clientobject;
+
+import com.alibaba.cola.dto.ClientObject;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+public class FencePointCo extends ClientObject {
+ private Integer seq;
+ private BigDecimal longitude;
+ private BigDecimal latitude;
+}
diff --git a/web-client/src/main/java/com/zcloud/gbs/edgeguard/dto/clientobject/RegionCo.java b/web-client/src/main/java/com/zcloud/gbs/edgeguard/dto/clientobject/RegionCo.java
new file mode 100644
index 0000000..65a3e7a
--- /dev/null
+++ b/web-client/src/main/java/com/zcloud/gbs/edgeguard/dto/clientobject/RegionCo.java
@@ -0,0 +1,26 @@
+package com.zcloud.gbs.edgeguard.dto.clientobject;
+
+import com.alibaba.cola.dto.ClientObject;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class RegionCo extends ClientObject {
+ private Long id;
+ private String regionCode;
+ private String regionName;
+ private Long corpId;
+ private String corpName;
+ private Long parentId;
+ private String treePath;
+ private Integer treeLevel;
+ private Integer sortNo;
+ private Long leaderUserId;
+ private String leaderUserName;
+ private String coordinateSystem;
+ private List fencePoints;
+ private String regionStatus;
+ private Integer version;
+ private String remarks;
+}
diff --git a/web-client/src/main/java/com/zcloud/gbs/edgeguard/dto/clientobject/RegionTreeNodeCo.java b/web-client/src/main/java/com/zcloud/gbs/edgeguard/dto/clientobject/RegionTreeNodeCo.java
new file mode 100644
index 0000000..4fdbd7d
--- /dev/null
+++ b/web-client/src/main/java/com/zcloud/gbs/edgeguard/dto/clientobject/RegionTreeNodeCo.java
@@ -0,0 +1,23 @@
+package com.zcloud.gbs.edgeguard.dto.clientobject;
+
+import com.alibaba.cola.dto.ClientObject;
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Data
+public class RegionTreeNodeCo extends ClientObject {
+ /** CORP节点使用C-{corpId},REGION节点使用R-{regionId},避免ID碰撞。 */
+ private String nodeId;
+ private String nodeType;
+ private Long id;
+ private Long corpId;
+ private Long parentId;
+ private String nodeName;
+ private String regionCode;
+ private Integer treeLevel;
+ private Integer sortNo;
+ private String regionStatus;
+ private List children = new ArrayList<>();
+}
diff --git a/web-domain/pom.xml b/web-domain/pom.xml
index 1040645..f294de3 100644
--- a/web-domain/pom.xml
+++ b/web-domain/pom.xml
@@ -22,5 +22,11 @@
com.jjb.saas
jjb-saas-base-starter
+
+ org.junit.vintage
+ junit-vintage-engine
+ 5.3.1
+ test
+
diff --git a/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/gateway/CorpGateway.java b/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/gateway/CorpGateway.java
new file mode 100644
index 0000000..03caeea
--- /dev/null
+++ b/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/gateway/CorpGateway.java
@@ -0,0 +1,10 @@
+package com.zcloud.gbs.edgeguard.domain.gateway;
+
+import com.zcloud.gbs.edgeguard.domain.model.CorpE;
+
+import java.util.List;
+
+/** 当前调用人的可访问企业,由公共企业服务提供。 */
+public interface CorpGateway {
+ List listAccessibleCorps();
+}
diff --git a/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/gateway/RegionCameraGateway.java b/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/gateway/RegionCameraGateway.java
index 118eae9..c6c039e 100644
--- a/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/gateway/RegionCameraGateway.java
+++ b/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/gateway/RegionCameraGateway.java
@@ -7,4 +7,5 @@ public interface RegionCameraGateway {
Boolean update(RegionCameraE regionCamera);
Boolean deleteById(Long id);
RegionCameraE getById(Long id);
+ boolean existsBoundByRegionId(Long regionId);
}
diff --git a/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/gateway/RegionGateway.java b/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/gateway/RegionGateway.java
index ad06e93..41f93b7 100644
--- a/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/gateway/RegionGateway.java
+++ b/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/gateway/RegionGateway.java
@@ -2,9 +2,15 @@ package com.zcloud.gbs.edgeguard.domain.gateway;
import com.zcloud.gbs.edgeguard.domain.model.RegionE;
+import java.util.Collection;
+import java.util.List;
+
public interface RegionGateway {
- Boolean add(RegionE region);
+ Long add(RegionE region);
Boolean update(RegionE region);
Boolean deleteById(Long id);
RegionE getById(Long id);
+ List listByCorpIds(Collection corpIds);
+ boolean existsSiblingName(Long corpId, Long parentId, String regionName, Long excludeId);
+ boolean existsChild(Long parentId);
}
diff --git a/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/gateway/UserGateway.java b/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/gateway/UserGateway.java
new file mode 100644
index 0000000..c5c5e10
--- /dev/null
+++ b/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/gateway/UserGateway.java
@@ -0,0 +1,11 @@
+package com.zcloud.gbs.edgeguard.domain.gateway;
+
+import com.zcloud.gbs.edgeguard.domain.model.UserE;
+
+import java.util.Collection;
+import java.util.List;
+
+public interface UserGateway {
+ UserE getById(Long id);
+ List listByIds(Collection ids);
+}
diff --git a/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/model/CorpE.java b/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/model/CorpE.java
new file mode 100644
index 0000000..94a0f27
--- /dev/null
+++ b/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/model/CorpE.java
@@ -0,0 +1,13 @@
+package com.zcloud.gbs.edgeguard.domain.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class CorpE {
+ private Long id;
+ private String corpName;
+}
diff --git a/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/model/RegionE.java b/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/model/RegionE.java
index 56600a6..f621016 100644
--- a/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/model/RegionE.java
+++ b/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/model/RegionE.java
@@ -23,4 +23,6 @@ public class RegionE extends BaseE {
/** 四个有序坐标点的 JSON 数组。 */
private String fencePoints;
private String regionStatus;
+ private Integer version;
+ private String remarks;
}
diff --git a/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/model/UserE.java b/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/model/UserE.java
new file mode 100644
index 0000000..bdee7d8
--- /dev/null
+++ b/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/model/UserE.java
@@ -0,0 +1,15 @@
+package com.zcloud.gbs.edgeguard.domain.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class UserE {
+ private Long id;
+ private String name;
+ private Long corpId;
+ private boolean active;
+}
diff --git a/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/service/RegionDomainService.java b/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/service/RegionDomainService.java
new file mode 100644
index 0000000..34a08bd
--- /dev/null
+++ b/web-domain/src/main/java/com/zcloud/gbs/edgeguard/domain/service/RegionDomainService.java
@@ -0,0 +1,114 @@
+package com.zcloud.gbs.edgeguard.domain.service;
+
+import com.alibaba.cola.exception.BizException;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+@Service
+public class RegionDomainService {
+ private static final Set COORDINATE_SYSTEMS = new HashSet<>();
+ private static final Set REGION_STATUSES = new HashSet<>();
+
+ static {
+ COORDINATE_SYSTEMS.add("WGS84");
+ COORDINATE_SYSTEMS.add("GCJ02");
+ COORDINATE_SYSTEMS.add("BD09");
+ REGION_STATUSES.add("ENABLED");
+ REGION_STATUSES.add("DISABLED");
+ }
+
+ public void validateCoordinateSystem(String coordinateSystem) {
+ if (!COORDINATE_SYSTEMS.contains(coordinateSystem)) {
+ throw new BizException("不支持的坐标系,仅支持WGS84、GCJ02或BD09");
+ }
+ }
+
+ public void validateStatus(String status) {
+ if (!REGION_STATUSES.contains(status)) {
+ throw new BizException("区域状态仅支持ENABLED或DISABLED");
+ }
+ }
+
+ /** 点须按1~4排序,首尾隐式闭合;同时拒绝重复点、零面积和自相交四边形。 */
+ public void validateFence(List points) {
+ if (points == null || points.size() != 4) {
+ throw new BizException("电子围栏必须包含4个点");
+ }
+ Set sequences = new HashSet<>();
+ Set coordinates = new HashSet<>();
+ for (Point point : points) {
+ if (point == null || point.seq == null || point.longitude == null || point.latitude == null) {
+ throw new BizException("电子围栏坐标不能为空");
+ }
+ if (point.seq < 1 || point.seq > 4 || !sequences.add(point.seq)) {
+ throw new BizException("电子围栏点序号必须为不重复的1至4");
+ }
+ if (point.longitude.compareTo(BigDecimal.valueOf(-180)) < 0
+ || point.longitude.compareTo(BigDecimal.valueOf(180)) > 0
+ || point.latitude.compareTo(BigDecimal.valueOf(-90)) < 0
+ || point.latitude.compareTo(BigDecimal.valueOf(90)) > 0) {
+ throw new BizException("电子围栏坐标超出合法范围");
+ }
+ if (!coordinates.add(point.longitude.stripTrailingZeros() + ":" + point.latitude.stripTrailingZeros())) {
+ throw new BizException("电子围栏坐标点不能重复");
+ }
+ }
+ points.sort((a, b) -> a.seq.compareTo(b.seq));
+ if (cross(points.get(0), points.get(1), points.get(2), points.get(3))
+ || cross(points.get(1), points.get(2), points.get(3), points.get(0))) {
+ throw new BizException("电子围栏边不能自相交");
+ }
+ BigDecimal twiceArea = BigDecimal.ZERO;
+ for (int i = 0; i < points.size(); i++) {
+ Point current = points.get(i);
+ Point next = points.get((i + 1) % points.size());
+ twiceArea = twiceArea.add(current.longitude.multiply(next.latitude)
+ .subtract(next.longitude.multiply(current.latitude)));
+ }
+ if (twiceArea.compareTo(BigDecimal.ZERO) == 0) {
+ throw new BizException("电子围栏面积不能为0");
+ }
+ }
+
+ private boolean cross(Point a, Point b, Point c, Point d) {
+ BigDecimal d1 = direction(a, b, c);
+ BigDecimal d2 = direction(a, b, d);
+ BigDecimal d3 = direction(c, d, a);
+ BigDecimal d4 = direction(c, d, b);
+ if (d1.signum() * d2.signum() < 0 && d3.signum() * d4.signum() < 0) {
+ return true;
+ }
+ return d1.signum() == 0 && onSegment(a, b, c)
+ || d2.signum() == 0 && onSegment(a, b, d)
+ || d3.signum() == 0 && onSegment(c, d, a)
+ || d4.signum() == 0 && onSegment(c, d, b);
+ }
+
+ private boolean onSegment(Point a, Point b, Point point) {
+ return point.longitude.compareTo(a.longitude.min(b.longitude)) >= 0
+ && point.longitude.compareTo(a.longitude.max(b.longitude)) <= 0
+ && point.latitude.compareTo(a.latitude.min(b.latitude)) >= 0
+ && point.latitude.compareTo(a.latitude.max(b.latitude)) <= 0;
+ }
+
+ private BigDecimal direction(Point a, Point b, Point c) {
+ return b.longitude.subtract(a.longitude).multiply(c.latitude.subtract(a.latitude))
+ .subtract(b.latitude.subtract(a.latitude).multiply(c.longitude.subtract(a.longitude)));
+ }
+
+ public static class Point {
+ private final Integer seq;
+ private final BigDecimal longitude;
+ private final BigDecimal latitude;
+
+ public Point(Integer seq, BigDecimal longitude, BigDecimal latitude) {
+ this.seq = seq;
+ this.longitude = longitude;
+ this.latitude = latitude;
+ }
+ }
+}
diff --git a/web-domain/src/test/java/com/zcloud/gbs/edgeguard/domain/service/RegionDomainServiceTest.java b/web-domain/src/test/java/com/zcloud/gbs/edgeguard/domain/service/RegionDomainServiceTest.java
new file mode 100644
index 0000000..b4f53f4
--- /dev/null
+++ b/web-domain/src/test/java/com/zcloud/gbs/edgeguard/domain/service/RegionDomainServiceTest.java
@@ -0,0 +1,64 @@
+package com.zcloud.gbs.edgeguard.domain.service;
+
+import com.alibaba.cola.exception.BizException;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public class RegionDomainServiceTest {
+ private final RegionDomainService service = new RegionDomainService();
+
+ @Test
+ public void shouldAcceptValidFenceAndSortBySequence() {
+ List points = new ArrayList<>(Arrays.asList(
+ point(3, "113.2", "23.2"),
+ point(1, "113.1", "23.1"),
+ point(4, "113.1", "23.2"),
+ point(2, "113.2", "23.1")
+ ));
+
+ service.validateFence(points);
+ }
+
+ @Test
+ public void shouldRejectSelfIntersectingFence() {
+ List points = new ArrayList<>(Arrays.asList(
+ point(1, "0", "0"),
+ point(2, "1", "1"),
+ point(3, "0", "1"),
+ point(4, "1", "0")
+ ));
+
+ try {
+ service.validateFence(points);
+ Assert.fail("自相交围栏应校验失败");
+ } catch (BizException expected) {
+ Assert.assertEquals("电子围栏边不能自相交", expected.getMessage());
+ }
+ }
+
+ @Test
+ public void shouldRejectDuplicateSequence() {
+ List points = new ArrayList<>(Arrays.asList(
+ point(1, "0", "0"),
+ point(1, "1", "0"),
+ point(3, "1", "1"),
+ point(4, "0", "1")
+ ));
+
+ try {
+ service.validateFence(points);
+ Assert.fail("重复序号应校验失败");
+ } catch (BizException expected) {
+ Assert.assertEquals("电子围栏点序号必须为不重复的1至4", expected.getMessage());
+ }
+ }
+
+ private RegionDomainService.Point point(int seq, String longitude, String latitude) {
+ return new RegionDomainService.Point(seq, new BigDecimal(longitude), new BigDecimal(latitude));
+ }
+}
diff --git a/web-infrastructure/src/main/java/com/zcloud/gbs/edgeguard/gatewayimpl/CorpGatewayImpl.java b/web-infrastructure/src/main/java/com/zcloud/gbs/edgeguard/gatewayimpl/CorpGatewayImpl.java
new file mode 100644
index 0000000..16bf772
--- /dev/null
+++ b/web-infrastructure/src/main/java/com/zcloud/gbs/edgeguard/gatewayimpl/CorpGatewayImpl.java
@@ -0,0 +1,38 @@
+package com.zcloud.gbs.edgeguard.gatewayimpl;
+
+import com.alibaba.cola.dto.MultiResponse;
+import com.alibaba.cola.exception.BizException;
+import com.zcloud.gbs.edgeguard.domain.gateway.CorpGateway;
+import com.zcloud.gbs.edgeguard.domain.model.CorpE;
+import com.zcloud.gbscommon.zcloudcorp.face.ZcloudCorpFacade;
+import com.zcloud.gbscommon.zcloudcorp.response.ZcloudCorpInfoCO;
+import org.apache.dubbo.config.annotation.DubboReference;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+@Service
+public class CorpGatewayImpl implements CorpGateway {
+ @DubboReference(check = false)
+ private ZcloudCorpFacade corpFacade;
+
+ @Override
+ public List listAccessibleCorps() {
+ MultiResponse response = corpFacade.getCorpList();
+ if (response == null || !response.isSuccess()) {
+ throw new BizException("获取当前用户可访问企业失败");
+ }
+ if (response.getData() == null) {
+ return Collections.emptyList();
+ }
+ List result = new ArrayList<>();
+ for (ZcloudCorpInfoCO item : response.getData()) {
+ if (item != null && item.getId() != null && !"true".equalsIgnoreCase(item.getDeleteEnum())) {
+ result.add(new CorpE(item.getId(), item.getCorpName()));
+ }
+ }
+ return result;
+ }
+}
diff --git a/web-infrastructure/src/main/java/com/zcloud/gbs/edgeguard/gatewayimpl/RegionCameraGatewayImpl.java b/web-infrastructure/src/main/java/com/zcloud/gbs/edgeguard/gatewayimpl/RegionCameraGatewayImpl.java
index d4f4b36..0a8717a 100644
--- a/web-infrastructure/src/main/java/com/zcloud/gbs/edgeguard/gatewayimpl/RegionCameraGatewayImpl.java
+++ b/web-infrastructure/src/main/java/com/zcloud/gbs/edgeguard/gatewayimpl/RegionCameraGatewayImpl.java
@@ -33,6 +33,14 @@ public class RegionCameraGatewayImpl implements RegionCameraGateway {
return toEntity(repository.getById(id));
}
+ @Override
+ public boolean existsBoundByRegionId(Long regionId) {
+ return repository.lambdaQuery()
+ .eq(RegionCameraDO::getRegionId, regionId)
+ .eq(RegionCameraDO::getBindStatus, "BOUND")
+ .count() > 0;
+ }
+
private RegionCameraDO toDO(RegionCameraE source) {
RegionCameraDO target = new RegionCameraDO();
BeanUtils.copyProperties(source, target);
diff --git a/web-infrastructure/src/main/java/com/zcloud/gbs/edgeguard/gatewayimpl/RegionGatewayImpl.java b/web-infrastructure/src/main/java/com/zcloud/gbs/edgeguard/gatewayimpl/RegionGatewayImpl.java
index d9949e6..6f35309 100644
--- a/web-infrastructure/src/main/java/com/zcloud/gbs/edgeguard/gatewayimpl/RegionGatewayImpl.java
+++ b/web-infrastructure/src/main/java/com/zcloud/gbs/edgeguard/gatewayimpl/RegionGatewayImpl.java
@@ -8,14 +8,23 @@ import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
@Service
@RequiredArgsConstructor
public class RegionGatewayImpl implements RegionGateway {
private final RegionRepository repository;
@Override
- public Boolean add(RegionE region) {
- return repository.save(toDO(region));
+ public Long add(RegionE region) {
+ RegionDO data = toDO(region);
+ if (!repository.save(data)) {
+ return null;
+ }
+ return data.getId();
}
@Override
@@ -33,6 +42,37 @@ public class RegionGatewayImpl implements RegionGateway {
return toEntity(repository.getById(id));
}
+ @Override
+ public List listByCorpIds(Collection corpIds) {
+ if (corpIds == null || corpIds.isEmpty()) {
+ return Collections.emptyList();
+ }
+ List rows = repository.lambdaQuery()
+ .in(RegionDO::getCorpId, corpIds)
+ .orderByAsc(RegionDO::getCorpId, RegionDO::getTreeLevel, RegionDO::getSortNo, RegionDO::getId)
+ .list();
+ List result = new ArrayList<>();
+ for (RegionDO row : rows) {
+ result.add(toEntity(row));
+ }
+ return result;
+ }
+
+ @Override
+ public boolean existsSiblingName(Long corpId, Long parentId, String regionName, Long excludeId) {
+ return repository.lambdaQuery()
+ .eq(RegionDO::getCorpId, corpId)
+ .eq(RegionDO::getParentId, parentId)
+ .eq(RegionDO::getRegionName, regionName)
+ .ne(excludeId != null, RegionDO::getId, excludeId)
+ .count() > 0;
+ }
+
+ @Override
+ public boolean existsChild(Long parentId) {
+ return repository.lambdaQuery().eq(RegionDO::getParentId, parentId).count() > 0;
+ }
+
private RegionDO toDO(RegionE source) {
RegionDO target = new RegionDO();
BeanUtils.copyProperties(source, target);
diff --git a/web-infrastructure/src/main/java/com/zcloud/gbs/edgeguard/gatewayimpl/UserGatewayImpl.java b/web-infrastructure/src/main/java/com/zcloud/gbs/edgeguard/gatewayimpl/UserGatewayImpl.java
new file mode 100644
index 0000000..8e02b61
--- /dev/null
+++ b/web-infrastructure/src/main/java/com/zcloud/gbs/edgeguard/gatewayimpl/UserGatewayImpl.java
@@ -0,0 +1,60 @@
+package com.zcloud.gbs.edgeguard.gatewayimpl;
+
+import com.alibaba.cola.dto.MultiResponse;
+import com.alibaba.cola.dto.SingleResponse;
+import com.alibaba.cola.exception.BizException;
+import com.zcloud.gbs.edgeguard.domain.gateway.UserGateway;
+import com.zcloud.gbs.edgeguard.domain.model.UserE;
+import com.zcloud.gbscommon.zclouduser.facade.ZcloudUserFacade;
+import com.zcloud.gbscommon.zclouduser.response.ZcloudUserCo;
+import org.apache.dubbo.config.annotation.DubboReference;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+@Service
+public class UserGatewayImpl implements UserGateway {
+ @DubboReference(check = false)
+ private ZcloudUserFacade userFacade;
+
+ @Override
+ public UserE getById(Long id) {
+ SingleResponse response = userFacade.getInfoByUserId(id);
+ if (response == null || !response.isSuccess()) {
+ throw new BizException("查询区域负责人失败");
+ }
+ return toEntity(response.getData());
+ }
+
+ @Override
+ public List listByIds(Collection ids) {
+ if (ids == null || ids.isEmpty()) {
+ return Collections.emptyList();
+ }
+ MultiResponse response = userFacade.listByUserIds(ids);
+ if (response == null || !response.isSuccess()) {
+ throw new BizException("批量查询区域负责人失败");
+ }
+ List result = new ArrayList<>();
+ if (response.getData() != null) {
+ for (ZcloudUserCo item : response.getData()) {
+ UserE entity = toEntity(item);
+ if (entity != null) {
+ result.add(entity);
+ }
+ }
+ }
+ return result;
+ }
+
+ private UserE toEntity(ZcloudUserCo source) {
+ if (source == null || source.getId() == null) {
+ return null;
+ }
+ return new UserE(source.getId(), source.getName(), source.getCorpinfoId(),
+ !"true".equalsIgnoreCase(source.getDeleteEnum()));
+ }
+}
diff --git a/边缘人脸识别区域安全设计方案.md b/边缘人脸识别区域安全设计方案.md
index 3f21c71..f2f7886 100644
--- a/边缘人脸识别区域安全设计方案.md
+++ b/边缘人脸识别区域安全设计方案.md
@@ -118,13 +118,13 @@ nodeType = REGION 本地区域节点
- 重复选择已经绑定到当前区域的摄像头应幂等成功。
- 已绑定其他区域时,应返回明确提示,不静默迁移。
- 解绑只删除本地关系,不调用第三方删除摄像头。
-- 在线状态等动态字段不写入本地绑定表,以第三方实时结果为准。
+- 在线状态等动态字段允许作为列表快照写入绑定表,并记录 `camera_synced_at`;第三方实时结果仍是权威数据,刷新失败时展示最近快照。
## 4. 表结构草案
-原型可以确定两张核心业务表。所有表都包含根目录 `AGENTS.md` 规定的公共字段。可执行评审的 MySQL 8.0 DDL 草案见 `web-infrastructure/src/main/resources/RegionManagementDDL.sql`。
+当前需求确定四张业务表。所有表都包含根目录 `AGENTS.md` 规定的公共字段。MySQL 8.0 DDL 见 `web-infrastructure/src/main/resources/RegionManagementDDL.sql`。
-### 4.1 区域表 `security_region`
+### 4.1 区域表 `edgeguard_region`
| 字段 | 类型建议 | 说明 |
| --- | --- | --- |
@@ -148,7 +148,7 @@ nodeType = REGION 本地区域节点
- 树查询索引:`tenant_id + corp_id + parent_id + delete_enum + sort_no`。
- 负责人查询索引:`leader_user_id`,仅在存在反向查询需求时增加。
-### 4.2 区域摄像头绑定表 `security_region_camera`
+### 4.2 区域摄像头绑定表 `edgeguard_region_camera`
| 字段 | 类型建议 | 说明 |
| --- | --- | --- |
@@ -396,3 +396,137 @@ OR
摄像头负责人从 `edgeguard_region_camera.leader_user_id` 获取,区域负责人从 `edgeguard_region.leader_user_id` 获取。负责人发生变更后按当前负责人权限查询;如果未来要求报警产生时锁定负责人,再在报警表增加负责人快照字段。
本规则只表示“能够在列表看到”,不表示系统主动通知。若后续要求短信、消息中心或待办提醒,并需要记录发送结果和防止重复通知,再设计通知记录或升级标记。
+
+## 11. 待开发接口总清单
+
+### 11.1 接口设计约定
+
+- 页面内部接口统一放在 `/edgeguard/**`,使用登录态、接口权限和数据权限。
+- 第三方开放接口统一放在 `/open/edgeguard/**`,使用独立的应用鉴权、签名、防重放和租户/企业授权范围。
+- 新增使用 `POST`,修改使用 `PUT`,删除或解绑使用 `DELETE`,详情使用 `GET`,复杂分页查询使用 `POST`。
+- 新增、修改入参使用 `XxxCmd`,分页查询使用 `XxxPageQry`,返回对象使用 `XxxCo`。
+- 单对象返回 `SingleResponse`,分页返回 `PageResponse`,集合返回 `MultiResponse`,无数据操作返回 `Response`。
+- 下表中的权限编码先使用“待分配”标识,等菜单和权限资源建立后再填写真实值,禁止复制示例权限编码。
+- `corpId`、`tenantId`、`orgId` 等数据范围不得只信任前端入参,必须结合当前用户上下文和数据权限校验。
+- 接口路径是当前开发基线;如果项目统一网关会自动增加服务前缀,Controller 中不重复拼接网关前缀。
+
+### 11.2 区域管理接口
+
+| 编号 | 方法与路径 | 用途 | 主要入参 | 主要返回 | 核心规则 | 优先级 |
+| --- | --- | --- | --- | --- | --- | --- |
+| `REGION-01` | `GET /edgeguard/regions/tree` | 加载企业虚拟根及区域树 | 可选 `corpId` | `MultiResponse` | 企业来自公共服务;区域一次批量查询后内存组树;按当前用户企业数据权限过滤 | P0 |
+| `REGION-02` | `GET /edgeguard/regions/{id}` | 查询区域详情 | `id` | `SingleResponse` | 校验数据权限;负责人姓名使用公共翻译;围栏返回有序点集合 | P0 |
+| `REGION-03` | `POST /edgeguard/regions` | 新增顶级或下级区域 | `RegionAddCmd` | `SingleResponse` | 校验父子企业一致、同级名称、负责人、坐标系及四点围栏;生成 `regionCode/treePath/treeLevel` | P0 |
+| `REGION-04` | `PUT /edgeguard/regions/{id}` | 修改区域 | `id + RegionUpdateCmd` | `Response` | 一期不提供父节点迁移;修改名称、负责人、围栏、排序和状态;使用版本号防并发覆盖 | P0 |
+| `REGION-05` | `DELETE /edgeguard/regions/{id}` | 删除区域 | `id` | `Response` | 存在子区域或有效摄像头绑定时禁止删除;使用逻辑删除 | P0 |
+
+`RegionAddCmd` 建议字段:
+
+- `corpId`、`parentId`、`regionName`、`leaderUserId`。
+- `coordinateSystem`、`List fencePoints`。
+- `sortNo`、`remarks`。
+
+`RegionUpdateCmd` 在上述可修改字段外增加 `id/version/regionStatus`。`regionCode`、`corpId`、`treePath` 和 `treeLevel` 不允许由前端直接修改。
+
+### 11.3 区域摄像头接口
+
+| 编号 | 方法与路径 | 用途 | 主要入参 | 主要返回 | 核心规则 | 优先级 |
+| --- | --- | --- | --- | --- | --- | --- |
+| `CAMERA-01` | `POST /edgeguard/region-cameras/page` | 查询区域已绑定摄像头 | `RegionCameraPageQry` | `PageResponse` | 必须传 `regionId`;从本地快照分页;支持名称、等级、在线状态;负责人批量翻译 | P0 |
+| `CAMERA-02` | `POST /edgeguard/region-cameras/candidates/page` | 查询第三方可选摄像头 | `CameraCandidatePageQry` | `PageResponse` | 通过 Infrastructure Gateway 调第三方;标记未绑定、已绑定当前区域、已绑定其他区域 | P0,依赖第三方契约 |
+| `CAMERA-03` | `PUT /edgeguard/regions/{regionId}/cameras` | 保存区域摄像头选择结果 | `regionId + RegionCameraBindCmd` | `Response` | 入参提交选中的 `cameraSource + cameraId`;服务端重新查询权威数据;计算新增和解绑差异并在本地事务保存;重复提交幂等 | P0,依赖第三方契约 |
+| `CAMERA-04` | `PUT /edgeguard/region-cameras/{id}/leader` | 设置摄像头负责人 | `id + CameraLeaderUpdateCmd` | `Response` | 负责人必须为有效用户;只修改当前有效绑定 | P0 |
+| `CAMERA-05` | `DELETE /edgeguard/region-cameras/{id}` | 解除摄像头绑定 | `id` | `Response` | 只解除本地关系,不删除第三方摄像头;写入 `UNBOUND/unboundTime` | P0 |
+| `CAMERA-06` | `GET /edgeguard/region-cameras/{id}` | 查看摄像头详情 | `id` | `SingleResponse` | 优先查询第三方实时详情;失败时返回本地快照并明确 `snapshot=true` | P1,依赖第三方契约 |
+
+`RegionCameraPageQry` 至少包含 `regionId`,可选 `cameraName/cameraLevelCode/onlineStatus`。摄像头选择接口的分页字段和筛选能力最终以第三方接口能力为准。
+
+### 11.4 第三方区域开放接口
+
+| 编号 | 方法与路径 | 用途 | 主要入参 | 主要返回 | 核心规则 | 优先级 |
+| --- | --- | --- | --- | --- | --- | --- |
+| `OPEN-REGION-01` | `POST /open/edgeguard/regions/page` | 第三方分页查询区域 | `OpenRegionPageQry` | `PageResponse` | 只返回调用方被授权企业的数据;默认不返回逻辑删除数据 | P1 |
+| `OPEN-REGION-02` | `GET /open/edgeguard/regions/{regionCode}` | 第三方查询区域详情 | `regionCode` | `SingleResponse` | 对外使用稳定的 `regionCode`,不暴露仅供内部使用的查询规则 | P1 |
+| `OPEN-REGION-03` | `GET /open/edgeguard/regions/tree` | 第三方查询企业区域树 | `corpId` | `MultiResponse` | `corpId` 必须在应用授权范围内;只返回真实区域节点 | P1 |
+| `OPEN-REGION-04` | `POST /open/edgeguard/region-cameras/page` | 第三方查询区域摄像头绑定 | `OpenRegionCameraPageQry` | `PageResponse` | 与区域接口分开分页,避免区域树报文过大;是否开放负责人字段联调前确认 | P1 |
+
+开放接口不直接复用页面 Controller。鉴权失败、签名错误、越权、参数错误和服务异常需要使用双方约定的稳定错误码。
+
+### 11.5 报警接收与处置接口
+
+| 编号 | 方法与路径 | 用途 | 主要入参 | 主要返回 | 核心规则 | 优先级 |
+| --- | --- | --- | --- | --- | --- | --- |
+| `ALARM-01` | `POST /open/edgeguard/alarms` | 接收第三方报警 | `AlarmReceiveCmd` | `SingleResponse` | 以 `sourceSystem + alarmNo` 幂等;服务端设置 `receivedTime/PENDING`;根据摄像头绑定固化企业、区域和摄像头快照 | P0,依赖第三方报文/鉴权 |
+| `ALARM-02` | `POST /edgeguard/alarms/page` | 报警处置列表/台账分页 | `AlarmPageQry` | `PageResponse` | 未处置列表执行负责人和10分钟升级规则;台账按页面数据权限;支持编号、区域、状态、时间筛选 | P0 |
+| `ALARM-03` | `GET /edgeguard/alarms/{id}` | 查询报警及处置详情 | `id` | `SingleResponse` | 返回报警、处置结论和 `hiddenId`;不复制隐患详情,前端按 ID 调隐患服务 | P0 |
+| `ALARM-04` | `POST /edgeguard/alarms/{id}/disposal` | 提交报警处置 | `id + AlarmDisposeCmd` | `Response` | 一条报警只处置一次;校验误报/隐患条件;保存处置记录并将报警改为 `DISPOSED` | P0 |
+| `ALARM-05` | `POST /edgeguard/alarms/{id}/hidden/retry` | 重试创建隐患 | `id` | `Response` | 仅 `isHiddenHazard=1` 且创建失败时允许;以报警编号作为 `foreignKey` 保证幂等 | P1 |
+
+`AlarmReceiveCmd` 当前确认字段:
+
+- `sourceSystem`、`alarmNo`、`alarmTime`、`alarmAddress`。
+- `cameraSource`、`cameraId`、`cameraName`。
+- `regionCode`(第三方可传,但最终以本地摄像头绑定匹配结果校验)。
+- `alarmImageUrl`。
+
+`AlarmReceiveCo` 返回本地 `alarmId`、`alarmNo` 和 `repeated`,重复推送返回受理成功而不是重复插入。
+
+`AlarmDisposeCmd` 建议字段:
+
+- `isFalseAlarm`:必填。
+- `isHiddenHazard`:非误报时必填。
+- `disposalResultCode/disposalResultName/disposalDescription`:非误报且非隐患时按业务规则必填。
+- 创建隐患所需字段暂不固化到本文,确认 `HiddenAddCmd` 必填项后补充;禁止由 Controller 直接调用隐患 Facade。
+
+处置事务边界:先完成本地处置条件校验;隐患远程创建不能放在持有数据库锁的长事务中。远程调用成功后保存 `hiddenId/SUCCESS`;失败时保存 `FAILED/error` 并允许 `ALARM-05` 重试。报警是否在隐患创建失败时立即标记为 `DISPOSED`,开发 `ALARM-04` 前必须最终确认。
+
+### 11.6 报警统计接口
+
+统计接口属于已确认开发范围,依据原型 `E:\job_worde\人脸识别原型\人脸识别原型\人脸识别综合原型.html` 实现,不再作为待确认功能:
+
+- 分公司端按当前分公司下的区域分页展示:报警区域、报警总数、未处置、已处置、查看。
+- 股份端按分公司分页展示:分公司名称、报警总数、未处置、已处置。
+- 股份端点击分公司名称下钻到该分公司的区域统计。
+- 区域统计点击“查看”进入统计报警台账,并自动携带公司和区域条件。
+- 统计报警台账展示报警编号、报警区域、报警摄像头、报警时间、处置状态和查看操作;查询复用 `ALARM-02`。
+- 没有报警的已维护区域也要展示为 0,不能只从报警表 `GROUP BY` 后返回有数据的区域;区域维度应以有效区域表为主表左连接报警聚合结果。
+
+| 编号 | 方法与路径 | 用途 | 主要入参 | 主要返回 | 核心规则 | 优先级 |
+| --- | --- | --- | --- | --- | --- | --- |
+| `STAT-01` | `POST /edgeguard/alarm-statistics/corporations` | 股份端按分公司统计 | `AlarmCorpStatisticQry` | `PageResponse` | 按 `corpId` 聚合总数、未处置、已处置;公司名称来自公共服务;受股份端数据权限控制 | P0 |
+| `STAT-02` | `POST /edgeguard/alarm-statistics/regions` | 分公司端及股份下钻按区域统计 | `AlarmRegionStatisticQry` | `PageResponse` | 必须传或从权限确定 `corpId`;以有效区域为基础分页并关联报警聚合,包含报警数为0的区域 | P0 |
+
+统计页面点击公司或区域后的明细复用 `ALARM-02`,携带 `corpId/regionId/disposalStatus`,不再增加重复的台账查询接口。
+
+### 11.7 暂不单独开发的接口
+
+- 企业、部门、岗位、用户主数据接口:统一调用公共服务或使用 `TranslateField`,本项目不维护。
+- 摄像头新增、修改、删除接口:摄像头主数据归第三方,本项目只查询和绑定。
+- 隐患详情、进度和整改接口:复用隐患服务,本项目只保存 `hiddenId`。
+- 报警附件接口:当前只有单张截图 URL,直接保存在报警主表。
+- 报警派单、转办、操作轨迹接口:当前报警直接处置,没有派单流程。
+- 人员、人脸和人员区域准入接口:不在当前项目范围。
+- 主动推送、回执和失败重试接口:当前综合平台是数据提供方和报警接收方,不主动推送。
+
+### 11.8 外部依赖契约
+
+| 依赖 | 使用接口 | 当前状态 | 开发前需要确认 |
+| --- | --- | --- | --- |
+| 企业/用户公共服务 | `REGION-01~04`、`CAMERA-04`、统计展示 | 已有公共能力 | 企业数据权限来源、负责人有效性校验和批量翻译字段 |
+| 第三方摄像头服务 | `CAMERA-02/03/06` | 契约待提供 | 协议、分页报文、筛选字段、唯一 ID、详情接口、超时和错误码 |
+| 第三方报警平台 | `ALARM-01` | 契约待联调 | JSON 报文、鉴权签名、重试策略、截图 URL 有效期、区域/摄像头无法匹配时的处理 |
+| 隐患服务 `ZcloudHiddenFacade` | `ALARM-04/05` | Common 已有能力 | `HiddenAddCmd` 必填字段、返回的隐患 ID、幂等行为、失败错误码 |
+
+### 11.9 建议实现顺序与状态
+
+接口状态统一使用:`TODO`、`DOING`、`DONE`、`BLOCKED`。每完成一个接口,需要同时更新本文状态、请求/响应示例、权限编码和异常码。
+
+| 开发批次 | 接口 | 当前状态 | 原因 |
+| --- | --- | --- | --- |
+| 第1批:区域闭环 | `REGION-01~05` | `DOING` | 分层实现、校验、测试及编译已完成;待菜单资源分配真实权限编码后转 `DONE` |
+| 第2批:摄像头绑定 | `CAMERA-01~05` | `TODO` | 本地绑定可开发;候选和保存快照依赖第三方摄像头契约 |
+| 第3批:报警与统计 | `ALARM-01~04`、`STAT-01~02` | `TODO` | 统计需求和下钻行为已由原型确认;与报警主流程同批开发、联调 |
+| 第4批:开放查询 | `OPEN-REGION-01~04` | `TODO` | 需先确认第三方实际需要和开放鉴权方案 |
+| 第5批:增强能力 | `CAMERA-06`、`ALARM-05` | `TODO` | 实时详情降级和隐患失败重试 |
+
+推荐从 `REGION-01` 区域树接口开始逐个实现。每个接口的完成标准包括:Client 契约、Controller、App 执行器、Domain 规则、Infrastructure 查询/持久化、权限与数据范围、参数校验、异常码、单元测试或集成测试,以及 Maven 编译通过。