mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
Two P0 fixes from ISSUE #413 — both address workflow await_approval approvals that silently failed in production: B1 — AwaitApprovalStepAdapter now dispatches the approval notice to every channel in approverChannels that carries a target. Previously approverChannels was write-only metadata: a workflow that declared ["feishu:oc_xxx"] silently dropped the notice and the IM group never learned an approval was waiting. Element format is "channelType" (no push, operator uses admin console) or "channelType:targetId". Each channel failure is logged and skipped — it must not fail the step. B2 — requestWorkflowApproval now registers the wf- approval into the in-memory map via registerRecovered. Previously it only did approvalMapper.insert, so getPending("wf-...") returned null, performResolve short-circuited at the not-pending guard, the WorkflowApprovalResolvedEvent was never published, and ApprovalResumeBridge was dead code. With this fix, resolving a wf- approval walks the full two-phase contract and the bridge fires. Tests: - WorkflowApprovalResumeBridgeTest (3): map registration, event publish on resolve, safe no-op for unregistered wf- ids. - AwaitApprovalNotifyTest (2): targeted channels dispatched, bare "web" skipped, channel failure non-fatal. Regression: ApprovalWorkflowServiceResolveTest (13), AwaitApprovalRuntimeTest (3), DispatchChannelRuntimeTest (3), GcTest (7), RecoveryTest (7) — all green.
This commit is contained in:
parent
f7f1c30557
commit
20014c72ff
@ -335,6 +335,29 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
|||||||
approvalMapper.insert(entity);
|
approvalMapper.insert(entity);
|
||||||
log.info("[ApprovalWorkflow] requested workflow approval row id={}, runId={}, workspace={}, kind={}",
|
log.info("[ApprovalWorkflow] requested workflow approval row id={}, runId={}, workspace={}, kind={}",
|
||||||
entity.getId(), runId, workspaceId, kind);
|
entity.getId(), runId, workspaceId, kind);
|
||||||
|
|
||||||
|
// ISSUE #413: register the workflow approval into the in-memory map
|
||||||
|
// so the resolve → resume bridge actually fires. Without this, the
|
||||||
|
// row only lives in DB and ApprovalService.getPending("wf-...") returns
|
||||||
|
// null, so performResolve() short-circuits at the "not pending" guard
|
||||||
|
// and never reaches the WorkflowApprovalResolvedEvent publish in
|
||||||
|
// Phase 4 — leaving ApprovalResumeBridge as dead code. Mirrors the
|
||||||
|
// recoverFromDb() snapshot shape exactly.
|
||||||
|
Instant createdAt = entity.getCreatedAt() != null
|
||||||
|
? entity.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant()
|
||||||
|
: Instant.now();
|
||||||
|
PendingApproval snapshot = new PendingApproval(
|
||||||
|
entity.getPendingId(),
|
||||||
|
entity.getConversationId(),
|
||||||
|
/*userId*/ null,
|
||||||
|
entity.getToolName(),
|
||||||
|
entity.getToolArguments(),
|
||||||
|
/*reason*/ entity.getSummary(),
|
||||||
|
createdAt,
|
||||||
|
"pending");
|
||||||
|
snapshot.setSummary(entity.getSummary());
|
||||||
|
approvalService.registerRecovered(snapshot);
|
||||||
|
|
||||||
return entity.getId();
|
return entity.getId();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[ApprovalWorkflow] requestWorkflowApproval failed: {}", e.getMessage());
|
log.warn("[ApprovalWorkflow] requestWorkflowApproval failed: {}", e.getMessage());
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import vip.mate.workflow.model.WorkflowRunPauseEntity;
|
|||||||
import vip.mate.workflow.model.WorkflowRunStepEntity;
|
import vip.mate.workflow.model.WorkflowRunStepEntity;
|
||||||
import vip.mate.workflow.repository.WorkflowRunPauseMapper;
|
import vip.mate.workflow.repository.WorkflowRunPauseMapper;
|
||||||
import vip.mate.workflow.repository.WorkflowRunStepMapper;
|
import vip.mate.workflow.repository.WorkflowRunStepMapper;
|
||||||
|
import vip.mate.workflow.runtime.ChannelDispatcher;
|
||||||
import vip.mate.workflow.runtime.StepAdapter;
|
import vip.mate.workflow.runtime.StepAdapter;
|
||||||
import vip.mate.workflow.runtime.StepResult;
|
import vip.mate.workflow.runtime.StepResult;
|
||||||
import vip.mate.workflow.runtime.WorkflowRunContext;
|
import vip.mate.workflow.runtime.WorkflowRunContext;
|
||||||
@ -57,6 +58,13 @@ public class AwaitApprovalStepAdapter implements StepAdapter {
|
|||||||
* adapter falls back to a no-op approval row when null. */
|
* adapter falls back to a no-op approval row when null. */
|
||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private ApprovalWorkflowService approvalService;
|
private ApprovalWorkflowService approvalService;
|
||||||
|
/**
|
||||||
|
* ISSUE #413: used to push the approval notice to every channel listed in
|
||||||
|
* {@code approverChannels}. Optional — null in narrow test contexts (the
|
||||||
|
* notice is skipped and the pause still resolves via REST / inbox).
|
||||||
|
*/
|
||||||
|
@Autowired(required = false)
|
||||||
|
private ChannelDispatcher channelDispatcher;
|
||||||
|
|
||||||
public AwaitApprovalStepAdapter(WorkflowRunPauseMapper pauseMapper,
|
public AwaitApprovalStepAdapter(WorkflowRunPauseMapper pauseMapper,
|
||||||
WorkflowRunStepMapper stepMapper) {
|
WorkflowRunStepMapper stepMapper) {
|
||||||
@ -129,7 +137,70 @@ public class AwaitApprovalStepAdapter implements StepAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ISSUE #413: push the approval notice to the configured channels so
|
||||||
|
// the approver actually learns an approval is waiting. Before this,
|
||||||
|
// approverChannels was write-only metadata and a workflow that asked
|
||||||
|
// for IM notification silently dropped it.
|
||||||
|
notifyApproverChannels(cfg, context, context.runId(), pauseToken);
|
||||||
|
|
||||||
return StepResult.paused(pauseToken,
|
return StepResult.paused(pauseToken,
|
||||||
"awaiting " + (cfg.approvalKind() == null ? "approval" : cfg.approvalKind()));
|
"awaiting " + (cfg.approvalKind() == null ? "approval" : cfg.approvalKind()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISSUE #413: push the approval notice to every channel in
|
||||||
|
* {@code approverChannels}. Previously this field was written into the
|
||||||
|
* approval row's {@code tool_arguments} and never read back, so a workflow
|
||||||
|
* that declared {@code approverChannels: ["feishu"]} silently dropped the
|
||||||
|
* notice — the IM group never learned an approval was waiting.
|
||||||
|
*
|
||||||
|
* <p>Element format is {@code "channelType"} (e.g. {@code "web"} — no
|
||||||
|
* proactive dispatch, operator uses the admin console) or
|
||||||
|
* {@code "channelType:targetId"} (e.g. {@code "feishu:oc_xxx"} — pushes a
|
||||||
|
* text notice to that target). The short {@code pauseId} suffix is
|
||||||
|
* included so the operator can correlate the notice with the inbox entry.
|
||||||
|
* Each channel failure is logged and skipped — a delivery hiccup on one
|
||||||
|
* channel must not fail the step (the pause row + REST resume are the
|
||||||
|
* canonical recovery path).
|
||||||
|
*/
|
||||||
|
private void notifyApproverChannels(StepMode.AwaitApproval cfg, WorkflowRunContext context,
|
||||||
|
long runId, String pauseToken) {
|
||||||
|
if (channelDispatcher == null || cfg.approverChannels() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String kind = cfg.approvalKind() == null || cfg.approvalKind().isBlank() ? "approval" : cfg.approvalKind().trim();
|
||||||
|
String shortToken = pauseToken.substring(0, Math.min(8, pauseToken.length()));
|
||||||
|
String message = "🔐 工作流审批待处理\n"
|
||||||
|
+ "**类型**: " + kind + "\n"
|
||||||
|
+ (cfg.approvalMessage() != null && !cfg.approvalMessage().isBlank()
|
||||||
|
? "**说明**: " + cfg.approvalMessage() + "\n" : "")
|
||||||
|
+ "**runId**: " + runId + "\n"
|
||||||
|
+ "**审批码**: " + shortToken + "\n"
|
||||||
|
+ "请前往管理端审批(工作流 → 运行记录 → 恢复)。";
|
||||||
|
|
||||||
|
for (String entry : cfg.approverChannels()) {
|
||||||
|
if (entry == null || entry.isBlank()) continue;
|
||||||
|
String channelType = entry;
|
||||||
|
String targetId = null;
|
||||||
|
int colon = entry.indexOf(':');
|
||||||
|
if (colon > 0) {
|
||||||
|
channelType = entry.substring(0, colon);
|
||||||
|
targetId = entry.substring(colon + 1);
|
||||||
|
}
|
||||||
|
// "web" (and any channel with no target) means "operator handles
|
||||||
|
// it from the admin console" — no proactive push.
|
||||||
|
if (targetId == null || targetId.isBlank()) continue;
|
||||||
|
try {
|
||||||
|
ChannelDispatcher.DispatchResult result =
|
||||||
|
channelDispatcher.dispatch(context.workspaceId(), channelType, targetId, message);
|
||||||
|
if (!result.success()) {
|
||||||
|
org.slf4j.LoggerFactory.getLogger(AwaitApprovalStepAdapter.class)
|
||||||
|
.warn("await_approval notify failed for channel '{}': {}", channelType, result.message());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
org.slf4j.LoggerFactory.getLogger(AwaitApprovalStepAdapter.class)
|
||||||
|
.warn("await_approval notify threw for channel '{}': {}", channelType, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,168 @@
|
|||||||
|
package vip.mate.approval;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.apache.ibatis.session.Configuration;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
import vip.mate.approval.event.WorkflowApprovalResolvedEvent;
|
||||||
|
import vip.mate.approval.model.ToolApprovalEntity;
|
||||||
|
import vip.mate.approval.repository.ToolApprovalMapper;
|
||||||
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
|
import static org.mockito.Mockito.atLeastOnce;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies ISSUE #413 P0-B2: a workflow-scoped approval created via
|
||||||
|
* {@code requestWorkflowApproval} is registered into the in-memory map, so a
|
||||||
|
* subsequent {@code resolve("wf-...")} walks the full two-phase contract and
|
||||||
|
* publishes {@link WorkflowApprovalResolvedEvent} — the event that
|
||||||
|
* {@code ApprovalResumeBridge} listens for to resume the paused run.
|
||||||
|
*
|
||||||
|
* <p>Before the fix, {@code requestWorkflowApproval} only did
|
||||||
|
* {@code approvalMapper.insert(entity)} without {@code registerRecovered}, so
|
||||||
|
* {@code getPending("wf-...")} returned null, {@code performResolve}
|
||||||
|
* short-circuited at the "not pending" guard, the event was never published,
|
||||||
|
* and {@code ApprovalResumeBridge} was dead code.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class WorkflowApprovalResumeBridgeTest {
|
||||||
|
|
||||||
|
@Mock private ToolApprovalMapper approvalMapper;
|
||||||
|
@Mock private ConversationService conversationService;
|
||||||
|
|
||||||
|
private ApprovalService approvalService;
|
||||||
|
private CapturingEventPublisher publisher;
|
||||||
|
private ApprovalWorkflowService workflow;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initMyBatisPlusCache() {
|
||||||
|
TableInfoHelper.initTableInfo(
|
||||||
|
new MapperBuilderAssistant(new Configuration(), ""),
|
||||||
|
ToolApprovalEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
approvalService = new ApprovalService();
|
||||||
|
publisher = new CapturingEventPublisher();
|
||||||
|
workflow = new ApprovalWorkflowService(
|
||||||
|
approvalService, approvalMapper, new ObjectMapper(), conversationService);
|
||||||
|
// events is @Autowired(required = false) with no setter; inject via
|
||||||
|
// reflection so this unit test (no Spring context) can capture the
|
||||||
|
// WorkflowApprovalResolvedEvent publish.
|
||||||
|
try {
|
||||||
|
var field = ApprovalWorkflowService.class.getDeclaredField("events");
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(workflow, publisher);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("failed to inject event publisher", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("requestWorkflowApproval registers the wf- approval into the in-memory map")
|
||||||
|
void requestRegistersIntoMemoryMap() {
|
||||||
|
// insert returns the row id the adapter writes back as external_approval_id.
|
||||||
|
when(approvalMapper.insert(any(ToolApprovalEntity.class))).thenAnswer(inv -> {
|
||||||
|
((ToolApprovalEntity) inv.getArgument(0)).setId(42L);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
Long approvalId = workflow.requestWorkflowApproval(
|
||||||
|
1L, 100L, 7L, "manager", "please approve", java.util.List.of("web"), 1800);
|
||||||
|
|
||||||
|
assertThat(approvalId).isEqualTo(42L);
|
||||||
|
|
||||||
|
// The fix: the wf- entry is now in the in-memory map, queryable by the
|
||||||
|
// synthetic conversation key. Before the fix, getPending("wf-...")
|
||||||
|
// returned null and the resolve path dead-ended at the "not pending"
|
||||||
|
// guard, leaving ApprovalResumeBridge as dead code.
|
||||||
|
PendingApproval pending = approvalService.findPendingByConversation("workflow:run:100");
|
||||||
|
assertThat(pending).as("wf- approval must be in the in-memory map after request").isNotNull();
|
||||||
|
assertThat(pending.getPendingId()).startsWith("wf-");
|
||||||
|
assertThat(pending.getToolName()).isEqualTo("workflow:manager");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("resolve('wf-...') publishes WorkflowApprovalResolvedEvent after the fix")
|
||||||
|
void resolvePublishesWorkflowEvent() {
|
||||||
|
// Seed via requestWorkflowApproval so the entry is in the map under a
|
||||||
|
// wf- pendingId (the same path the AwaitApprovalStepAdapter takes).
|
||||||
|
when(approvalMapper.insert(any(ToolApprovalEntity.class))).thenAnswer(inv -> {
|
||||||
|
((ToolApprovalEntity) inv.getArgument(0)).setId(42L);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
workflow.requestWorkflowApproval(
|
||||||
|
1L, 100L, 7L, "manager", "please approve", java.util.List.of("web"), 1800);
|
||||||
|
|
||||||
|
// Recover the generated wf- pendingId via the conversation key.
|
||||||
|
PendingApproval pending = approvalService.findPendingByConversation("workflow:run:100");
|
||||||
|
assertThat(pending).as("wf- approval must be in the in-memory map after request").isNotNull();
|
||||||
|
assertThat(pending.getPendingId()).startsWith("wf-");
|
||||||
|
String pendingId = pending.getPendingId();
|
||||||
|
|
||||||
|
// Two-phase resolve: DB UPDATE conditional on PENDING succeeds.
|
||||||
|
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
|
||||||
|
when(conversationService.markPendingApprovalsResolved(
|
||||||
|
eq("workflow:run:100"), eq(Set.of(pendingId)), any())).thenReturn(0);
|
||||||
|
// selectOne lookup for the workflow-bridge row id (Phase 4).
|
||||||
|
ToolApprovalEntity row = new ToolApprovalEntity();
|
||||||
|
row.setId(42L);
|
||||||
|
row.setPendingId(pendingId);
|
||||||
|
when(approvalMapper.selectOne(any())).thenReturn(row);
|
||||||
|
|
||||||
|
ResolveOutcome outcome = workflow.resolve(pendingId, "operator", "approved");
|
||||||
|
|
||||||
|
assertThat(outcome.dbSynced()).isTrue();
|
||||||
|
assertThat(outcome.decision()).isEqualTo("approved");
|
||||||
|
|
||||||
|
// The critical assertion: the workflow-resolved event was published.
|
||||||
|
// ApprovalResumeBridge listens for this to call WorkflowResumer.resume.
|
||||||
|
assertThat(publisher.workflowEvents).hasSize(1);
|
||||||
|
WorkflowApprovalResolvedEvent ev = publisher.workflowEvents.get(0);
|
||||||
|
assertThat(ev.approvalRowId()).isEqualTo(42L);
|
||||||
|
assertThat(ev.pendingId()).isEqualTo(pendingId);
|
||||||
|
assertThat(ev.decision()).isEqualTo("approved");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("resolve of a wf- approval that was NOT registered is still a safe no-op")
|
||||||
|
void resolveUnregisteredWorkflowApprovalIsNoop() {
|
||||||
|
// Simulate the pre-fix state: a wf- pendingId that never entered the map.
|
||||||
|
ResolveOutcome outcome = workflow.resolve("wf-ghostthatdoesnotexist", "operator", "approved");
|
||||||
|
|
||||||
|
assertThat(outcome.isAlreadyResolved()).isTrue();
|
||||||
|
assertThat(publisher.workflowEvents).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Captures published events so tests can assert on the workflow-bridge event. */
|
||||||
|
static class CapturingEventPublisher implements ApplicationEventPublisher {
|
||||||
|
final List<WorkflowApprovalResolvedEvent> workflowEvents = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void publishEvent(Object event) {
|
||||||
|
if (event instanceof WorkflowApprovalResolvedEvent wfe) {
|
||||||
|
workflowEvents.add(wfe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,157 @@
|
|||||||
|
package vip.mate.workflow.runtime;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
import vip.mate.MateClawApplication;
|
||||||
|
import vip.mate.workflow.compiler.WorkflowParser;
|
||||||
|
import vip.mate.workflow.compiler.ir.WorkflowGraph;
|
||||||
|
import vip.mate.workflow.model.WorkflowRunPauseEntity;
|
||||||
|
import vip.mate.workflow.repository.WorkflowRunPauseMapper;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies ISSUE #413 P0-B1: an {@code await_approval} step pushes a notice to
|
||||||
|
* every channel listed in {@code approverChannels} that carries a target. Before
|
||||||
|
* the fix, {@code approverChannels} was write-only metadata — a workflow that
|
||||||
|
* declared {@code ["feishu:oc_xxx"]} silently dropped the notice.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(
|
||||||
|
classes = MateClawApplication.class,
|
||||||
|
webEnvironment = SpringBootTest.WebEnvironment.NONE
|
||||||
|
)
|
||||||
|
@TestPropertySource(properties = {
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:workflow_notify_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1",
|
||||||
|
"spring.ai.dashscope.api-key=test-key",
|
||||||
|
"spring.main.web-application-type=none",
|
||||||
|
"mateclaw.workflow.trigger.async-dispatch=false"
|
||||||
|
})
|
||||||
|
@Import({StubAgentInvokerConfig.class,
|
||||||
|
AwaitApprovalNotifyTest.StubChannelDispatcherConfig.class})
|
||||||
|
class AwaitApprovalNotifyTest {
|
||||||
|
|
||||||
|
@Autowired private WorkflowRunner runner;
|
||||||
|
@Autowired private WorkflowParser parser;
|
||||||
|
@Autowired private WorkflowRunPauseMapper pauseMapper;
|
||||||
|
@Autowired private StubChannelDispatcher stubDispatcher;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("approverChannels with target dispatches a notice; bare 'web' does not.")
|
||||||
|
void dispatchesNoticeToTargetedChannels() {
|
||||||
|
stubDispatcher.reset();
|
||||||
|
|
||||||
|
WorkflowGraph graph = parser.parse("""
|
||||||
|
{
|
||||||
|
"steps": [
|
||||||
|
{"name":"approve",
|
||||||
|
"mode":{"type":"await_approval","approvalKind":"manager",
|
||||||
|
"approverChannels":["feishu:oc_manager_group","web","email:ops@acme.com"],
|
||||||
|
"approvalMessage":"请经理审批新客户入驻"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
WorkflowRunResult result = runner.run(graph,
|
||||||
|
new WorkflowRunRequest(70L, 1L, 99L, "manual", Map.of()));
|
||||||
|
assertEquals("paused", result.state());
|
||||||
|
|
||||||
|
// feishu + email both carry a target → two dispatches; "web" has no
|
||||||
|
// target → skipped (operator uses the admin console).
|
||||||
|
List<StubChannelDispatcher.Sent> sent = stubDispatcher.sentList();
|
||||||
|
assertEquals(2, sent.size(), "only channels with an explicit target should be notified");
|
||||||
|
assertTrue(sent.stream().anyMatch(s -> "feishu".equals(s.channel())
|
||||||
|
&& "oc_manager_group".equals(s.target())));
|
||||||
|
assertTrue(sent.stream().anyMatch(s -> "email".equals(s.channel())
|
||||||
|
&& "ops@acme.com".equals(s.target())));
|
||||||
|
|
||||||
|
// The notice body carries the approval message + runId so the approver
|
||||||
|
// can correlate it with the inbox entry.
|
||||||
|
assertTrue(sent.get(0).content().contains("请经理审批新客户入驻"));
|
||||||
|
assertTrue(sent.get(0).content().contains("runId"),
|
||||||
|
"notice should mention runId: " + sent.get(0).content());
|
||||||
|
|
||||||
|
// The pause row still exists — the notice is non-fatal best-effort.
|
||||||
|
WorkflowRunPauseEntity pause = pauseMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<WorkflowRunPauseEntity>()
|
||||||
|
.eq(WorkflowRunPauseEntity::getRunId, result.runId()));
|
||||||
|
assertNotNull(pause);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("A channel dispatch failure does not fail the await_approval step.")
|
||||||
|
void channelFailureIsNonFatal() {
|
||||||
|
stubDispatcher.reset();
|
||||||
|
stubDispatcher.makeFail("feishu", "rate limited");
|
||||||
|
|
||||||
|
WorkflowGraph graph = parser.parse("""
|
||||||
|
{
|
||||||
|
"steps": [
|
||||||
|
{"name":"approve",
|
||||||
|
"mode":{"type":"await_approval","approvalKind":"k",
|
||||||
|
"approverChannels":["feishu:oc_group"]}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
WorkflowRunResult result = runner.run(graph,
|
||||||
|
new WorkflowRunRequest(71L, 1L, 99L, "manual", Map.of()));
|
||||||
|
// The step still pauses successfully — a delivery hiccup must not abort
|
||||||
|
// the run (pause row + REST resume are the canonical recovery path).
|
||||||
|
assertEquals("paused", result.state());
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
static class StubChannelDispatcherConfig {
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
StubChannelDispatcher stubChannelDispatcher() {
|
||||||
|
return new StubChannelDispatcher();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static class StubChannelDispatcher implements ChannelDispatcher {
|
||||||
|
record Sent(String channel, String target, String content) {}
|
||||||
|
|
||||||
|
private final List<Sent> sent = new ArrayList<>();
|
||||||
|
private final Map<String, String> failures = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
synchronized void reset() {
|
||||||
|
sent.clear();
|
||||||
|
failures.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized List<Sent> sentList() {
|
||||||
|
return List.copyOf(sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
void makeFail(String channelType, String message) {
|
||||||
|
failures.put(channelType, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized DispatchResult dispatch(long workspaceId, String channelType,
|
||||||
|
String targetId, String content) {
|
||||||
|
String forced = failures.get(channelType);
|
||||||
|
if (forced != null) {
|
||||||
|
return DispatchResult.fail(forced);
|
||||||
|
}
|
||||||
|
sent.add(new Sent(channelType, targetId, content));
|
||||||
|
return DispatchResult.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user