feat: add digital employee management sync endpoint

This commit is contained in:
baiyanyun
2026-06-05 17:21:48 +08:00
parent b9132c421d
commit ab1d022905
14 changed files with 308 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 = '数字员工客户端同步记录';

View File

@@ -16,6 +16,7 @@ interface DigitalEmployeeSyncConfig {
enabled: boolean;
endpoint: string | null;
clientKey: string | null;
authToken: string | null;
batchSize: number;
}
@@ -96,7 +97,7 @@ export async function flushDigitalEmployeeSyncOutbox(): Promise<DigitalEmployeeS
if (syncing) return getDigitalEmployeeSyncStatus();
const config = resolveSyncConfig();
if (!config.enabled || !config.endpoint || !config.clientKey) {
if (!config.enabled || !config.endpoint || !config.clientKey || !config.authToken) {
lastSyncError = null;
return getDigitalEmployeeSyncStatus();
}
@@ -167,6 +168,7 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
enabled: !disabled,
endpoint,
clientKey: readClientKey(serverHost),
authToken: readAuthToken(serverHost),
batchSize,
};
}
@@ -190,6 +192,21 @@ function readClientKey(serverHost: string | null): string | 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[] {
const db = getDb();
if (!db) return [];
@@ -217,7 +234,7 @@ async function postOutbox(
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.clientKey}`,
Authorization: `Bearer ${config.authToken}`,
"X-Qiming-Client-Key": config.clientKey!,
},
body: JSON.stringify({

View File

@@ -455,13 +455,23 @@ window.QimingClawBridge.digital.flushSync()
当前边界:
- 后端接口尚未真正确认,因此客户端先实现可配置 endpoint 和兼容 ack 协议
- qiming-backend 已补最小接收端,先以通用同步记录表承接客户端 outbox
- 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()`,展示联动状态。
- 增加 outbox 重试退避策略,避免接口长期不可用时频繁请求。