mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
Harden goal approval and workspace flows
This commit is contained in:
parent
5cd6e841a4
commit
a9c2d45790
@ -519,6 +519,7 @@ public class AgentGraphBuilder {
|
||||
// Token Usage
|
||||
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.LLM_CALL_COUNT, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
|
||||
// SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的
|
||||
@ -540,6 +541,8 @@ public class AgentGraphBuilder {
|
||||
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, KeyStrategy.REPLACE)
|
||||
// Skill progressive disclosure — pinned skills loaded this
|
||||
// run. Registered in BOTH graphs so the read-merge-write in
|
||||
// ActionNode is not dropped on multi-node merges.
|
||||
@ -798,6 +801,8 @@ public class AgentGraphBuilder {
|
||||
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, KeyStrategy.REPLACE)
|
||||
// Skill progressive disclosure — pinned skills loaded this
|
||||
// run. Registered in BOTH graphs so the read-merge-write in
|
||||
// ActionNode is not dropped on multi-node merges.
|
||||
|
||||
@ -5,6 +5,7 @@ import com.alibaba.cloud.ai.graph.action.EdgeAction;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import static vip.mate.agent.graph.state.MateClawStateKeys.GOAL_EVALUATED_THIS_RUN;
|
||||
import static vip.mate.agent.graph.state.MateClawStateKeys.GOAL_FOLLOWUP_INJECTED;
|
||||
|
||||
/**
|
||||
@ -29,8 +30,15 @@ public class GoalEvaluationDispatcher implements EdgeAction {
|
||||
|
||||
@Override
|
||||
public String apply(OverAllState state) {
|
||||
// Re-enter the loop only when a followup was injected AND this was not a
|
||||
// terminal evaluation pass. GOAL_FOLLOWUP_INJECTED uses the REPLACE key
|
||||
// strategy and is never cleared by the reasoning nodes, so after a
|
||||
// run-to-completion loop it can linger true; goalEvaluatedThisRun (set
|
||||
// true on every terminal branch — completed / exhausted / skip /
|
||||
// continue-without-followup) is the authoritative end-of-run signal.
|
||||
boolean followup = Boolean.TRUE.equals(state.value(GOAL_FOLLOWUP_INJECTED, false));
|
||||
if (followup) {
|
||||
boolean terminal = Boolean.TRUE.equals(state.value(GOAL_EVALUATED_THIS_RUN, false));
|
||||
if (followup && !terminal) {
|
||||
log.debug("[GoalEvaluationDispatcher] followup injected -> routing to {}", followupTarget);
|
||||
return followupTarget;
|
||||
}
|
||||
|
||||
@ -160,10 +160,13 @@ public class GoalEvaluationNode implements NodeAction {
|
||||
try {
|
||||
result = evaluationService.evaluate(goal, recent, terminal);
|
||||
|
||||
// Pre-eval agent_llm count snapshot — the bookkeeping helper
|
||||
// folds it into the per-goal counter so future turns see
|
||||
// growing usage.
|
||||
int agentLlmDelta = accessor.llmCallCount();
|
||||
// Bill only the NEW agent LLM calls since the last accounted point.
|
||||
// The run-to-completion loop evaluates multiple times per graph run
|
||||
// while LLM_CALL_COUNT keeps growing, so passing the cumulative value
|
||||
// raw would re-bill earlier calls on every pass and exhaust the
|
||||
// goal's LLM budget prematurely. The followup branch advances the
|
||||
// accounted marker; terminal branches don't (the run ends there).
|
||||
int agentLlmDelta = Math.max(0, accessor.llmCallCount() - accessor.goalAccountedLlmCallCount());
|
||||
int evalLlmDelta = result.llmCallsConsumed();
|
||||
goalService.recordEvaluation(goal.getId(), result, agentLlmDelta, evalLlmDelta);
|
||||
|
||||
@ -219,6 +222,7 @@ public class GoalEvaluationNode implements NodeAction {
|
||||
.build();
|
||||
}
|
||||
|
||||
int followupCountThisRun = accessor.goalFollowupCount();
|
||||
Optional<String> followup;
|
||||
try {
|
||||
followup = followupService.maybeBuildFollowup(refreshed, result);
|
||||
@ -227,7 +231,18 @@ public class GoalEvaluationNode implements NodeAction {
|
||||
refreshed.getId(), t.toString());
|
||||
followup = Optional.empty();
|
||||
}
|
||||
if (followup.isPresent()) {
|
||||
// Per-run safety net: cap the autonomous self-continuation loop so a
|
||||
// single user message can't drive an unbounded number of steps or
|
||||
// approach the graph recursion limit. When the cap is hit we fall
|
||||
// through to the terminal "continue, no followup" path — the goal stays
|
||||
// active and the cross-message turn / LLM budget (or the user) carries
|
||||
// it on.
|
||||
boolean perRunCapReached = followupCountThisRun >= properties.getMaxFollowupsPerRun();
|
||||
if (followup.isPresent() && perRunCapReached) {
|
||||
log.info("[GoalEvaluationNode] per-run followup cap reached ({}/{}) for goal={}; ending this run",
|
||||
followupCountThisRun, properties.getMaxFollowupsPerRun(), refreshed.getId());
|
||||
}
|
||||
if (followup.isPresent() && !perRunCapReached) {
|
||||
try {
|
||||
goalService.recordFollowupInjected(refreshed.getId(), followup.get());
|
||||
} catch (Throwable t) {
|
||||
@ -240,7 +255,18 @@ public class GoalEvaluationNode implements NodeAction {
|
||||
.goalEvaluationResult(result.toMap())
|
||||
.goalFollowupInjected(true)
|
||||
.goalFollowupPrompt(followup.get())
|
||||
.goalEvaluatedThisRun(true)
|
||||
.goalFollowupCount(followupCountThisRun + 1)
|
||||
// Advance the LLM-billing marker to the current cumulative
|
||||
// count so the NEXT evaluation in this run charges only its
|
||||
// own delta (see agentLlmDelta above).
|
||||
.goalAccountedLlmCallCount(accessor.llmCallCount())
|
||||
// Deliberately NOT setting goalEvaluatedThisRun(true): leaving
|
||||
// it false lets the NEXT answer be re-evaluated, turning the
|
||||
// old single-step behaviour into run-to-completion. The loop
|
||||
// is bounded by the per-run cap above plus the turn / LLM
|
||||
// budgets; the dispatcher treats any terminal pass
|
||||
// (goalEvaluatedThisRun == true) as END even if this flag
|
||||
// lingers true under the REPLACE key strategy.
|
||||
.needsToolCall(false)
|
||||
.events(List.of(goalEvent("goal_followup", Map.of(
|
||||
"goalId", String.valueOf(refreshed.getId()),
|
||||
|
||||
@ -246,8 +246,10 @@ public final class PlanStateAccessor {
|
||||
NodeStreamingChatHelper.StreamResult result) {
|
||||
int existingPrompt = currentState.value(MateClawStateKeys.PROMPT_TOKENS, 0);
|
||||
int existingCompletion = currentState.value(MateClawStateKeys.COMPLETION_TOKENS, 0);
|
||||
int existingLlmCalls = currentState.value(MateClawStateKeys.LLM_CALL_COUNT, 0);
|
||||
map.put(MateClawStateKeys.PROMPT_TOKENS, existingPrompt + result.promptTokens());
|
||||
map.put(MateClawStateKeys.COMPLETION_TOKENS, existingCompletion + result.completionTokens());
|
||||
map.put(MateClawStateKeys.LLM_CALL_COUNT, existingLlmCalls + 1);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@ -293,6 +293,16 @@ public final class MateClawStateAccessor {
|
||||
return state.value(GOAL_FOLLOWUP_PROMPT, "");
|
||||
}
|
||||
|
||||
/** Auto-followups already injected in this graph run (0 at run start). */
|
||||
public int goalFollowupCount() {
|
||||
return state.value(GOAL_FOLLOWUP_COUNT, 0);
|
||||
}
|
||||
|
||||
/** Cumulative agent LLM calls already billed to the goal this run (0 at run start). */
|
||||
public int goalAccountedLlmCallCount() {
|
||||
return state.value(GOAL_ACCOUNTED_LLM_CALL_COUNT, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge across ReAct and Plan-Execute: ReAct writes the terminal text
|
||||
* to {@link MateClawStateKeys#FINAL_ANSWER} via FinalAnswerNode;
|
||||
@ -534,6 +544,14 @@ public final class MateClawStateAccessor {
|
||||
return put(GOAL_EVALUATED_THIS_RUN, v);
|
||||
}
|
||||
|
||||
public OutputBuilder goalFollowupCount(int n) {
|
||||
return put(GOAL_FOLLOWUP_COUNT, n);
|
||||
}
|
||||
|
||||
public OutputBuilder goalAccountedLlmCallCount(int n) {
|
||||
return put(GOAL_ACCOUNTED_LLM_CALL_COUNT, n);
|
||||
}
|
||||
|
||||
/** Wipe FINAL_ANSWER on follow-up so the next graph pass doesn't
|
||||
* immediately re-terminate via the existing final text. */
|
||||
public OutputBuilder clearFinalAnswer() {
|
||||
|
||||
@ -200,14 +200,34 @@ public final class MateClawStateKeys {
|
||||
public static final String GOAL_FOLLOWUP_PROMPT = "goal_followup_prompt";
|
||||
|
||||
/**
|
||||
* Re-entry guard: GoalEvaluationNode sets this true on its first run
|
||||
* of a graph invocation; the FinalAnswerNode→GoalEvaluation conditional
|
||||
* edge skips re-entering the node when it's already true. Combined with
|
||||
* the dispatcher's followup clearing of FINAL_ANSWER, this bounds
|
||||
* follow-ups to at most one per graph run.
|
||||
* Re-entry guard for TERMINAL evaluation passes: GoalEvaluationNode sets
|
||||
* this true only when it ENDS the run (completed / exhausted / skip /
|
||||
* continue-without-followup). The FinalAnswerNode→GoalEvaluation edge skips
|
||||
* re-entering once it's true. The followup branch deliberately leaves it
|
||||
* false so the self-continuation loop can re-evaluate the next answer; that
|
||||
* loop is bounded instead by {@link #GOAL_FOLLOWUP_COUNT} (per-run cap) plus
|
||||
* the goal's turn / LLM-call budgets.
|
||||
*/
|
||||
public static final String GOAL_EVALUATED_THIS_RUN = "goal_evaluated_this_run";
|
||||
|
||||
/**
|
||||
* Number of auto-followups already injected in THIS graph run (one user
|
||||
* turn). Bounds the self-continuation loop per single message — independent
|
||||
* of the goal's cross-turn turn_budget — so one message can't drive an
|
||||
* unbounded number of autonomous steps or exhaust the graph recursion
|
||||
* limit. Implicitly 0 at the start of each graph invocation.
|
||||
*/
|
||||
public static final String GOAL_FOLLOWUP_COUNT = "goal_followup_count";
|
||||
|
||||
/**
|
||||
* Cumulative agent LLM-call count already billed to the goal in THIS graph
|
||||
* run. The run-to-completion loop evaluates multiple times per run while
|
||||
* {@link #LLM_CALL_COUNT} keeps growing; recording only
|
||||
* (current − accounted) on each pass avoids re-billing earlier calls and
|
||||
* exhausting the goal's LLM budget prematurely. Implicitly 0 at run start.
|
||||
*/
|
||||
public static final String GOAL_ACCOUNTED_LLM_CALL_COUNT = "goal_accounted_llm_call_count";
|
||||
|
||||
/** Graph-node identifier for the GoalEvaluationNode. */
|
||||
public static final String GOAL_EVALUATION_NODE = "goal_evaluation";
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package vip.mate.auth.model;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@ -20,7 +21,12 @@ public class UserEntity {
|
||||
/** 用户名 */
|
||||
private String username;
|
||||
|
||||
/** 密码(BCrypt加密) */
|
||||
/**
|
||||
* 密码(BCrypt加密)。WRITE_ONLY: accepted from request bodies (login / user
|
||||
* creation) but never serialized into a response, so the bcrypt hash cannot
|
||||
* leak via endpoints that return UserEntity (e.g. GET /auth/users).
|
||||
*/
|
||||
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||
private String password;
|
||||
|
||||
/** 昵称 */
|
||||
|
||||
@ -488,6 +488,47 @@ public class ChannelMessageRouter {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity gate shared by /approve and /deny (group-chat safety): only the
|
||||
* original human requester may resolve a pending. Agent/cron ("system") and
|
||||
* unattributed (null) approvals are fail-closed in IM — any group member
|
||||
* could otherwise approve OR deny/cancel a guarded action — and must be
|
||||
* handled from the admin console. Sends the rejection notice + logs and
|
||||
* returns {@code false} when the caller is not authorized.
|
||||
*/
|
||||
private boolean approvalResolveAuthorized(PendingApproval pending, ChannelMessage message,
|
||||
ChannelAdapter adapter, String replyTarget) {
|
||||
String originalRequester = pending.getUserId();
|
||||
boolean systemOriginated = originalRequester == null || "system".equals(originalRequester);
|
||||
if (systemOriginated || !originalRequester.equals(message.getSenderId())) {
|
||||
adapter.sendMessage(replyTarget, systemOriginated
|
||||
? "⚠️ 该审批由系统/定时任务发起,请在管理端处理。"
|
||||
: "⚠️ 只有原始请求者可以审批此操作。");
|
||||
log.warn("[{}] Approval resolve rejected: sender={} != requester={} (systemOriginated={})",
|
||||
adapter.getChannelType(), message.getSenderId(), originalRequester, systemOriginated);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* When an /approve or /deny command carries an explicit short pendingId
|
||||
* (e.g. "/deny a1b2c3"), verify it matches the conversation's current
|
||||
* pending before resolving — otherwise a stale or copy-pasted id would
|
||||
* silently act on the wrong pending. Sends the mismatch notice and returns
|
||||
* {@code true} (caller must abort) when the ids don't line up.
|
||||
*/
|
||||
private boolean pendingIdMismatch(String userText, PendingApproval pending,
|
||||
ChannelAdapter adapter, String replyTarget) {
|
||||
String shortId = extractShortId(userText);
|
||||
if (shortId != null && !pending.getPendingId().startsWith(shortId)) {
|
||||
adapter.sendMessage(replyTarget, "⚠️ 审批ID不匹配。当前待审批: "
|
||||
+ pending.getPendingId().substring(0, Math.min(6, pending.getPendingId().length())));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ==================== 消息处理(原 route 逻辑 + 审批拦截层) ====================
|
||||
|
||||
/**
|
||||
@ -510,20 +551,12 @@ public class ChannelMessageRouter {
|
||||
String replyTarget = resolveReplyTarget(message);
|
||||
|
||||
if (isApproveCommand(userText)) {
|
||||
// pendingId 校验:如果命令包含 shortId,验证是否匹配当前 pending
|
||||
String shortId = extractShortId(userText);
|
||||
if (shortId != null && !pending.getPendingId().startsWith(shortId)) {
|
||||
adapter.sendMessage(replyTarget, "⚠️ 审批ID不匹配。当前待审批: "
|
||||
+ pending.getPendingId().substring(0, Math.min(6, pending.getPendingId().length())));
|
||||
// pendingId 校验:approve / deny 共用——命令带 shortId 时必须匹配当前 pending。
|
||||
if (pendingIdMismatch(userText, pending, adapter, replyTarget)) {
|
||||
return;
|
||||
}
|
||||
// 身份校验:只有原始请求者可以审批(群聊安全)
|
||||
String originalRequester = pending.getUserId();
|
||||
if (originalRequester != null && !"system".equals(originalRequester)
|
||||
&& !originalRequester.equals(message.getSenderId())) {
|
||||
adapter.sendMessage(replyTarget, "⚠️ 只有原始请求者可以审批此操作。");
|
||||
log.warn("[{}] Approval rejected: sender={} != requester={}",
|
||||
adapter.getChannelType(), message.getSenderId(), originalRequester);
|
||||
// 身份校验:approve / deny 共用同一道门禁(群聊安全 + system/null fail-closed)。
|
||||
if (!approvalResolveAuthorized(pending, message, adapter, replyTarget)) {
|
||||
return;
|
||||
}
|
||||
// Approve via IM: workflow.resolveAndConsume runs DB + metadata + memory atomically.
|
||||
@ -542,9 +575,24 @@ public class ChannelMessageRouter {
|
||||
return;
|
||||
|
||||
} else if (isDenyCommand(userText)) {
|
||||
// pendingId 校验:与 approve 一致——命令带 shortId 时必须匹配当前 pending,
|
||||
// 否则 /deny <其它ID> 会错误地拒绝当前 conversation 的 pending。
|
||||
if (pendingIdMismatch(userText, pending, adapter, replyTarget)) {
|
||||
return;
|
||||
}
|
||||
// 身份校验:deny 与 approve 共用门禁。否则群里任意成员可拒绝/取消他人的
|
||||
// pending,system/null 发起的审批也会被任意人 deny(取消审批、清 placeholder、
|
||||
// 写入 denied 状态);这类审批改到管理端处理。
|
||||
if (!approvalResolveAuthorized(pending, message, adapter, replyTarget)) {
|
||||
return;
|
||||
}
|
||||
// Deny via IM: workflow.resolve owns the full state-machine transition.
|
||||
ResolveOutcome denyOutcome = approvalService.resolve(
|
||||
pending.getPendingId(), message.getSenderId(), "denied");
|
||||
if (denyOutcome.isAlreadyResolved()) {
|
||||
adapter.sendMessage(replyTarget, "⚠️ 审批记录已过期或已被处理。");
|
||||
return;
|
||||
}
|
||||
conversationService.removeApprovalPlaceholders(conversationId);
|
||||
String denyHint = "⛔ 已拒绝执行工具: " + pending.getToolName();
|
||||
persistAndBroadcastApprovalHint(conversationId, denyHint,
|
||||
|
||||
@ -100,11 +100,16 @@ public class ToolGuardCardHandler implements FeishuCardHandler {
|
||||
}
|
||||
PendingApproval pending = opt.get();
|
||||
|
||||
// ---- 3. Identity check
|
||||
// ---- 3. Identity check (fail-closed)
|
||||
// Agent/cron ("system") or unattributed (null) approvals have no human
|
||||
// requester to match the clicker against. A guarded-tool card landing in
|
||||
// a group chat would otherwise let ANY member click Approve and run the
|
||||
// tool. Those approvals must be resolved from the admin console instead,
|
||||
// so only an exact requester==clicker match is authorized here.
|
||||
String originalRequester = pending.getUserId();
|
||||
boolean authorized = originalRequester == null
|
||||
|| "system".equals(originalRequester)
|
||||
|| originalRequester.equals(clickerOpenId);
|
||||
boolean authorized = originalRequester != null
|
||||
&& !"system".equals(originalRequester)
|
||||
&& originalRequester.equals(clickerOpenId);
|
||||
if (!authorized) {
|
||||
log.warn("[feishu-toolguard] Unauthorised click: clicker={} != requester={}, pending={}",
|
||||
abbrev(clickerOpenId), abbrev(originalRequester), pendingId);
|
||||
|
||||
@ -75,11 +75,16 @@ public class ToolGuardCardHandler implements WeComCardHandler {
|
||||
}
|
||||
PendingApproval pending = opt.get();
|
||||
|
||||
// ---- 3. Identity check ----
|
||||
// ---- 3. Identity check (fail-closed) ----
|
||||
// Agent/cron ("system") or unattributed (null) approvals have no human
|
||||
// requester to match the clicker against; a group card would let any
|
||||
// member resolve a guarded action. Reject here (mirrors the feishu card
|
||||
// handler + router) so we never renderResolved a click the router will
|
||||
// then refuse to execute. These approvals go through the admin console.
|
||||
String originalRequester = pending.getUserId();
|
||||
boolean isAuthorized = originalRequester == null
|
||||
|| "system".equals(originalRequester)
|
||||
|| originalRequester.equals(clickerUserId);
|
||||
boolean isAuthorized = originalRequester != null
|
||||
&& !"system".equals(originalRequester)
|
||||
&& originalRequester.equals(clickerUserId);
|
||||
if (!isAuthorized) {
|
||||
log.warn("[wecom-toolguard] Unauthorised click: clicker={} != requester={}, pending={}",
|
||||
abbrev(clickerUserId), abbrev(originalRequester), pendingId);
|
||||
|
||||
@ -10,7 +10,7 @@ import vip.mate.datasource.service.DatasourceService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
|
||||
|
||||
/**
|
||||
* 数据源管理接口
|
||||
@ -27,28 +27,28 @@ public class DatasourceController {
|
||||
|
||||
@Operation(summary = "获取数据源列表")
|
||||
@GetMapping
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<List<DatasourceEntity>> list() {
|
||||
return R.ok(datasourceService.listAll());
|
||||
}
|
||||
|
||||
@Operation(summary = "获取数据源详情")
|
||||
@GetMapping("/{id}")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<DatasourceEntity> get(@PathVariable Long id) {
|
||||
return R.ok(datasourceService.getByIdMasked(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "创建数据源")
|
||||
@PostMapping
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<DatasourceEntity> create(@RequestBody DatasourceEntity entity) {
|
||||
return R.ok(datasourceService.create(entity));
|
||||
}
|
||||
|
||||
@Operation(summary = "更新数据源")
|
||||
@PutMapping("/{id}")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<DatasourceEntity> update(@PathVariable Long id, @RequestBody DatasourceEntity entity) {
|
||||
entity.setId(id);
|
||||
return R.ok(datasourceService.update(entity));
|
||||
@ -56,7 +56,7 @@ public class DatasourceController {
|
||||
|
||||
@Operation(summary = "删除数据源")
|
||||
@DeleteMapping("/{id}")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<Void> delete(@PathVariable Long id) {
|
||||
datasourceService.delete(id);
|
||||
return R.ok();
|
||||
@ -64,7 +64,7 @@ public class DatasourceController {
|
||||
|
||||
@Operation(summary = "测试数据源连接")
|
||||
@PostMapping("/{id}/test")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> testConnection(@PathVariable Long id) {
|
||||
boolean ok = datasourceService.testConnection(id);
|
||||
return R.ok(Map.of("success", ok, "message", ok ? "连接成功" : "连接失败"));
|
||||
@ -72,7 +72,7 @@ public class DatasourceController {
|
||||
|
||||
@Operation(summary = "启用/禁用数据源")
|
||||
@PutMapping("/{id}/toggle")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<DatasourceEntity> toggle(@PathVariable Long id, @RequestParam boolean enabled) {
|
||||
return R.ok(datasourceService.toggle(id, enabled));
|
||||
}
|
||||
|
||||
@ -35,6 +35,15 @@ public class GoalProperties {
|
||||
/** Default cooldown between auto-followups in seconds. */
|
||||
private int autoFollowupCooldownSeconds = 0;
|
||||
|
||||
/**
|
||||
* Max auto-followups injected within a single graph run (one user turn).
|
||||
* Caps the self-continuation loop so one message can't drive too many
|
||||
* autonomous steps or approach the graph recursion limit. The goal's
|
||||
* overall {@code turn_budget} still bounds total turns across messages;
|
||||
* this is the tighter per-message safety net.
|
||||
*/
|
||||
private int maxFollowupsPerRun = 8;
|
||||
|
||||
/**
|
||||
* Provider/model id for the evaluator. Empty string means "use the
|
||||
* same model as the chat agent" — convenient for dev, expensive in
|
||||
|
||||
@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService;
|
||||
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService.OAuthStatus;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
|
||||
|
||||
/**
|
||||
* RFC-062 PR-3: management-UI surface for the Claude Code OAuth provider.
|
||||
@ -46,7 +46,7 @@ public class ClaudeCodeOAuthController {
|
||||
|
||||
@Operation(summary = "Read current Claude Code OAuth credential status from local disk")
|
||||
@GetMapping("/status")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<OAuthStatus> status() {
|
||||
return R.ok(oauthService.getStatus());
|
||||
}
|
||||
@ -62,7 +62,7 @@ public class ClaudeCodeOAuthController {
|
||||
*/
|
||||
@Operation(summary = "Force re-detect credentials and refresh if near expiry")
|
||||
@PostMapping("/reload")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<OAuthStatus> reload() {
|
||||
try {
|
||||
oauthService.getValidToken();
|
||||
|
||||
@ -21,6 +21,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
|
||||
@Slf4j
|
||||
@ -40,28 +41,28 @@ public class ModelConfigController {
|
||||
|
||||
@Operation(summary = "获取 Provider 列表(仅 enabled)")
|
||||
@GetMapping
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<List<ProviderInfoDTO>> list() {
|
||||
return R.ok(modelProviderService.listProviders());
|
||||
}
|
||||
|
||||
@Operation(summary = "RFC-074: 获取 Provider 全量目录(含未启用),供 Add Provider 抽屉使用")
|
||||
@GetMapping("/catalog")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<List<ProviderInfoDTO>> catalog() {
|
||||
return R.ok(modelProviderService.listCatalog());
|
||||
}
|
||||
|
||||
@Operation(summary = "RFC-074: 启用 Provider")
|
||||
@PostMapping("/{providerId}/enable")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<EnableResult> enableProvider(@PathVariable String providerId) {
|
||||
return R.ok(modelProviderService.setEnabled(providerId, true));
|
||||
}
|
||||
|
||||
@Operation(summary = "RFC-074: 禁用 Provider(如其下模型为当前默认会自动切换)")
|
||||
@PostMapping("/{providerId}/disable")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<EnableResult> disableProvider(@PathVariable String providerId) {
|
||||
return R.ok(modelProviderService.setEnabled(providerId, false));
|
||||
}
|
||||
@ -97,7 +98,7 @@ public class ModelConfigController {
|
||||
|
||||
@Operation(summary = "设置当前激活模型")
|
||||
@PutMapping("/active")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<ActiveModelsInfo> setActiveModel(@RequestBody ModelSlotRequest request) {
|
||||
ModelConfigEntity model = modelConfigService.setDefaultModel(request.getProviderId(), request.getModel());
|
||||
ActiveModelsInfo info = new ActiveModelsInfo();
|
||||
@ -107,7 +108,7 @@ public class ModelConfigController {
|
||||
|
||||
@Operation(summary = "更新 Provider 配置")
|
||||
@PutMapping("/{providerId}/config")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<ProviderInfoDTO> updateProviderConfig(@PathVariable String providerId,
|
||||
@RequestBody ProviderConfigRequest request) {
|
||||
ProviderInfoDTO updated = modelProviderService.updateProviderConfig(providerId, request);
|
||||
@ -118,14 +119,14 @@ public class ModelConfigController {
|
||||
|
||||
@Operation(summary = "创建自定义 Provider")
|
||||
@PostMapping("/custom-providers")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<ProviderInfoDTO> createCustomProvider(@RequestBody CreateCustomProviderRequest request) {
|
||||
return R.ok(modelProviderService.createCustomProvider(request));
|
||||
}
|
||||
|
||||
@Operation(summary = "删除自定义 Provider")
|
||||
@DeleteMapping("/custom-providers/{providerId}")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<Void> deleteCustomProvider(@PathVariable String providerId) {
|
||||
modelProviderService.deleteCustomProvider(providerId);
|
||||
return R.ok();
|
||||
@ -141,7 +142,7 @@ public class ModelConfigController {
|
||||
*/
|
||||
@Operation(summary = "删除自定义 Provider(查询参数变体,兼容含特殊字符的旧 ID)")
|
||||
@DeleteMapping("/custom-providers")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<Void> deleteCustomProviderByQuery(@RequestParam("providerId") String providerId) {
|
||||
modelProviderService.deleteCustomProvider(providerId);
|
||||
return R.ok();
|
||||
@ -149,7 +150,7 @@ public class ModelConfigController {
|
||||
|
||||
@Operation(summary = "向 Provider 添加模型")
|
||||
@PostMapping("/{providerId}/models")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<ProviderInfoDTO> addProviderModel(@PathVariable String providerId,
|
||||
@RequestBody AddProviderModelRequest request) {
|
||||
return R.ok(modelProviderService.addModel(providerId, request));
|
||||
@ -157,7 +158,7 @@ public class ModelConfigController {
|
||||
|
||||
@Operation(summary = "从 Provider 删除模型")
|
||||
@DeleteMapping("/{providerId}/models")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<ProviderInfoDTO> removeProviderModel(@PathVariable String providerId,
|
||||
@RequestParam String modelId) {
|
||||
return R.ok(modelProviderService.removeModel(providerId, modelId));
|
||||
@ -165,21 +166,21 @@ public class ModelConfigController {
|
||||
|
||||
@Operation(summary = "获取模型详情")
|
||||
@GetMapping("/{id}")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<ModelConfigEntity> get(@PathVariable Long id) {
|
||||
return R.ok(modelConfigService.getModel(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "创建模型")
|
||||
@PostMapping
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<ModelConfigEntity> create(@RequestBody ModelConfigEntity entity) {
|
||||
return R.ok(modelConfigService.createModel(entity));
|
||||
}
|
||||
|
||||
@Operation(summary = "更新模型")
|
||||
@PutMapping("/{id}")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<ModelConfigEntity> update(@PathVariable Long id, @RequestBody ModelConfigEntity entity) {
|
||||
entity.setId(id);
|
||||
return R.ok(modelConfigService.updateModel(entity));
|
||||
@ -187,7 +188,7 @@ public class ModelConfigController {
|
||||
|
||||
@Operation(summary = "删除模型")
|
||||
@DeleteMapping("/{id}")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<Void> delete(@PathVariable Long id) {
|
||||
modelConfigService.deleteModel(id);
|
||||
return R.ok();
|
||||
@ -195,7 +196,7 @@ public class ModelConfigController {
|
||||
|
||||
@Operation(summary = "设置默认模型")
|
||||
@PostMapping("/{id}/default")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<ModelConfigEntity> setDefault(@PathVariable Long id) {
|
||||
return R.ok(modelConfigService.setDefaultModel(id));
|
||||
}
|
||||
@ -204,14 +205,14 @@ public class ModelConfigController {
|
||||
|
||||
@Operation(summary = "发现远端模型")
|
||||
@PostMapping("/{providerId}/discover")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<DiscoverResult> discoverModels(@PathVariable String providerId) {
|
||||
return R.ok(modelDiscoveryService.discoverModels(providerId));
|
||||
}
|
||||
|
||||
@Operation(summary = "批量添加发现的模型")
|
||||
@PostMapping("/{providerId}/discover/apply")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Integer>> applyDiscoveredModels(@PathVariable String providerId,
|
||||
@RequestBody ApplyDiscoveredModelsRequest request) {
|
||||
int added = modelDiscoveryService.batchAddModels(providerId, request.getModelIds());
|
||||
@ -220,14 +221,14 @@ public class ModelConfigController {
|
||||
|
||||
@Operation(summary = "测试供应商连接")
|
||||
@PostMapping("/{providerId}/test-connection")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<TestResult> testConnection(@PathVariable String providerId) {
|
||||
return R.ok(modelDiscoveryService.testConnection(providerId));
|
||||
}
|
||||
|
||||
@Operation(summary = "测试单个模型可用性")
|
||||
@PostMapping("/{providerId}/models/test")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<TestResult> testModel(@PathVariable String providerId,
|
||||
@RequestParam String modelId) {
|
||||
return R.ok(modelDiscoveryService.testModel(providerId, modelId));
|
||||
@ -246,7 +247,7 @@ public class ModelConfigController {
|
||||
|
||||
@Operation(summary = "测试 Embedding 模型连通性(嵌入一个短文本验证 API key)")
|
||||
@PostMapping("/embedding/{modelId}/test")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> testEmbedding(@PathVariable Long modelId) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
try {
|
||||
@ -279,7 +280,7 @@ public class ModelConfigController {
|
||||
|
||||
@Operation(summary = "获取系统默认 Embedding 模型 ID")
|
||||
@GetMapping("/embedding/default")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> getDefaultEmbedding() {
|
||||
SystemSettingEntity entity = systemSettingMapper.selectOne(
|
||||
new LambdaQueryWrapper<SystemSettingEntity>()
|
||||
@ -293,7 +294,7 @@ public class ModelConfigController {
|
||||
|
||||
@Operation(summary = "设置系统默认 Embedding 模型")
|
||||
@PostMapping("/embedding/default")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<Void> setDefaultEmbedding(@RequestBody Map<String, Object> body) {
|
||||
Object v = body.get("modelId");
|
||||
String value = v == null ? "" : v.toString();
|
||||
|
||||
@ -12,7 +12,7 @@ import vip.mate.llm.oauth.OpenAIDeviceCodeService.DeviceCodeStartResult;
|
||||
import vip.mate.llm.oauth.OpenAIOAuthService;
|
||||
import vip.mate.llm.oauth.OpenAIOAuthService.OAuthAuthorizeResult;
|
||||
import vip.mate.llm.oauth.OpenAIOAuthService.OAuthStatusResult;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
|
||||
|
||||
@Tag(name = "OpenAI OAuth")
|
||||
@RestController
|
||||
@ -25,7 +25,7 @@ public class OAuthController {
|
||||
|
||||
@Operation(summary = "获取 OAuth 授权 URL(自动选 LOCAL / MANUAL_PASTE 模式)")
|
||||
@GetMapping("/authorize")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<OAuthAuthorizeResult> authorize(HttpServletRequest request) {
|
||||
// 用 Host header 判断是否远程部署 — 远程则不启 localhost server,进 MANUAL_PASTE 流。
|
||||
// 优先 X-Forwarded-Host(反向代理后的实际入口),fallback 到 Host。
|
||||
@ -44,7 +44,7 @@ public class OAuthController {
|
||||
*/
|
||||
@Operation(summary = "MANUAL_PASTE 模式:用户粘贴浏览器回调 URL 完成 OAuth")
|
||||
@PostMapping("/callback-paste")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<Void> callbackPaste(@RequestBody PasteRequest request) {
|
||||
oauthService.completeFromPastedUrl(request.callbackUrl());
|
||||
return R.ok();
|
||||
@ -55,21 +55,21 @@ public class OAuthController {
|
||||
|
||||
@Operation(summary = "Device flow: start — request user_code")
|
||||
@PostMapping("/device/start")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<DeviceCodeStartResult> deviceStart() {
|
||||
return R.ok(deviceCodeService.start());
|
||||
}
|
||||
|
||||
@Operation(summary = "Device flow: poll for completion")
|
||||
@PostMapping("/device/poll")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<DeviceCodePollResult> devicePoll(@RequestBody DeviceRequest request) {
|
||||
return R.ok(deviceCodeService.poll(request.deviceAuthId()));
|
||||
}
|
||||
|
||||
@Operation(summary = "Device flow: cancel a pending session")
|
||||
@PostMapping("/device/cancel")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<Void> deviceCancel(@RequestBody DeviceRequest request) {
|
||||
deviceCodeService.cancel(request.deviceAuthId());
|
||||
return R.ok();
|
||||
@ -80,7 +80,7 @@ public class OAuthController {
|
||||
|
||||
@Operation(summary = "手动刷新 Token")
|
||||
@PostMapping("/refresh")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<Void> refresh() {
|
||||
oauthService.refreshToken();
|
||||
return R.ok();
|
||||
@ -88,7 +88,7 @@ public class OAuthController {
|
||||
|
||||
@Operation(summary = "清除 OAuth 凭证")
|
||||
@DeleteMapping("/revoke")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<Void> revoke() {
|
||||
oauthService.revokeToken();
|
||||
return R.ok();
|
||||
@ -96,7 +96,7 @@ public class OAuthController {
|
||||
|
||||
@Operation(summary = "获取 OAuth 连接状态")
|
||||
@GetMapping("/status")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<OAuthStatusResult> status() {
|
||||
return R.ok(oauthService.getStatus());
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ import vip.mate.llm.service.ModelProviderService;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
|
||||
|
||||
/**
|
||||
* RFC-009 Phase 4 — read-only diagnostic endpoint for the provider pool.
|
||||
@ -50,7 +50,7 @@ public class ProviderPoolController {
|
||||
|
||||
@Operation(summary = "查询所有 provider 的池状态 + 冷却信息")
|
||||
@GetMapping
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<List<ProviderPoolEntryDTO>> snapshot() {
|
||||
Map<String, RemovalReason> poolView = providerPool.snapshot();
|
||||
Map<String, ProviderHealthSnapshot> healthView = healthTracker.snapshot();
|
||||
@ -83,7 +83,7 @@ public class ProviderPoolController {
|
||||
|
||||
@Operation(summary = "手动重新探测某个 provider,立即更新池状态")
|
||||
@PostMapping("/{providerId}/reprobe")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireGlobalAdmin
|
||||
public R<ReprobeResultDTO> reprobe(@PathVariable String providerId) {
|
||||
ProbeResult result = initProbe.probeOne(providerId);
|
||||
return R.ok(new ReprobeResultDTO(
|
||||
|
||||
@ -302,7 +302,7 @@ public class DelegateAgentTool {
|
||||
ChildResult result;
|
||||
try {
|
||||
result = runSingleChild(0, target, taskWithContext, parentConversationId, childConversationId,
|
||||
parentOrigin, rootConversationId, subagentId);
|
||||
parentOrigin, rootConversationId, subagentId, childDepth);
|
||||
} finally {
|
||||
// Cleanup relay + registry regardless of how the child returned
|
||||
// (success / exception / interruption) so we never leak entries.
|
||||
@ -440,7 +440,7 @@ public class DelegateAgentTool {
|
||||
for (PreparedChild p : prepared) {
|
||||
CompletableFuture<ChildResult> future = CompletableFuture.supplyAsync(
|
||||
() -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId,
|
||||
parentOriginParallel, rootConvFinal, p.subagentId),
|
||||
parentOriginParallel, rootConvFinal, p.subagentId, childDepth),
|
||||
DELEGATION_EXECUTOR);
|
||||
|
||||
// Broadcast per-child completion as soon as each child finishes
|
||||
@ -723,7 +723,7 @@ public class DelegateAgentTool {
|
||||
try {
|
||||
ChildResult childResult = runSingleChild(0, target, task,
|
||||
parentConversationId, childConversationId, parentOrigin,
|
||||
rootConvAsync, subagentId);
|
||||
rootConvAsync, subagentId, childDepth);
|
||||
return childResult.toToolResponse(target.getName());
|
||||
} finally {
|
||||
subagentRegistry.get(subagentId).ifPresent(rec -> {
|
||||
@ -933,7 +933,7 @@ public class DelegateAgentTool {
|
||||
private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task,
|
||||
String parentConversationId, String childConversationId,
|
||||
ChatOrigin parentOrigin,
|
||||
String rootConversationId, String subagentId) {
|
||||
String rootConversationId, String subagentId, int childDepth) {
|
||||
boolean relayChildEvents = parentConversationId != null && streamTracker.isRunning(parentConversationId);
|
||||
if (relayChildEvents) {
|
||||
streamTracker.register(childConversationId);
|
||||
@ -941,8 +941,10 @@ public class DelegateAgentTool {
|
||||
}
|
||||
// Carry root conversation + this child's subagentId into the context so
|
||||
// a grandchild broadcasts to the root stream and tags this as its parent.
|
||||
// Pass the real tree depth so the gate survives the executor-thread hop:
|
||||
// async/parallel children run with an empty ThreadLocal stack.
|
||||
DelegationContext.enter(parentConversationId, deniedToolsForChild(),
|
||||
rootConversationId, subagentId);
|
||||
rootConversationId, subagentId, childDepth);
|
||||
try {
|
||||
long startTime = System.currentTimeMillis();
|
||||
// RFC-063r §2.5 改动点 5: inherit parent origin, swap agentId
|
||||
|
||||
@ -27,15 +27,29 @@ public final class DelegationContext {
|
||||
* the spawn tree.
|
||||
*/
|
||||
private record Frame(String parentConversationId, Set<String> childDeniedTools,
|
||||
String rootConversationId, String currentSubagentId) {}
|
||||
String rootConversationId, String currentSubagentId, int depth) {}
|
||||
|
||||
private static final ThreadLocal<Deque<Frame>> STACK = ThreadLocal.withInitial(ArrayDeque::new);
|
||||
|
||||
private DelegationContext() {}
|
||||
|
||||
/** Current delegation depth (0 = top-level call, not inside any delegation) */
|
||||
/**
|
||||
* Current delegation depth (0 = top-level call, not inside any delegation).
|
||||
* <p>Read from the TOP frame's recorded depth, NOT the thread-local stack
|
||||
* size: async / parallel children run on fresh executor threads where the
|
||||
* stack starts empty, so a size-based depth would reset to 1 at every async
|
||||
* hop and let a child bypass {@code MAX_DELEGATION_DEPTH}. The real tree
|
||||
* depth is carried in via {@link #enter(String, Set, String, String, int)}.
|
||||
*/
|
||||
public static int currentDepth() {
|
||||
return STACK.get().size();
|
||||
Frame top = STACK.get().peek();
|
||||
return top != null ? top.depth : 0;
|
||||
}
|
||||
|
||||
/** Depth for the next layer when the caller doesn't pass one explicitly. */
|
||||
private static int nextDepth() {
|
||||
Frame top = STACK.get().peek();
|
||||
return (top != null ? top.depth : 0) + 1;
|
||||
}
|
||||
|
||||
/** Parent conversation ID for event relay (from the current frame) */
|
||||
@ -64,22 +78,35 @@ public final class DelegationContext {
|
||||
|
||||
/** Enter the next delegation layer (with parent conversation ID and child tool restrictions) */
|
||||
public static void enter(String parentConversationId, Set<String> deniedTools) {
|
||||
enter(parentConversationId, deniedTools, null, null);
|
||||
enter(parentConversationId, deniedTools, null, null, nextDepth());
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter the next delegation layer carrying the full tree identity so deeper
|
||||
* children can broadcast to the root conversation and tag their parent.
|
||||
* Depth is inferred from the current frame; use the explicit-depth overload
|
||||
* from executor threads where the stack starts empty.
|
||||
*/
|
||||
public static void enter(String parentConversationId, Set<String> deniedTools,
|
||||
String rootConversationId, String currentSubagentId) {
|
||||
enter(parentConversationId, deniedTools, rootConversationId, currentSubagentId, nextDepth());
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter the next delegation layer with an EXPLICIT tree depth. Async /
|
||||
* parallel children run on fresh executor threads with an empty stack, so
|
||||
* they must pass the real {@code childDepth} computed on the dispatching
|
||||
* thread — otherwise depth-based recursion limits reset at every hop.
|
||||
*/
|
||||
public static void enter(String parentConversationId, Set<String> deniedTools,
|
||||
String rootConversationId, String currentSubagentId, int depth) {
|
||||
STACK.get().push(new Frame(parentConversationId, deniedTools,
|
||||
rootConversationId, currentSubagentId));
|
||||
rootConversationId, currentSubagentId, depth));
|
||||
}
|
||||
|
||||
/** Enter the next delegation layer (backward-compatible overload) */
|
||||
public static void enter() {
|
||||
enter(null, null, null, null);
|
||||
enter(null, null, null, null, nextDepth());
|
||||
}
|
||||
|
||||
/** Exit the current delegation layer, restoring the previous layer's context */
|
||||
|
||||
@ -9,6 +9,7 @@ import vip.mate.auth.model.UserEntity;
|
||||
import vip.mate.auth.service.AuthService;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
|
||||
import vip.mate.workspace.core.model.WorkspaceAccessVO;
|
||||
import vip.mate.workspace.core.model.WorkspaceEntity;
|
||||
import vip.mate.workspace.core.model.WorkspaceMemberEntity;
|
||||
@ -58,6 +59,7 @@ public class WorkspaceController {
|
||||
|
||||
@Operation(summary = "创建工作区")
|
||||
@PostMapping
|
||||
@RequireGlobalAdmin
|
||||
public R<WorkspaceEntity> create(@RequestBody WorkspaceEntity entity, Authentication auth) {
|
||||
Long userId = resolveUserId(auth);
|
||||
return R.ok(workspaceService.create(entity, userId));
|
||||
@ -85,7 +87,11 @@ public class WorkspaceController {
|
||||
|
||||
@Operation(summary = "获取工作区成员列表")
|
||||
@GetMapping("/{id}/members")
|
||||
public R<List<WorkspaceMemberEntity>> listMembers(@PathVariable Long id) {
|
||||
public R<List<WorkspaceMemberEntity>> listMembers(@PathVariable Long id, Authentication auth) {
|
||||
UserEntity currentUser = resolveUser(auth);
|
||||
if (!isGlobalAdmin(currentUser)) {
|
||||
workspaceService.requirePermission(id, currentUser.getId(), "viewer");
|
||||
}
|
||||
List<WorkspaceMemberEntity> members = workspaceService.listMembers(id);
|
||||
// 填充用户名/昵称
|
||||
for (WorkspaceMemberEntity m : members) {
|
||||
@ -124,12 +130,11 @@ public class WorkspaceController {
|
||||
newUser.setNickname(body.containsKey("nickname")
|
||||
? body.get("nickname").toString() : username);
|
||||
target = authService.createUser(newUser);
|
||||
} else if (password != null && !password.isBlank()) {
|
||||
// User exists AND admin provided a password — reset it.
|
||||
// This fixes the case where an admin removes a member, re-adds
|
||||
// them with a new password, but the stale password blocks login.
|
||||
authService.resetPassword(target.getId(), password);
|
||||
}
|
||||
// Existing users are added as-is. A workspace admin must NOT be able
|
||||
// to reset another account's password (including a global admin's)
|
||||
// through the member-add path — that would be an account-takeover
|
||||
// vector. Password changes go through the dedicated reset flow.
|
||||
targetUserId = target.getId();
|
||||
} else {
|
||||
targetUserId = Long.valueOf(body.get("userId").toString());
|
||||
@ -139,22 +144,26 @@ public class WorkspaceController {
|
||||
}
|
||||
|
||||
@Operation(summary = "更新成员角色")
|
||||
@PutMapping("/{id}/members/{memberId}")
|
||||
@PutMapping("/{id}/members/{targetUserId}")
|
||||
public R<WorkspaceMemberEntity> updateMemberRole(@PathVariable Long id,
|
||||
@PathVariable Long memberId,
|
||||
@PathVariable Long targetUserId,
|
||||
@RequestBody Map<String, String> body,
|
||||
Authentication auth) {
|
||||
Long userId = resolveUserId(auth);
|
||||
workspaceService.requirePermission(id, userId, "admin");
|
||||
return R.ok(workspaceService.updateMemberRole(id, memberId, body.get("role")));
|
||||
// Path variable is the member's USER id (not the membership row id):
|
||||
// WorkspaceService resolves membership by (workspaceId, userId).
|
||||
return R.ok(workspaceService.updateMemberRole(id, targetUserId, body.get("role")));
|
||||
}
|
||||
|
||||
@Operation(summary = "移除工作区成员")
|
||||
@DeleteMapping("/{id}/members/{memberId}")
|
||||
public R<Void> removeMember(@PathVariable Long id, @PathVariable Long memberId, Authentication auth) {
|
||||
@DeleteMapping("/{id}/members/{targetUserId}")
|
||||
public R<Void> removeMember(@PathVariable Long id, @PathVariable Long targetUserId, Authentication auth) {
|
||||
Long userId = resolveUserId(auth);
|
||||
workspaceService.requirePermission(id, userId, "admin");
|
||||
workspaceService.removeMember(id, memberId);
|
||||
// Path variable is the member's USER id (not the membership row id):
|
||||
// WorkspaceService resolves membership by (workspaceId, userId).
|
||||
workspaceService.removeMember(id, targetUserId);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
|
||||
@ -269,12 +269,12 @@ public class WorkspaceService {
|
||||
// 检查是否已是成员
|
||||
WorkspaceMemberEntity existing = getMembership(workspaceId, userId);
|
||||
if (existing != null) {
|
||||
throw new MateClawException("err.workspace.member_exists", "用户已经是该工作区的成员");
|
||||
throw new MateClawException("err.workspace.member_exists", 409, "用户已经是该工作区的成员");
|
||||
}
|
||||
WorkspaceMemberEntity member = new WorkspaceMemberEntity();
|
||||
member.setWorkspaceId(workspaceId);
|
||||
member.setUserId(userId);
|
||||
member.setRole(role != null ? role : "member");
|
||||
member.setRole(normalizeAssignableRole(role));
|
||||
memberMapper.insert(member);
|
||||
evictMembershipCache(workspaceId, userId);
|
||||
log.info("Added member to workspace: userId={}, workspaceId={}, role={}", userId, workspaceId, member.getRole());
|
||||
@ -284,24 +284,35 @@ public class WorkspaceService {
|
||||
public WorkspaceMemberEntity updateMemberRole(Long workspaceId, Long userId, String role) {
|
||||
WorkspaceMemberEntity member = getMembership(workspaceId, userId);
|
||||
if (member == null) {
|
||||
throw new MateClawException("err.workspace.not_member", "用户不是该工作区的成员");
|
||||
throw new MateClawException("err.workspace.not_member", 404, "用户不是该工作区的成员");
|
||||
}
|
||||
if ("owner".equals(member.getRole())) {
|
||||
throw new MateClawException("err.workspace.cannot_modify_owner", "不能修改工作区拥有者的角色");
|
||||
throw new MateClawException("err.workspace.cannot_modify_owner", 400, "不能修改工作区拥有者的角色");
|
||||
}
|
||||
member.setRole(role);
|
||||
member.setRole(normalizeAssignableRole(role));
|
||||
memberMapper.updateById(member);
|
||||
evictMembershipCache(workspaceId, userId);
|
||||
return member;
|
||||
}
|
||||
|
||||
private String normalizeAssignableRole(String role) {
|
||||
String normalized = role == null || role.isBlank() ? "member" : role.trim();
|
||||
return switch (normalized) {
|
||||
case "admin", "member", "viewer" -> normalized;
|
||||
case "owner" -> throw new MateClawException(
|
||||
"err.workspace.invalid_member_role", 400, "不能通过成员管理授予 owner 角色");
|
||||
default -> throw new MateClawException(
|
||||
"err.workspace.invalid_member_role", 400, "无效的成员角色: " + normalized);
|
||||
};
|
||||
}
|
||||
|
||||
public void removeMember(Long workspaceId, Long userId) {
|
||||
WorkspaceMemberEntity member = getMembership(workspaceId, userId);
|
||||
if (member == null) {
|
||||
throw new MateClawException("err.workspace.not_member", "用户不是该工作区的成员");
|
||||
throw new MateClawException("err.workspace.not_member", 404, "用户不是该工作区的成员");
|
||||
}
|
||||
if ("owner".equals(member.getRole())) {
|
||||
throw new MateClawException("err.workspace.cannot_remove_owner", "不能移除工作区拥有者");
|
||||
throw new MateClawException("err.workspace.cannot_remove_owner", 400, "不能移除工作区拥有者");
|
||||
}
|
||||
memberMapper.deleteById(member.getId());
|
||||
evictMembershipCache(workspaceId, userId);
|
||||
|
||||
@ -19,10 +19,13 @@ import static org.mockito.Mockito.when;
|
||||
class GoalEvaluationDispatcherTest {
|
||||
|
||||
private OverAllState stateWith(boolean followup) {
|
||||
return stateWith(followup, false);
|
||||
}
|
||||
|
||||
private OverAllState stateWith(boolean followup, boolean terminal) {
|
||||
OverAllState s = mock(OverAllState.class);
|
||||
// The dispatcher only reads GOAL_FOLLOWUP_INJECTED; everything else
|
||||
// can stay default.
|
||||
lenient().when(s.value("goal_followup_injected", false)).thenReturn(followup);
|
||||
lenient().when(s.value("goal_evaluated_this_run", false)).thenReturn(terminal);
|
||||
return s;
|
||||
}
|
||||
|
||||
@ -49,4 +52,25 @@ class GoalEvaluationDispatcherTest {
|
||||
GoalEvaluationDispatcher d = new GoalEvaluationDispatcher("plan_generation", "__END__");
|
||||
assertEquals("__END__", d.apply(stateWith(false)));
|
||||
}
|
||||
|
||||
// ===== Run-to-completion loop guard =====
|
||||
|
||||
@Test
|
||||
void followupOnNonTerminalPass_reentersLoop() throws Exception {
|
||||
// The self-continuation loop: followup injected, not a terminal pass.
|
||||
GoalEvaluationDispatcher d = new GoalEvaluationDispatcher("reasoning", "__END__");
|
||||
assertEquals("reasoning", d.apply(stateWith(true, false)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void followupFlagLingeringOnTerminalPass_routesToEnd() throws Exception {
|
||||
// GOAL_FOLLOWUP_INJECTED uses REPLACE and is never cleared, so after a
|
||||
// run-to-completion loop it can still be true on the final (completed /
|
||||
// exhausted) pass. goalEvaluatedThisRun == true must win and END the run,
|
||||
// otherwise the graph loops forever.
|
||||
GoalEvaluationDispatcher react = new GoalEvaluationDispatcher("reasoning", "__END__");
|
||||
assertEquals("__END__", react.apply(stateWith(true, true)));
|
||||
GoalEvaluationDispatcher plan = new GoalEvaluationDispatcher("plan_generation", "__END__");
|
||||
assertEquals("__END__", plan.apply(stateWith(true, true)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
package vip.mate.agent.graph.plan.state;
|
||||
|
||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class PlanStateAccessorUsageTest {
|
||||
|
||||
@Test
|
||||
void mergeUsageAlsoIncrementsSharedLlmCallCount() {
|
||||
Map<String, Object> state = new HashMap<>();
|
||||
state.put(MateClawStateKeys.PROMPT_TOKENS, 10);
|
||||
state.put(MateClawStateKeys.COMPLETION_TOKENS, 20);
|
||||
state.put(MateClawStateKeys.LLM_CALL_COUNT, 2);
|
||||
NodeStreamingChatHelper.StreamResult result =
|
||||
new NodeStreamingChatHelper.StreamResult("ok", "", null, List.of(), false, 3, 4);
|
||||
|
||||
Map<String, Object> output = PlanStateAccessor.output()
|
||||
.mergeUsage(new OverAllState(state), result)
|
||||
.build();
|
||||
|
||||
assertEquals(13, output.get(MateClawStateKeys.PROMPT_TOKENS));
|
||||
assertEquals(24, output.get(MateClawStateKeys.COMPLETION_TOKENS));
|
||||
assertEquals(3, output.get(MateClawStateKeys.LLM_CALL_COUNT));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
package vip.mate.channel;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.approval.ApprovalWorkflowService;
|
||||
import vip.mate.approval.PendingApproval;
|
||||
import vip.mate.approval.ResolveOutcome;
|
||||
import vip.mate.channel.model.ChannelEntity;
|
||||
import vip.mate.channel.notification.ApprovalNotificationService;
|
||||
import vip.mate.channel.service.ChannelService;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.memory.event.ConversationCompletionPublisher;
|
||||
import vip.mate.tts.TtsService;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ChannelMessageRouterApprovalDenyTest {
|
||||
|
||||
@Test
|
||||
void denyAlreadyResolvedDoesNotRewriteConversationOrBroadcastDeniedHint() throws Exception {
|
||||
AgentService agentService = mock(AgentService.class);
|
||||
ConversationService conversationService = mock(ConversationService.class);
|
||||
ChannelService channelService = mock(ChannelService.class);
|
||||
ChannelSessionStore channelSessionStore = mock(ChannelSessionStore.class);
|
||||
ApprovalWorkflowService approvalService = mock(ApprovalWorkflowService.class);
|
||||
ApprovalNotificationService approvalNotificationService = mock(ApprovalNotificationService.class);
|
||||
ConversationCompletionPublisher completionPublisher = mock(ConversationCompletionPublisher.class);
|
||||
TtsService ttsService = mock(TtsService.class);
|
||||
ChatStreamTracker streamTracker = mock(ChatStreamTracker.class);
|
||||
ChannelChatOriginFactory chatOriginFactory = mock(ChannelChatOriginFactory.class);
|
||||
ChannelErrorClassifier errorClassifier = mock(ChannelErrorClassifier.class);
|
||||
ChannelMessageRouter router = new ChannelMessageRouter(agentService, conversationService,
|
||||
channelService, channelSessionStore, approvalService, approvalNotificationService,
|
||||
completionPublisher, ttsService, new ObjectMapper(), streamTracker,
|
||||
chatOriginFactory, errorClassifier);
|
||||
|
||||
PendingApproval pending = new PendingApproval("abcdef123", "conv-1", "alice",
|
||||
"dangerous_tool", "{}", "needs approval");
|
||||
when(approvalService.findPendingByConversation("conv-1")).thenReturn(pending);
|
||||
when(approvalService.resolve("abcdef123", "alice", "denied"))
|
||||
.thenReturn(ResolveOutcome.alreadyResolved("abcdef123"));
|
||||
ChannelAdapter adapter = mock(ChannelAdapter.class);
|
||||
when(adapter.getChannelType()).thenReturn("test");
|
||||
ChannelEntity channel = new ChannelEntity();
|
||||
channel.setAgentId(100L);
|
||||
ChannelMessage message = ChannelMessage.builder()
|
||||
.senderId("alice")
|
||||
.replyToken("reply-1")
|
||||
.content("/deny abcdef")
|
||||
.build();
|
||||
|
||||
Method process = ChannelMessageRouter.class.getDeclaredMethod(
|
||||
"processMessage", ChannelMessage.class, ChannelAdapter.class, ChannelEntity.class, String.class);
|
||||
process.setAccessible(true);
|
||||
process.invoke(router, message, adapter, channel, "conv-1");
|
||||
|
||||
verify(conversationService, never()).removeApprovalPlaceholders(anyString());
|
||||
verify(conversationService, never()).saveMessage(anyString(), anyString(), anyString(), any(), anyString());
|
||||
verify(adapter).sendMessage("reply-1", "⚠️ 审批记录已过期或已被处理。");
|
||||
verify(adapter, never()).sendMessage(eq("reply-1"), startsWith("⛔ 已拒绝执行工具"));
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,9 @@ import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@ -149,4 +152,38 @@ class DelegationContextTest {
|
||||
assertEquals(1, DelegationContext.currentDepth());
|
||||
assertEquals("main-thread-conv", DelegationContext.parentConversationId());
|
||||
}
|
||||
|
||||
// ===== Explicit depth (async/parallel recursion-cap bypass guard) =====
|
||||
|
||||
@Test
|
||||
@DisplayName("Explicit-depth enter reports the depth verbatim, not stack size")
|
||||
void explicitDepthReportedVerbatim() {
|
||||
DelegationContext.enter("conv", Set.of(), "root", "sub", 3);
|
||||
assertEquals(3, DelegationContext.currentDepth());
|
||||
DelegationContext.exit();
|
||||
assertEquals(0, DelegationContext.currentDepth());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Explicit childDepth survives the executor-thread hop (async/parallel bypass guard)")
|
||||
void explicitDepthSurvivesExecutorThreadHop() throws Exception {
|
||||
// Async/parallel children run on a fresh executor thread with an EMPTY
|
||||
// stack. Before the fix, currentDepth() used stack size and reset to 1
|
||||
// here, letting a child exceed MAX_DELEGATION_DEPTH at every hop.
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
Future<Integer> f = executor.submit(() -> {
|
||||
assertEquals(0, DelegationContext.currentDepth()); // fresh thread, empty stack
|
||||
// Enter with the real tree depth computed on the dispatching thread.
|
||||
DelegationContext.enter("childConv", Set.of(), "root", "sub", 3);
|
||||
int observed = DelegationContext.currentDepth();
|
||||
DelegationContext.exit();
|
||||
return observed;
|
||||
});
|
||||
assertEquals(3, f.get(),
|
||||
"child on a fresh thread must observe the explicit childDepth, not the stack size");
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
package vip.mate.workspace.core.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.authentication.TestingAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import vip.mate.auth.model.UserEntity;
|
||||
import vip.mate.auth.service.AuthService;
|
||||
import vip.mate.workspace.core.service.WorkspaceService;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class WorkspaceControllerMembersAuthTest {
|
||||
|
||||
@Test
|
||||
void listMembersRequiresViewerPermissionForNonGlobalAdmin() throws Exception {
|
||||
WorkspaceService workspaceService = mock(WorkspaceService.class);
|
||||
AuthService authService = mock(AuthService.class);
|
||||
WorkspaceController controller = new WorkspaceController(workspaceService, authService);
|
||||
UserEntity user = new UserEntity();
|
||||
user.setId(42L);
|
||||
user.setUsername("alice");
|
||||
user.setRole("user");
|
||||
when(authService.findByUsername("alice")).thenReturn(user);
|
||||
when(workspaceService.listMembers(7L)).thenReturn(List.of());
|
||||
|
||||
invokeListMembers(controller, 7L, new TestingAuthenticationToken("alice", "pw"));
|
||||
|
||||
verify(workspaceService).requirePermission(7L, 42L, "viewer");
|
||||
verify(workspaceService).listMembers(7L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMembersLetsGlobalAdminBypassWorkspaceMembership() throws Exception {
|
||||
WorkspaceService workspaceService = mock(WorkspaceService.class);
|
||||
AuthService authService = mock(AuthService.class);
|
||||
WorkspaceController controller = new WorkspaceController(workspaceService, authService);
|
||||
UserEntity user = new UserEntity();
|
||||
user.setId(1L);
|
||||
user.setUsername("admin");
|
||||
user.setRole("admin");
|
||||
when(authService.findByUsername("admin")).thenReturn(user);
|
||||
when(workspaceService.listMembers(7L)).thenReturn(List.of());
|
||||
|
||||
invokeListMembers(controller, 7L, new TestingAuthenticationToken("admin", "pw"));
|
||||
|
||||
verify(workspaceService, never()).requirePermission(anyLong(), anyLong(), anyString());
|
||||
verify(workspaceService).listMembers(7L);
|
||||
}
|
||||
|
||||
private void invokeListMembers(WorkspaceController controller, Long workspaceId,
|
||||
Authentication auth) throws Exception {
|
||||
Method method = WorkspaceController.class.getMethod("listMembers", Long.class, Authentication.class);
|
||||
assertNotNull(method);
|
||||
method.invoke(controller, workspaceId, auth);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
package vip.mate.workspace.core.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.workspace.conversation.repository.ConversationMapper;
|
||||
import vip.mate.workspace.core.model.WorkspaceEntity;
|
||||
import vip.mate.workspace.core.model.WorkspaceMemberEntity;
|
||||
import vip.mate.workspace.core.repository.WorkspaceMapper;
|
||||
import vip.mate.workspace.core.repository.WorkspaceMemberMapper;
|
||||
import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class WorkspaceServiceRoleValidationTest {
|
||||
|
||||
private final WorkspaceMapper workspaceMapper = mock(WorkspaceMapper.class);
|
||||
private final WorkspaceMemberMapper memberMapper = mock(WorkspaceMemberMapper.class);
|
||||
private final ConversationMapper conversationMapper = mock(ConversationMapper.class);
|
||||
private final WikiKnowledgeBaseService wikiKnowledgeBaseService = mock(WikiKnowledgeBaseService.class);
|
||||
private final WorkspaceService service = new WorkspaceService(
|
||||
workspaceMapper, memberMapper, conversationMapper, wikiKnowledgeBaseService, null);
|
||||
|
||||
@Test
|
||||
void addMemberRejectsOwnerRole() {
|
||||
WorkspaceEntity workspace = new WorkspaceEntity();
|
||||
workspace.setId(1L);
|
||||
when(workspaceMapper.selectById(1L)).thenReturn(workspace);
|
||||
when(memberMapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> service.addMember(1L, 42L, "owner"));
|
||||
|
||||
assertEquals(400, ex.getCode());
|
||||
assertEquals("err.workspace.invalid_member_role", ex.getMsgKey());
|
||||
verify(memberMapper, never()).insert(any(WorkspaceMemberEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateMemberRoleRejectsOwnerEscalation() {
|
||||
WorkspaceMemberEntity member = new WorkspaceMemberEntity();
|
||||
member.setWorkspaceId(1L);
|
||||
member.setUserId(42L);
|
||||
member.setRole("admin");
|
||||
when(memberMapper.selectOne(any())).thenReturn(member);
|
||||
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> service.updateMemberRole(1L, 42L, "owner"));
|
||||
|
||||
assertEquals(400, ex.getCode());
|
||||
assertEquals("err.workspace.invalid_member_role", ex.getMsgKey());
|
||||
verify(memberMapper, never()).updateById(any(WorkspaceMemberEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void addMemberDefaultsMissingRoleToMember() {
|
||||
WorkspaceEntity workspace = new WorkspaceEntity();
|
||||
workspace.setId(1L);
|
||||
when(workspaceMapper.selectById(1L)).thenReturn(workspace);
|
||||
when(memberMapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
WorkspaceMemberEntity member = service.addMember(1L, 42L, null);
|
||||
|
||||
assertEquals("member", member.getRole());
|
||||
verify(memberMapper).insert(any(WorkspaceMemberEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateMemberRoleRejectsUnknownRole() {
|
||||
WorkspaceMemberEntity member = new WorkspaceMemberEntity();
|
||||
member.setWorkspaceId(1L);
|
||||
member.setUserId(42L);
|
||||
member.setRole("member");
|
||||
when(memberMapper.selectOne(any())).thenReturn(member);
|
||||
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> service.updateMemberRole(1L, 42L, "superuser"));
|
||||
|
||||
assertEquals(400, ex.getCode());
|
||||
verify(memberMapper, never()).updateById(any(WorkspaceMemberEntity.class));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user