feat: 支持工作流对话匿名分享
- 增加免登录公共接口、访客隔离、限流和匿名上传校验 - 分离 SSE 连接与运行生命周期,支持刷新恢复服务端权威状态 - 持久化分享页对话并优化时间线滚动与输入区交互
This commit is contained in:
@@ -85,7 +85,7 @@ public class ChainEventListenerForSave implements ChainEventListener {
|
||||
record.setStartTime(new Date());
|
||||
record.setStatus(state.getStatus().getValue());
|
||||
record.setCreatedKey(WorkFlowUtil.getCreatedKey(chain));
|
||||
record.setCreatedBy(WorkFlowUtil.getOperator(chain).getId().toString());
|
||||
record.setCreatedBy(WorkFlowUtil.getCreatedBy(chain));
|
||||
// 启动记录保留同步确认,避免执行接口返回后立即查询时记录尚不可见。
|
||||
try {
|
||||
workflowExecResultService.save(record);
|
||||
|
||||
@@ -92,4 +92,22 @@ public interface WorkflowShareService extends IService<WorkflowShare> {
|
||||
* @return 有效对话分享记录
|
||||
*/
|
||||
WorkflowShare resolveChatShare(String shareKey, BigInteger tenantId);
|
||||
|
||||
/**
|
||||
* 跨租户解析当前有效的匿名对话分享。
|
||||
*
|
||||
* @param shareKey 原始分享密钥
|
||||
* @return 有效且指向严格发布工作流的分享记录
|
||||
*/
|
||||
WorkflowShare resolvePublicChatShare(String shareKey);
|
||||
|
||||
/**
|
||||
* 跨租户解析匿名对话分享的历史记录。
|
||||
*
|
||||
* <p>仅用于详情和取消已发起执行,不校验分享状态、有效期与当前发布态。</p>
|
||||
*
|
||||
* @param shareKey 原始分享密钥
|
||||
* @return 对话分享记录
|
||||
*/
|
||||
WorkflowShare resolveHistoricalChatShare(String shareKey);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package tech.easyflow.ai.service.impl;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import com.mybatisflex.core.tenant.TenantManager;
|
||||
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
@@ -23,6 +24,7 @@ import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
@@ -184,6 +186,61 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
|
||||
return resolveShare(shareKey, tenantId, WorkflowSharePurpose.CHAT);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public WorkflowShare resolvePublicChatShare(String shareKey) {
|
||||
if (shareKey == null || shareKey.isBlank()) {
|
||||
throw invalidShare();
|
||||
}
|
||||
return TenantManager.withoutTenantCondition(() -> {
|
||||
WorkflowShare share = findShare(
|
||||
shareKey,
|
||||
WorkflowSharePurpose.CHAT,
|
||||
true
|
||||
);
|
||||
if (share == null) {
|
||||
throw invalidShare();
|
||||
}
|
||||
if (share.getExpiresAt() == null
|
||||
|| !share.getExpiresAt().after(new Date())) {
|
||||
throw new BusinessException(403, 403, "工作流分享链接已过期");
|
||||
}
|
||||
Workflow workflow = workflowService.getPublishedById(
|
||||
share.getWorkflowId());
|
||||
if (workflow == null
|
||||
|| !Objects.equals(
|
||||
share.getTenantId(), workflow.getTenantId())) {
|
||||
throw invalidShare();
|
||||
}
|
||||
if (!isStrictlyPublished(workflow)) {
|
||||
throw new BusinessException(409, 409, "工作流尚未发布或已下线");
|
||||
}
|
||||
return share;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public WorkflowShare resolveHistoricalChatShare(String shareKey) {
|
||||
if (shareKey == null || shareKey.isBlank()) {
|
||||
throw invalidShare();
|
||||
}
|
||||
WorkflowShare share = TenantManager.withoutTenantCondition(
|
||||
() -> findShare(
|
||||
shareKey,
|
||||
WorkflowSharePurpose.CHAT,
|
||||
false
|
||||
));
|
||||
if (share == null) {
|
||||
throw invalidShare();
|
||||
}
|
||||
return share;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按用途校验并解析分享。
|
||||
*
|
||||
@@ -200,10 +257,7 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
|
||||
if (shareKey == null || shareKey.isBlank() || tenantId == null) {
|
||||
throw invalidShare();
|
||||
}
|
||||
WorkflowShare share = getOne(QueryWrapper.create()
|
||||
.eq(WorkflowShare::getShareKeyHash, WorkflowSharePolicy.hashShareKey(shareKey))
|
||||
.eq(WorkflowShare::getSharePurpose, purpose.name())
|
||||
.eq(WorkflowShare::getStatus, KnowledgeShareStatus.ENABLED.name()));
|
||||
WorkflowShare share = findShare(shareKey, purpose, true);
|
||||
if (share == null || !tenantId.equals(share.getTenantId())) {
|
||||
throw invalidShare();
|
||||
}
|
||||
@@ -220,6 +274,34 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
|
||||
return share;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按密钥与用途查询分享记录。
|
||||
*
|
||||
* @param shareKey 原始分享密钥
|
||||
* @param purpose 分享用途
|
||||
* @param activeOnly 是否仅查询启用记录
|
||||
* @return 分享记录
|
||||
*/
|
||||
private WorkflowShare findShare(
|
||||
String shareKey,
|
||||
WorkflowSharePurpose purpose,
|
||||
boolean activeOnly
|
||||
) {
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.eq(
|
||||
WorkflowShare::getShareKeyHash,
|
||||
WorkflowSharePolicy.hashShareKey(shareKey)
|
||||
)
|
||||
.eq(WorkflowShare::getSharePurpose, purpose.name());
|
||||
if (activeOnly) {
|
||||
query.eq(
|
||||
WorkflowShare::getStatus,
|
||||
KnowledgeShareStatus.ENABLED.name()
|
||||
);
|
||||
}
|
||||
return getOne(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在锁保护下创建或替换工作流的唯一分享记录。
|
||||
*
|
||||
|
||||
@@ -9,6 +9,8 @@ import java.time.Duration;
|
||||
import java.util.Date;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Set;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* 工作流协作分享的密钥、时效与接口授权策略。
|
||||
@@ -25,6 +27,11 @@ public final class WorkflowSharePolicy {
|
||||
*/
|
||||
public static final String CHAT_SHARE_KEY_HEADER = "X-Workflow-Chat-Share-Key";
|
||||
|
||||
/**
|
||||
* 工作流对话分享访客标识请求头。
|
||||
*/
|
||||
public static final String CHAT_VISITOR_HEADER = "X-Workflow-Chat-Visitor";
|
||||
|
||||
private static final Duration DEFAULT_EXPIRE_DURATION = Duration.ofMinutes(30);
|
||||
private static final Duration DEFAULT_CHAT_EXPIRE_DURATION = Duration.ofDays(7);
|
||||
private static final Set<String> ALLOWED_REQUESTS = Set.of(
|
||||
@@ -67,6 +74,28 @@ public final class WorkflowSharePolicy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算匿名访客的不可逆执行归属摘要。
|
||||
*
|
||||
* @param shareKey 原始分享密钥
|
||||
* @param visitorId 当前标签页访客标识
|
||||
* @return HMAC-SHA256 前 16 字节的小写十六进制摘要
|
||||
*/
|
||||
public static String hashChatVisitor(String shareKey, String visitorId) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(
|
||||
shareKey.getBytes(StandardCharsets.UTF_8),
|
||||
"HmacSHA256"
|
||||
));
|
||||
byte[] digest = mac.doFinal(
|
||||
visitorId.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(digest, 0, 16);
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("HmacSHA256 unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算默认过期时间。
|
||||
*
|
||||
|
||||
@@ -19,6 +19,7 @@ public class WorkFlowUtil {
|
||||
public final static String WORKFLOW_CHAT_SHARE = "WORKFLOW_CHAT_SHARE";
|
||||
public final static String WORKFLOW_KEY = "workflow";
|
||||
public final static String CREATED_KEY_MEMORY_KEY = "workflowCreatedKey";
|
||||
public final static String CREATED_BY_MEMORY_KEY = "workflowCreatedBy";
|
||||
|
||||
public static String removeSensitiveInfo(String originJson) {
|
||||
JSONObject workflowInfo = JSON.parseObject(originJson);
|
||||
@@ -56,6 +57,35 @@ public class WorkFlowUtil {
|
||||
return value == null ? USER_KEY : String.valueOf(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工作流执行记录的归属主体。
|
||||
*
|
||||
* <p>匿名分享可覆盖为访客摘要;其他入口继续使用权限主体账号 ID。</p>
|
||||
*
|
||||
* @param chain 当前工作流执行链
|
||||
* @return 执行归属主体
|
||||
*/
|
||||
public static String getCreatedBy(Chain chain) {
|
||||
Object value = chain.getExecutionState()
|
||||
.getMemory()
|
||||
.get(CREATED_BY_MEMORY_KEY);
|
||||
if (value != null) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
LoginAccount operator = getOperator(chain);
|
||||
return operator.getId() == null ? "0" : operator.getId().toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建工作流匿名分享的执行来源标识。
|
||||
*
|
||||
* @param shareId 分享记录 ID
|
||||
* @return 执行来源标识
|
||||
*/
|
||||
public static String publicChatShareCreatedKey(BigInteger shareId) {
|
||||
return WORKFLOW_CHAT_SHARE + ":" + shareId;
|
||||
}
|
||||
|
||||
public static LoginAccount defaultAccount() {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(new BigInteger("0"));
|
||||
|
||||
@@ -24,6 +24,26 @@ public class WorkflowSharePolicyTest {
|
||||
Assert.assertNotEquals("share-key", first);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证匿名访客归属摘要稳定、定长且按分享密钥隔离。
|
||||
*/
|
||||
@Test
|
||||
public void shouldHashChatVisitorPerShareWithoutLeakingIdentity() {
|
||||
String visitorId = "00112233445566778899aabbccddeeff";
|
||||
|
||||
String first = WorkflowSharePolicy.hashChatVisitor(
|
||||
"share-key-a", visitorId);
|
||||
String second = WorkflowSharePolicy.hashChatVisitor(
|
||||
"share-key-a", visitorId);
|
||||
String otherShare = WorkflowSharePolicy.hashChatVisitor(
|
||||
"share-key-b", visitorId);
|
||||
|
||||
Assert.assertEquals(first, second);
|
||||
Assert.assertEquals(32, first.length());
|
||||
Assert.assertNotEquals(first, otherShare);
|
||||
Assert.assertFalse(first.contains(visitorId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证默认过期时间为创建时间后 30 分钟。
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user