feat: add digital employee management sync endpoint
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
package com.xspaceagi.agent.core.adapter.application;
|
||||||
|
|
||||||
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRequestDto;
|
||||||
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncResponseDto;
|
||||||
|
|
||||||
|
public interface DigitalEmployeeSyncApplicationService {
|
||||||
|
DigitalEmployeeSyncResponseDto syncOutbox(DigitalEmployeeSyncRequestDto request);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.xspaceagi.agent.core.adapter.dto.digital;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class DigitalEmployeeSyncAckDto {
|
||||||
|
@JsonProperty("outbox_id")
|
||||||
|
private String outboxId;
|
||||||
|
@JsonProperty("entity_type")
|
||||||
|
private String entityType;
|
||||||
|
@JsonProperty("entity_id")
|
||||||
|
private String entityId;
|
||||||
|
@JsonProperty("remote_id")
|
||||||
|
private String remoteId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.xspaceagi.agent.core.adapter.dto.digital;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class DigitalEmployeeSyncItemDto {
|
||||||
|
@JsonAlias("outbox_id")
|
||||||
|
private String outboxId;
|
||||||
|
@JsonAlias("entity_type")
|
||||||
|
private String entityType;
|
||||||
|
@JsonAlias("entity_id")
|
||||||
|
private String entityId;
|
||||||
|
private String operation;
|
||||||
|
private Map<String, Object> payload;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.xspaceagi.agent.core.adapter.dto.digital;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class DigitalEmployeeSyncRequestDto {
|
||||||
|
@JsonAlias("client_key")
|
||||||
|
private String clientKey;
|
||||||
|
@JsonAlias("device_id")
|
||||||
|
private String deviceId;
|
||||||
|
private List<DigitalEmployeeSyncItemDto> items;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.xspaceagi.agent.core.adapter.dto.digital;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class DigitalEmployeeSyncResponseDto {
|
||||||
|
private List<DigitalEmployeeSyncAckDto> acked;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.xspaceagi.agent.core.adapter.repository;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeSyncRecord;
|
||||||
|
|
||||||
|
public interface DigitalEmployeeSyncRecordRepository extends IService<DigitalEmployeeSyncRecord> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.xspaceagi.agent.core.adapter.repository.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@TableName("digital_employee_sync_record")
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class DigitalEmployeeSyncRecord {
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
@TableField(value = "_tenant_id")
|
||||||
|
private Long tenantId;
|
||||||
|
private Long userId;
|
||||||
|
private String clientKey;
|
||||||
|
private String deviceId;
|
||||||
|
private String outboxId;
|
||||||
|
private String entityType;
|
||||||
|
private String entityId;
|
||||||
|
private String operation;
|
||||||
|
private String payload;
|
||||||
|
private Date syncedAt;
|
||||||
|
private Date modified;
|
||||||
|
private Date created;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.xspaceagi.agent.core.application.service;
|
||||||
|
|
||||||
|
import com.baomidou.dynamic.datasource.annotation.DSTransactional;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.xspaceagi.agent.core.adapter.application.DigitalEmployeeSyncApplicationService;
|
||||||
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncAckDto;
|
||||||
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncItemDto;
|
||||||
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRequestDto;
|
||||||
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncResponseDto;
|
||||||
|
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeSyncRecordRepository;
|
||||||
|
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeSyncRecord;
|
||||||
|
import com.xspaceagi.system.spec.common.RequestContext;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.apache.commons.collections4.CollectionUtils;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class DigitalEmployeeSyncApplicationServiceImpl implements DigitalEmployeeSyncApplicationService {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private DigitalEmployeeSyncRecordRepository digitalEmployeeSyncRecordRepository;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@DSTransactional
|
||||||
|
public DigitalEmployeeSyncResponseDto syncOutbox(DigitalEmployeeSyncRequestDto request) {
|
||||||
|
List<DigitalEmployeeSyncAckDto> acked = new ArrayList<>();
|
||||||
|
if (request == null || CollectionUtils.isEmpty(request.getItems())) {
|
||||||
|
return DigitalEmployeeSyncResponseDto.builder().acked(acked).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
Date now = new Date();
|
||||||
|
for (DigitalEmployeeSyncItemDto item : request.getItems()) {
|
||||||
|
if (item == null || item.getOutboxId() == null || item.getEntityType() == null || item.getEntityId() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DigitalEmployeeSyncRecord existing = digitalEmployeeSyncRecordRepository.getOne(
|
||||||
|
new QueryWrapper<>(DigitalEmployeeSyncRecord.builder()
|
||||||
|
.clientKey(request.getClientKey())
|
||||||
|
.outboxId(item.getOutboxId())
|
||||||
|
.build()),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
DigitalEmployeeSyncRecord record = existing == null ? new DigitalEmployeeSyncRecord() : existing;
|
||||||
|
record.setTenantId(RequestContext.get().getTenantId());
|
||||||
|
record.setUserId(RequestContext.get().getUserId());
|
||||||
|
record.setClientKey(request.getClientKey());
|
||||||
|
record.setDeviceId(request.getDeviceId());
|
||||||
|
record.setOutboxId(item.getOutboxId());
|
||||||
|
record.setEntityType(item.getEntityType());
|
||||||
|
record.setEntityId(item.getEntityId());
|
||||||
|
record.setOperation(item.getOperation());
|
||||||
|
record.setPayload(toJson(item.getPayload()));
|
||||||
|
record.setSyncedAt(now);
|
||||||
|
record.setModified(now);
|
||||||
|
if (record.getId() == null) {
|
||||||
|
record.setCreated(now);
|
||||||
|
digitalEmployeeSyncRecordRepository.save(record);
|
||||||
|
} else {
|
||||||
|
digitalEmployeeSyncRecordRepository.updateById(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
acked.add(DigitalEmployeeSyncAckDto.builder()
|
||||||
|
.outboxId(item.getOutboxId())
|
||||||
|
.entityType(item.getEntityType())
|
||||||
|
.entityId(item.getEntityId())
|
||||||
|
.remoteId(String.valueOf(record.getId()))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
return DigitalEmployeeSyncResponseDto.builder().acked(acked).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toJson(Object payload) {
|
||||||
|
if (payload == null) {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(payload);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.xspaceagi.agent.core.infra.dao.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeSyncRecord;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface DigitalEmployeeSyncRecordMapper extends BaseMapper<DigitalEmployeeSyncRecord> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.xspaceagi.agent.core.infra.repository;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeSyncRecordRepository;
|
||||||
|
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeSyncRecord;
|
||||||
|
import com.xspaceagi.agent.core.infra.dao.mapper.DigitalEmployeeSyncRecordMapper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class DigitalEmployeeSyncRecordRepositoryImpl
|
||||||
|
extends ServiceImpl<DigitalEmployeeSyncRecordMapper, DigitalEmployeeSyncRecord>
|
||||||
|
implements DigitalEmployeeSyncRecordRepository {
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.xspaceagi.agent.web.ui.controller;
|
||||||
|
|
||||||
|
import com.xspaceagi.agent.core.adapter.application.DigitalEmployeeSyncApplicationService;
|
||||||
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRequestDto;
|
||||||
|
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncResponseDto;
|
||||||
|
import com.xspaceagi.system.spec.dto.ReqResult;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMethod;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/digital-employee/sync")
|
||||||
|
public class DigitalEmployeeSyncController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private DigitalEmployeeSyncApplicationService digitalEmployeeSyncApplicationService;
|
||||||
|
|
||||||
|
@RequestMapping(path = "/outbox", method = RequestMethod.POST)
|
||||||
|
public ReqResult<DigitalEmployeeSyncResponseDto> syncOutbox(@RequestBody DigitalEmployeeSyncRequestDto request) {
|
||||||
|
return ReqResult.success(digitalEmployeeSyncApplicationService.syncOutbox(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
23
qiming-backend/sql/update-20260605.sql
Normal file
23
qiming-backend/sql/update-20260605.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE TABLE `digital_employee_sync_record`
|
||||||
|
(
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`_tenant_id` BIGINT DEFAULT NULL COMMENT '租户ID',
|
||||||
|
`user_id` BIGINT DEFAULT NULL COMMENT '用户ID',
|
||||||
|
`client_key` VARCHAR(128) NOT NULL COMMENT '客户端配置Key',
|
||||||
|
`device_id` VARCHAR(128) DEFAULT NULL COMMENT '设备ID',
|
||||||
|
`outbox_id` VARCHAR(255) NOT NULL COMMENT '客户端outbox ID',
|
||||||
|
`entity_type` VARCHAR(32) NOT NULL COMMENT '实体类型:plan/task/run/event/artifact/approval',
|
||||||
|
`entity_id` VARCHAR(255) NOT NULL COMMENT '客户端实体ID',
|
||||||
|
`operation` VARCHAR(32) NOT NULL COMMENT '操作类型',
|
||||||
|
`payload` JSON DEFAULT NULL COMMENT '实体快照',
|
||||||
|
`synced_at` DATETIME NOT NULL COMMENT '最近同步时间',
|
||||||
|
`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_client_outbox` (`client_key`, `outbox_id`),
|
||||||
|
KEY `idx_client_entity` (`client_key`, `entity_type`, `entity_id`),
|
||||||
|
KEY `idx_tenant_user_time` (`_tenant_id`, `user_id`, `synced_at`)
|
||||||
|
) ENGINE = InnoDB
|
||||||
|
DEFAULT CHARSET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_unicode_ci
|
||||||
|
COMMENT = '数字员工客户端同步记录';
|
||||||
@@ -16,6 +16,7 @@ interface DigitalEmployeeSyncConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
endpoint: string | null;
|
endpoint: string | null;
|
||||||
clientKey: string | null;
|
clientKey: string | null;
|
||||||
|
authToken: string | null;
|
||||||
batchSize: number;
|
batchSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ export async function flushDigitalEmployeeSyncOutbox(): Promise<DigitalEmployeeS
|
|||||||
if (syncing) return getDigitalEmployeeSyncStatus();
|
if (syncing) return getDigitalEmployeeSyncStatus();
|
||||||
|
|
||||||
const config = resolveSyncConfig();
|
const config = resolveSyncConfig();
|
||||||
if (!config.enabled || !config.endpoint || !config.clientKey) {
|
if (!config.enabled || !config.endpoint || !config.clientKey || !config.authToken) {
|
||||||
lastSyncError = null;
|
lastSyncError = null;
|
||||||
return getDigitalEmployeeSyncStatus();
|
return getDigitalEmployeeSyncStatus();
|
||||||
}
|
}
|
||||||
@@ -167,6 +168,7 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
|||||||
enabled: !disabled,
|
enabled: !disabled,
|
||||||
endpoint,
|
endpoint,
|
||||||
clientKey: readClientKey(serverHost),
|
clientKey: readClientKey(serverHost),
|
||||||
|
authToken: readAuthToken(serverHost),
|
||||||
batchSize,
|
batchSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -190,6 +192,21 @@ function readClientKey(serverHost: string | null): string | null {
|
|||||||
return typeof globalKey === "string" && globalKey.trim() ? globalKey : null;
|
return typeof globalKey === "string" && globalKey.trim() ? globalKey : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readAuthToken(serverHost: string | null): string | null {
|
||||||
|
const oneShotToken = readSetting("auth.token");
|
||||||
|
if (typeof oneShotToken === "string" && oneShotToken.trim()) {
|
||||||
|
return oneShotToken;
|
||||||
|
}
|
||||||
|
if (serverHost) {
|
||||||
|
const domain = serverHost.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||||
|
const domainToken = readSetting(`auth.tokens.${domain}`);
|
||||||
|
if (typeof domainToken === "string" && domainToken.trim()) {
|
||||||
|
return domainToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function readPendingOutbox(limit: number): OutboxRow[] {
|
function readPendingOutbox(limit: number): OutboxRow[] {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
if (!db) return [];
|
if (!db) return [];
|
||||||
@@ -217,7 +234,7 @@ async function postOutbox(
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${config.clientKey}`,
|
Authorization: `Bearer ${config.authToken}`,
|
||||||
"X-Qiming-Client-Key": config.clientKey!,
|
"X-Qiming-Client-Key": config.clientKey!,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -455,13 +455,23 @@ window.QimingClawBridge.digital.flushSync()
|
|||||||
|
|
||||||
当前边界:
|
当前边界:
|
||||||
|
|
||||||
- 后端接口尚未真正确认,因此客户端先实现可配置 endpoint 和兼容 ack 协议。
|
- qiming-backend 已补最小接收端,先以通用同步记录表承接客户端 outbox。
|
||||||
- worker 只消费 outbox,不直接侵入 Plan / Task / Run / Event 写入链路。
|
- worker 只消费 outbox,不直接侵入 Plan / Task / Run / Event 写入链路。
|
||||||
- 管理端返回格式建议包含 `acked[]`,每项带 `outbox_id`、`entity_type`、`entity_id`、`remote_id`。
|
- 管理端返回格式包含 `acked[]`,每项带 `outbox_id`、`entity_type`、`entity_id`、`remote_id`。
|
||||||
|
- 当前接口沿用管理端登录态鉴权,客户端必须能读取到登录 ticket/token 才会推送;没有 token 时保持本地 outbox 等待。
|
||||||
|
|
||||||
|
后端最小落点:
|
||||||
|
|
||||||
|
```text
|
||||||
|
qiming-backend/sql/update-20260605.sql
|
||||||
|
qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-ui/.../DigitalEmployeeSyncController.java
|
||||||
|
qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-application/.../DigitalEmployeeSyncApplicationServiceImpl.java
|
||||||
|
qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-adapter/.../DigitalEmployeeSyncRecord.java
|
||||||
|
```
|
||||||
|
|
||||||
下一步:
|
下一步:
|
||||||
|
|
||||||
- 在 qiming-backend 落地 `/api/digital-employee/sync/outbox`。
|
- 在有 Maven 的环境验证 qiming-backend 编译与接口启动。
|
||||||
- 数字员工页面读取 `getSyncStatus()`,展示联动状态。
|
- 数字员工页面读取 `getSyncStatus()`,展示联动状态。
|
||||||
- 增加 outbox 重试退避策略,避免接口长期不可用时频繁请求。
|
- 增加 outbox 重试退避策略,避免接口长期不可用时频繁请求。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user