区域管理接口

master
aosodo_4812 2026-09-02 14:23:54 +08:00
parent 0d2516111b
commit e96c28f729
33 changed files with 1340 additions and 8 deletions

124
REGION-API.md Normal file
View File

@ -0,0 +1,124 @@
# 区域管理接口REGION-0105
## 公共约定
- 内部接口前缀:`/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` 摄像头绑定时拒绝删除。
## 当前业务异常
| 场景 | 信息 |
| --- | --- |
| 企业越权 | 无权访问该企业数据 |
| 区域不存在 | 区域不存在 / 父区域不存在 |
| 同级重名 | 同级区域名称已存在 |
| 负责人无效 | 区域负责人不存在或已停用 |
| 负责人跨企业 | 区域负责人不属于当前企业 |
| 围栏无效 | 点数、序号、范围、重复点、自相交或零面积对应提示 |
| 并发修改 | 区域已被其他用户修改,请刷新后重试 |
| 删除受限 | 区域存在下级区域,不能删除 / 区域仍绑定有效摄像头,不能删除 |

View File

@ -39,7 +39,7 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>
<configuration> <configuration>
<mainClass>com.zcloud.risk.Application</mainClass> <mainClass>com.zcloud.gbs.edgeguard.Application</mainClass>
</configuration> </configuration>
<executions> <executions>
<execution> <execution>

View File

@ -1,6 +1,7 @@
package com.zcloud.gbs.edgeguard; package com.zcloud.gbs.edgeguard;
import com.jjb.saas.base.starter.bootstart.JJBSpringbootApplication; import com.jjb.saas.base.starter.bootstart.JJBSpringbootApplication;
import com.jjb.saas.facade.starter.annotation.EnableFacadeRpcClient;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
/** /**
@ -11,6 +12,7 @@ import org.springframework.boot.SpringApplication;
scanBasePackages = {"com.zcloud","com.jjb"}, scanBasePackages = {"com.zcloud","com.jjb"},
mapperPackages = {"com.zcloud.gbs.edgeguard.persistence.mapper"} mapperPackages = {"com.zcloud.gbs.edgeguard.persistence.mapper"}
) )
@EnableFacadeRpcClient
public class Application { public class Application {
public static void main(String[] args) { public static void main(String[] args) {

View File

@ -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<RegionTreeNodeCo> tree(@RequestParam(value = "corpId", required = false) Long corpId) {
return regionService.tree(corpId);
}
@ApiOperation("查询区域详情")
@GetMapping("/{id}")
public SingleResponse<RegionCo> detail(@PathVariable("id") Long id) {
return regionService.getById(id);
}
@ApiOperation("新增顶级或下级区域")
@PostMapping
public SingleResponse<Long> 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);
}
}

View File

@ -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<CorpE> 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("无权访问该企业数据");
}
}

View File

@ -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;
}
}

View File

@ -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<FencePointCmd> fencePoints) {
domainService.validateCoordinateSystem(normalizeCoordinateSystem(coordinateSystem));
List<RegionDomainService.Point> 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();
}
}

View File

@ -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("删除区域失败");
}
}
}

View File

@ -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("区域已被其他用户修改,请刷新后重试");
}
}
}

View File

@ -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<RegionTreeNodeCo> tree(Long corpId) {
List<CorpE> corps;
if (corpId == null) {
corps = new ArrayList<>(accessService.accessibleCorps());
} else {
corps = new ArrayList<>(Collections.singletonList(accessService.requireAccessibleCorp(corpId)));
}
List<Long> corpIds = new ArrayList<>();
for (CorpE corp : corps) {
corpIds.add(corp.getId());
}
List<RegionE> regions = regionGateway.listByCorpIds(corpIds);
Map<Long, List<RegionE>> byCorp = new HashMap<>();
for (RegionE region : regions) {
byCorp.computeIfAbsent(region.getCorpId(), key -> new ArrayList<>()).add(region);
}
List<RegionTreeNodeCo> 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<RegionCo> 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<RegionTreeNodeCo> buildRegionTree(List<RegionE> regions) {
Map<Long, RegionTreeNodeCo> nodes = new LinkedHashMap<>();
for (RegionE region : regions) {
nodes.put(region.getId(), regionNode(region));
}
List<RegionTreeNodeCo> 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<RegionTreeNodeCo> 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;
}
}

View File

@ -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<RegionTreeNodeCo> tree(Long corpId) {
return queryExe.tree(corpId);
}
@Override
public SingleResponse<RegionCo> getById(Long id) {
return queryExe.detail(id);
}
@Override
public SingleResponse<Long> 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();
}
}

View File

@ -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<RegionTreeNodeCo> tree(Long corpId);
SingleResponse<RegionCo> getById(Long id);
SingleResponse<Long> add(RegionAddCmd cmd);
Response update(Long id, RegionUpdateCmd cmd);
Response remove(Long id);
}

View File

@ -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;
}

View File

@ -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<FencePointCmd> fencePoints;
@ApiModelProperty("同级排序号")
private Integer sortNo;
@Size(max = 255, message = "备注不能超过255个字符")
@ApiModelProperty("备注")
private String remarks;
}

View File

@ -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<FencePointCmd> 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;
}

View File

@ -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;
}

View File

@ -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<FencePointCo> fencePoints;
private String regionStatus;
private Integer version;
private String remarks;
}

View File

@ -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<RegionTreeNodeCo> children = new ArrayList<>();
}

View File

@ -22,5 +22,11 @@
<groupId>com.jjb.saas</groupId> <groupId>com.jjb.saas</groupId>
<artifactId>jjb-saas-base-starter</artifactId> <artifactId>jjb-saas-base-starter</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<version>5.3.1</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

@ -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<CorpE> listAccessibleCorps();
}

View File

@ -7,4 +7,5 @@ public interface RegionCameraGateway {
Boolean update(RegionCameraE regionCamera); Boolean update(RegionCameraE regionCamera);
Boolean deleteById(Long id); Boolean deleteById(Long id);
RegionCameraE getById(Long id); RegionCameraE getById(Long id);
boolean existsBoundByRegionId(Long regionId);
} }

View File

@ -2,9 +2,15 @@ package com.zcloud.gbs.edgeguard.domain.gateway;
import com.zcloud.gbs.edgeguard.domain.model.RegionE; import com.zcloud.gbs.edgeguard.domain.model.RegionE;
import java.util.Collection;
import java.util.List;
public interface RegionGateway { public interface RegionGateway {
Boolean add(RegionE region); Long add(RegionE region);
Boolean update(RegionE region); Boolean update(RegionE region);
Boolean deleteById(Long id); Boolean deleteById(Long id);
RegionE getById(Long id); RegionE getById(Long id);
List<RegionE> listByCorpIds(Collection<Long> corpIds);
boolean existsSiblingName(Long corpId, Long parentId, String regionName, Long excludeId);
boolean existsChild(Long parentId);
} }

View File

@ -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<UserE> listByIds(Collection<Long> ids);
}

View File

@ -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;
}

View File

@ -23,4 +23,6 @@ public class RegionE extends BaseE {
/** 四个有序坐标点的 JSON 数组。 */ /** 四个有序坐标点的 JSON 数组。 */
private String fencePoints; private String fencePoints;
private String regionStatus; private String regionStatus;
private Integer version;
private String remarks;
} }

View File

@ -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;
}

View File

@ -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<String> COORDINATE_SYSTEMS = new HashSet<>();
private static final Set<String> 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<Point> points) {
if (points == null || points.size() != 4) {
throw new BizException("电子围栏必须包含4个点");
}
Set<Integer> sequences = new HashSet<>();
Set<String> 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;
}
}
}

View File

@ -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<RegionDomainService.Point> 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<RegionDomainService.Point> 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<RegionDomainService.Point> 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));
}
}

View File

@ -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<CorpE> listAccessibleCorps() {
MultiResponse<ZcloudCorpInfoCO> response = corpFacade.getCorpList();
if (response == null || !response.isSuccess()) {
throw new BizException("获取当前用户可访问企业失败");
}
if (response.getData() == null) {
return Collections.emptyList();
}
List<CorpE> 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;
}
}

View File

@ -33,6 +33,14 @@ public class RegionCameraGatewayImpl implements RegionCameraGateway {
return toEntity(repository.getById(id)); 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) { private RegionCameraDO toDO(RegionCameraE source) {
RegionCameraDO target = new RegionCameraDO(); RegionCameraDO target = new RegionCameraDO();
BeanUtils.copyProperties(source, target); BeanUtils.copyProperties(source, target);

View File

@ -8,14 +8,23 @@ import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class RegionGatewayImpl implements RegionGateway { public class RegionGatewayImpl implements RegionGateway {
private final RegionRepository repository; private final RegionRepository repository;
@Override @Override
public Boolean add(RegionE region) { public Long add(RegionE region) {
return repository.save(toDO(region)); RegionDO data = toDO(region);
if (!repository.save(data)) {
return null;
}
return data.getId();
} }
@Override @Override
@ -33,6 +42,37 @@ public class RegionGatewayImpl implements RegionGateway {
return toEntity(repository.getById(id)); return toEntity(repository.getById(id));
} }
@Override
public List<RegionE> listByCorpIds(Collection<Long> corpIds) {
if (corpIds == null || corpIds.isEmpty()) {
return Collections.emptyList();
}
List<RegionDO> rows = repository.lambdaQuery()
.in(RegionDO::getCorpId, corpIds)
.orderByAsc(RegionDO::getCorpId, RegionDO::getTreeLevel, RegionDO::getSortNo, RegionDO::getId)
.list();
List<RegionE> 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) { private RegionDO toDO(RegionE source) {
RegionDO target = new RegionDO(); RegionDO target = new RegionDO();
BeanUtils.copyProperties(source, target); BeanUtils.copyProperties(source, target);

View File

@ -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<ZcloudUserCo> response = userFacade.getInfoByUserId(id);
if (response == null || !response.isSuccess()) {
throw new BizException("查询区域负责人失败");
}
return toEntity(response.getData());
}
@Override
public List<UserE> listByIds(Collection<Long> ids) {
if (ids == null || ids.isEmpty()) {
return Collections.emptyList();
}
MultiResponse<ZcloudUserCo> response = userFacade.listByUserIds(ids);
if (response == null || !response.isSuccess()) {
throw new BizException("批量查询区域负责人失败");
}
List<UserE> 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()));
}
}

View File

@ -118,13 +118,13 @@ nodeType = REGION 本地区域节点
- 重复选择已经绑定到当前区域的摄像头应幂等成功。 - 重复选择已经绑定到当前区域的摄像头应幂等成功。
- 已绑定其他区域时,应返回明确提示,不静默迁移。 - 已绑定其他区域时,应返回明确提示,不静默迁移。
- 解绑只删除本地关系,不调用第三方删除摄像头。 - 解绑只删除本地关系,不调用第三方删除摄像头。
- 在线状态等动态字段不写入本地绑定表,以第三方实时结果为准 - 在线状态等动态字段允许作为列表快照写入绑定表,并记录 `camera_synced_at`;第三方实时结果仍是权威数据,刷新失败时展示最近快照
## 4. 表结构草案 ## 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`。 - 树查询索引:`tenant_id + corp_id + parent_id + delete_enum + sort_no`。
- 负责人查询索引:`leader_user_id`,仅在存在反向查询需求时增加。 - 负责人查询索引:`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` 获取。负责人发生变更后按当前负责人权限查询;如果未来要求报警产生时锁定负责人,再在报警表增加负责人快照字段。 摄像头负责人从 `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<T>`,分页返回 `PageResponse<T>`,集合返回 `MultiResponse<T>`,无数据操作返回 `Response`
- 下表中的权限编码先使用“待分配”标识,等菜单和权限资源建立后再填写真实值,禁止复制示例权限编码。
- `corpId`、`tenantId`、`orgId` 等数据范围不得只信任前端入参,必须结合当前用户上下文和数据权限校验。
- 接口路径是当前开发基线如果项目统一网关会自动增加服务前缀Controller 中不重复拼接网关前缀。
### 11.2 区域管理接口
| 编号 | 方法与路径 | 用途 | 主要入参 | 主要返回 | 核心规则 | 优先级 |
| --- | --- | --- | --- | --- | --- | --- |
| `REGION-01` | `GET /edgeguard/regions/tree` | 加载企业虚拟根及区域树 | 可选 `corpId` | `MultiResponse<RegionTreeNodeCo>` | 企业来自公共服务;区域一次批量查询后内存组树;按当前用户企业数据权限过滤 | P0 |
| `REGION-02` | `GET /edgeguard/regions/{id}` | 查询区域详情 | `id` | `SingleResponse<RegionCo>` | 校验数据权限;负责人姓名使用公共翻译;围栏返回有序点集合 | P0 |
| `REGION-03` | `POST /edgeguard/regions` | 新增顶级或下级区域 | `RegionAddCmd` | `SingleResponse<Long>` | 校验父子企业一致、同级名称、负责人、坐标系及四点围栏;生成 `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<FencePointCmd> fencePoints`。
- `sortNo`、`remarks`。
`RegionUpdateCmd` 在上述可修改字段外增加 `id/version/regionStatus`。`regionCode`、`corpId`、`treePath` 和 `treeLevel` 不允许由前端直接修改。
### 11.3 区域摄像头接口
| 编号 | 方法与路径 | 用途 | 主要入参 | 主要返回 | 核心规则 | 优先级 |
| --- | --- | --- | --- | --- | --- | --- |
| `CAMERA-01` | `POST /edgeguard/region-cameras/page` | 查询区域已绑定摄像头 | `RegionCameraPageQry` | `PageResponse<RegionCameraCo>` | 必须传 `regionId`;从本地快照分页;支持名称、等级、在线状态;负责人批量翻译 | P0 |
| `CAMERA-02` | `POST /edgeguard/region-cameras/candidates/page` | 查询第三方可选摄像头 | `CameraCandidatePageQry` | `PageResponse<CameraCandidateCo>` | 通过 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<RegionCameraCo>` | 优先查询第三方实时详情;失败时返回本地快照并明确 `snapshot=true` | P1依赖第三方契约 |
`RegionCameraPageQry` 至少包含 `regionId`,可选 `cameraName/cameraLevelCode/onlineStatus`。摄像头选择接口的分页字段和筛选能力最终以第三方接口能力为准。
### 11.4 第三方区域开放接口
| 编号 | 方法与路径 | 用途 | 主要入参 | 主要返回 | 核心规则 | 优先级 |
| --- | --- | --- | --- | --- | --- | --- |
| `OPEN-REGION-01` | `POST /open/edgeguard/regions/page` | 第三方分页查询区域 | `OpenRegionPageQry` | `PageResponse<OpenRegionCo>` | 只返回调用方被授权企业的数据;默认不返回逻辑删除数据 | P1 |
| `OPEN-REGION-02` | `GET /open/edgeguard/regions/{regionCode}` | 第三方查询区域详情 | `regionCode` | `SingleResponse<OpenRegionCo>` | 对外使用稳定的 `regionCode`,不暴露仅供内部使用的查询规则 | P1 |
| `OPEN-REGION-03` | `GET /open/edgeguard/regions/tree` | 第三方查询企业区域树 | `corpId` | `MultiResponse<OpenRegionTreeNodeCo>` | `corpId` 必须在应用授权范围内;只返回真实区域节点 | P1 |
| `OPEN-REGION-04` | `POST /open/edgeguard/region-cameras/page` | 第三方查询区域摄像头绑定 | `OpenRegionCameraPageQry` | `PageResponse<OpenRegionCameraCo>` | 与区域接口分开分页,避免区域树报文过大;是否开放负责人字段联调前确认 | P1 |
开放接口不直接复用页面 Controller。鉴权失败、签名错误、越权、参数错误和服务异常需要使用双方约定的稳定错误码。
### 11.5 报警接收与处置接口
| 编号 | 方法与路径 | 用途 | 主要入参 | 主要返回 | 核心规则 | 优先级 |
| --- | --- | --- | --- | --- | --- | --- |
| `ALARM-01` | `POST /open/edgeguard/alarms` | 接收第三方报警 | `AlarmReceiveCmd` | `SingleResponse<AlarmReceiveCo>` | 以 `sourceSystem + alarmNo` 幂等;服务端设置 `receivedTime/PENDING`;根据摄像头绑定固化企业、区域和摄像头快照 | P0依赖第三方报文/鉴权 |
| `ALARM-02` | `POST /edgeguard/alarms/page` | 报警处置列表/台账分页 | `AlarmPageQry` | `PageResponse<AlarmCo>` | 未处置列表执行负责人和10分钟升级规则台账按页面数据权限支持编号、区域、状态、时间筛选 | P0 |
| `ALARM-03` | `GET /edgeguard/alarms/{id}` | 查询报警及处置详情 | `id` | `SingleResponse<AlarmDetailCo>` | 返回报警、处置结论和 `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<AlarmCorpStatisticCo>` | 按 `corpId` 聚合总数、未处置、已处置;公司名称来自公共服务;受股份端数据权限控制 | P0 |
| `STAT-02` | `POST /edgeguard/alarm-statistics/regions` | 分公司端及股份下钻按区域统计 | `AlarmRegionStatisticQry` | `PageResponse<AlarmRegionStatisticCo>` | 必须传或从权限确定 `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 编译通过。