feat: 支持工作流对话匿名分享

- 增加免登录公共接口、访客隔离、限流和匿名上传校验

- 分离 SSE 连接与运行生命周期,支持刷新恢复服务端权威状态

- 持久化分享页对话并优化时间线滚动与输入区交互
This commit is contained in:
2026-08-31 16:45:21 +08:00
parent 6daf805cd0
commit 263f5f4b8b
46 changed files with 3742 additions and 180 deletions

View File

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

View File

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

View File

@@ -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);
}
/**
* 在锁保护下创建或替换工作流的唯一分享记录。
*

View File

@@ -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);
}
}
/**
* 计算默认过期时间。
*

View File

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

View File

@@ -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 分钟。
*/