mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(team): enforce workspace isolation (#593)
This commit is contained in:
parent
54eb77d7c7
commit
763fb554da
@ -5,6 +5,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.common.result.R;
|
||||
@ -21,6 +23,7 @@ import vip.mate.team.service.TeamDispatchService;
|
||||
import vip.mate.team.service.TeamEventChannel;
|
||||
import vip.mate.team.service.TeamService;
|
||||
import vip.mate.team.service.TeamTaskService;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.List;
|
||||
@ -54,35 +57,32 @@ public class TeamController {
|
||||
|
||||
@Operation(summary = "团队列表")
|
||||
@GetMapping
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<List<TeamVO>> list() {
|
||||
return R.ok(teamService.listTeams().stream().map(this::toVO).toList());
|
||||
return R.ok(teamService.listTeams(currentWorkspaceId()).stream().map(this::toVO).toList());
|
||||
}
|
||||
|
||||
@Operation(summary = "团队详情(含成员)")
|
||||
@GetMapping("/{id}")
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<TeamDetailVO> get(@PathVariable Long id) {
|
||||
AgentTeamEntity team = teamService.getTeam(id);
|
||||
AgentTeamEntity team = teamService.getTeam(id, currentWorkspaceId());
|
||||
if (team == null) {
|
||||
return R.fail("team not found");
|
||||
}
|
||||
List<MemberVO> members = teamService.listMembers(id).stream()
|
||||
.map(m -> {
|
||||
AgentEntity agent = agentMapper.selectById(m.getAgentId());
|
||||
return new MemberVO(m.getAgentId(),
|
||||
agent != null && agent.getName() != null ? agent.getName()
|
||||
: String.valueOf(m.getAgentId()),
|
||||
m.getRole(),
|
||||
agent != null ? agent.getIcon() : null);
|
||||
})
|
||||
.map(m -> toMemberVO(team, m))
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.toList();
|
||||
return R.ok(new TeamDetailVO(toVO(team), members));
|
||||
}
|
||||
|
||||
@Operation(summary = "创建团队")
|
||||
@PostMapping
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<TeamVO> create(@RequestBody CreateTeamRequest req, Principal principal) {
|
||||
return guarded(() -> {
|
||||
AgentTeamEntity team = teamService.createTeam(req.getName(), req.getDescription(),
|
||||
AgentTeamEntity team = teamService.createTeam(currentWorkspaceId(), req.getName(), req.getDescription(),
|
||||
req.getLeadAgentId(), req.getMemberAgentIds(),
|
||||
principal != null ? principal.getName() : "admin");
|
||||
return R.ok(toVO(team));
|
||||
@ -91,16 +91,18 @@ public class TeamController {
|
||||
|
||||
@Operation(summary = "更新团队")
|
||||
@PutMapping("/{id}")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<TeamVO> update(@PathVariable Long id, @RequestBody UpdateTeamRequest req) {
|
||||
return guarded(() -> R.ok(toVO(teamService.updateTeam(id, req.getName(),
|
||||
return guarded(() -> R.ok(toVO(teamService.updateTeam(id, currentWorkspaceId(), req.getName(),
|
||||
req.getDescription(), req.getSettings()))));
|
||||
}
|
||||
|
||||
@Operation(summary = "删除团队")
|
||||
@DeleteMapping("/{id}")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Void> delete(@PathVariable Long id) {
|
||||
return guarded(() -> {
|
||||
teamService.deleteTeam(id);
|
||||
teamService.deleteTeam(id, currentWorkspaceId());
|
||||
return R.ok(null);
|
||||
});
|
||||
}
|
||||
@ -109,18 +111,20 @@ public class TeamController {
|
||||
|
||||
@Operation(summary = "添加成员")
|
||||
@PostMapping("/{id}/members")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Void> addMember(@PathVariable Long id, @RequestBody MemberRequest req) {
|
||||
return guarded(() -> {
|
||||
teamService.addMember(id, req.getAgentId(), req.getRole());
|
||||
teamService.addMember(id, currentWorkspaceId(), req.getAgentId(), req.getRole());
|
||||
return R.ok(null);
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "移除成员")
|
||||
@DeleteMapping("/{id}/members/{agentId}")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Void> removeMember(@PathVariable Long id, @PathVariable Long agentId) {
|
||||
return guarded(() -> {
|
||||
teamService.removeMember(id, agentId);
|
||||
teamService.removeMember(id, currentWorkspaceId(), agentId);
|
||||
return R.ok(null);
|
||||
});
|
||||
}
|
||||
@ -129,18 +133,24 @@ public class TeamController {
|
||||
|
||||
@Operation(summary = "任务板列表")
|
||||
@GetMapping("/{id}/tasks")
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<List<TaskVO>> listTasks(@PathVariable Long id,
|
||||
@RequestParam(required = false) List<String> status,
|
||||
@RequestParam(required = false) Integer limit,
|
||||
@RequestParam(required = false) Integer offset) {
|
||||
return R.ok(taskService.listTasks(id, status, limit, offset).stream()
|
||||
.map(this::toTaskVO).toList());
|
||||
return guarded(() -> {
|
||||
requireTeam(id);
|
||||
return R.ok(taskService.listTasks(id, status, limit, offset).stream()
|
||||
.map(this::toTaskVO).toList());
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "任务详情(含评论)")
|
||||
@GetMapping("/{id}/tasks/{taskId}")
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<TaskDetailVO> getTask(@PathVariable Long id, @PathVariable Long taskId) {
|
||||
return guarded(() -> {
|
||||
requireTeam(id);
|
||||
TeamTaskEntity task = requireTask(id, taskId);
|
||||
return R.ok(new TaskDetailVO(toTaskVO(task), taskService.listComments(taskId)));
|
||||
});
|
||||
@ -148,9 +158,11 @@ public class TeamController {
|
||||
|
||||
@Operation(summary = "手动创建任务")
|
||||
@PostMapping("/{id}/tasks")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<TaskVO> createTask(@PathVariable Long id, @RequestBody CreateTaskRequest req,
|
||||
Principal principal) {
|
||||
return guarded(() -> {
|
||||
requireTeam(id);
|
||||
TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder()
|
||||
.teamId(id)
|
||||
.subject(req.getSubject())
|
||||
@ -172,9 +184,11 @@ public class TeamController {
|
||||
|
||||
@Operation(summary = "批准 in_review 任务")
|
||||
@PostMapping("/{id}/tasks/{taskId}/approve")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<TaskVO> approve(@PathVariable Long id, @PathVariable Long taskId,
|
||||
Principal principal) {
|
||||
return guarded(() -> {
|
||||
requireTeam(id);
|
||||
requireTask(id, taskId);
|
||||
List<Long> released = taskService.approveTask(taskId);
|
||||
recordUserEvent(id, taskId, TeamTaskEventEntity.APPROVED, principal, null);
|
||||
@ -188,10 +202,12 @@ public class TeamController {
|
||||
|
||||
@Operation(summary = "驳回 in_review 任务")
|
||||
@PostMapping("/{id}/tasks/{taskId}/reject")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<TaskVO> reject(@PathVariable Long id, @PathVariable Long taskId,
|
||||
@RequestBody(required = false) ReasonRequest req,
|
||||
Principal principal) {
|
||||
return guarded(() -> {
|
||||
requireTeam(id);
|
||||
requireTask(id, taskId);
|
||||
taskService.rejectTask(taskId, req == null ? null : req.getReason());
|
||||
recordUserEvent(id, taskId, TeamTaskEventEntity.REJECTED, principal,
|
||||
@ -207,9 +223,11 @@ public class TeamController {
|
||||
|
||||
@Operation(summary = "重试 failed/stale 任务")
|
||||
@PostMapping("/{id}/tasks/{taskId}/retry")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<TaskVO> retry(@PathVariable Long id, @PathVariable Long taskId,
|
||||
Principal principal) {
|
||||
return guarded(() -> {
|
||||
requireTeam(id);
|
||||
requireTask(id, taskId);
|
||||
if (!taskService.retryTask(taskId)) {
|
||||
return R.fail("only failed or stale tasks can be retried");
|
||||
@ -223,10 +241,12 @@ public class TeamController {
|
||||
|
||||
@Operation(summary = "取消任务")
|
||||
@PostMapping("/{id}/tasks/{taskId}/cancel")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<TaskVO> cancel(@PathVariable Long id, @PathVariable Long taskId,
|
||||
@RequestBody(required = false) ReasonRequest req,
|
||||
Principal principal) {
|
||||
return guarded(() -> {
|
||||
requireTeam(id);
|
||||
TeamTaskEntity task = requireTask(id, taskId);
|
||||
List<Long> released = taskService.cancelTask(taskId, req == null ? null : req.getReason());
|
||||
recordUserEvent(id, taskId, TeamTaskEventEntity.CANCELLED, principal,
|
||||
@ -243,8 +263,10 @@ public class TeamController {
|
||||
|
||||
@Operation(summary = "任务时间线")
|
||||
@GetMapping("/{id}/tasks/{taskId}/events")
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<List<TeamTaskEventEntity>> taskEvents(@PathVariable Long id, @PathVariable Long taskId) {
|
||||
return guarded(() -> {
|
||||
requireTeam(id);
|
||||
requireTask(id, taskId);
|
||||
return R.ok(taskService.listEvents(taskId));
|
||||
});
|
||||
@ -252,8 +274,10 @@ public class TeamController {
|
||||
|
||||
@Operation(summary = "团队事件流(SSE)")
|
||||
@GetMapping("/{id}/events")
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public SseEmitter events(@PathVariable Long id,
|
||||
@RequestHeader(value = "Last-Event-ID", required = false) Long lastEventId) {
|
||||
requireTeam(id);
|
||||
SseEmitter emitter = new SseEmitter(0L);
|
||||
// A fresh subscription is an activity ticker, not a transcript: skip
|
||||
// the ring-buffer replay (stale events would render as breaking news)
|
||||
@ -275,9 +299,11 @@ public class TeamController {
|
||||
|
||||
@Operation(summary = "添加评论")
|
||||
@PostMapping("/{id}/tasks/{taskId}/comments")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Void> comment(@PathVariable Long id, @PathVariable Long taskId,
|
||||
@RequestBody CommentRequest req, Principal principal) {
|
||||
return guarded(() -> {
|
||||
requireTeam(id);
|
||||
requireTask(id, taskId);
|
||||
taskService.addComment(taskId, TeamTaskService.AUTHOR_USER,
|
||||
principal != null ? principal.getName() : "admin",
|
||||
@ -288,8 +314,12 @@ public class TeamController {
|
||||
|
||||
@Operation(summary = "任务状态统计(看板列头)")
|
||||
@GetMapping("/{id}/tasks/stats")
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<Map<String, Long>> taskStats(@PathVariable Long id) {
|
||||
return R.ok(taskService.countByStatus(id));
|
||||
return guarded(() -> {
|
||||
requireTeam(id);
|
||||
return R.ok(taskService.countByStatus(id));
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== helpers / DTOs ====================
|
||||
@ -316,8 +346,32 @@ public class TeamController {
|
||||
return task;
|
||||
}
|
||||
|
||||
private AgentTeamEntity requireTeam(Long teamId) {
|
||||
AgentTeamEntity team = teamService.getTeam(teamId, currentWorkspaceId());
|
||||
if (team == null) {
|
||||
throw new IllegalArgumentException("team not found: " + teamId);
|
||||
}
|
||||
return team;
|
||||
}
|
||||
|
||||
private long currentWorkspaceId() {
|
||||
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
|
||||
String header = attrs.getRequest().getHeader("X-Workspace-Id");
|
||||
if (header != null && !header.isBlank()) {
|
||||
try {
|
||||
return Long.parseLong(header.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
// Keep the same defaulting semantics as WorkspaceAccessInterceptor.
|
||||
}
|
||||
}
|
||||
}
|
||||
return 1L;
|
||||
}
|
||||
|
||||
private TeamVO toVO(AgentTeamEntity team) {
|
||||
long memberCount = teamService.listMembers(team.getId()).size();
|
||||
long memberCount = teamService.listMembers(team.getId()).stream()
|
||||
.filter(member -> memberBelongsToWorkspace(team, member))
|
||||
.count();
|
||||
AgentEntity lead = agentMapper.selectById(team.getLeadAgentId());
|
||||
return new TeamVO(team,
|
||||
lead != null && lead.getName() != null ? lead.getName()
|
||||
@ -326,6 +380,21 @@ public class TeamController {
|
||||
memberCount);
|
||||
}
|
||||
|
||||
private MemberVO toMemberVO(AgentTeamEntity team, AgentTeamMemberEntity member) {
|
||||
AgentEntity agent = agentMapper.selectById(member.getAgentId());
|
||||
if (agent == null || !team.getWorkspaceId().equals(agent.getWorkspaceId())) {
|
||||
return null;
|
||||
}
|
||||
return new MemberVO(member.getAgentId(),
|
||||
agent.getName() != null ? agent.getName() : String.valueOf(member.getAgentId()),
|
||||
member.getRole(), agent.getIcon());
|
||||
}
|
||||
|
||||
private boolean memberBelongsToWorkspace(AgentTeamEntity team, AgentTeamMemberEntity member) {
|
||||
AgentEntity agent = agentMapper.selectById(member.getAgentId());
|
||||
return agent != null && team.getWorkspaceId().equals(agent.getWorkspaceId());
|
||||
}
|
||||
|
||||
private TaskVO toTaskVO(TeamTaskEntity task) {
|
||||
return new TaskVO(task,
|
||||
agentName(task.getAssigneeAgentId()),
|
||||
|
||||
@ -23,6 +23,9 @@ public class AgentTeamEntity {
|
||||
|
||||
private String description;
|
||||
|
||||
/** Owning workspace. Team data is never shared across workspaces. */
|
||||
private Long workspaceId;
|
||||
|
||||
/** Agent that orchestrates this team; exactly one per team. */
|
||||
private Long leadAgentId;
|
||||
|
||||
|
||||
@ -108,7 +108,7 @@ public class TeamDispatchService {
|
||||
@Scheduled(fixedDelay = 30_000, initialDelay = 30_000)
|
||||
public void scheduledSweep() {
|
||||
taskService.recoverStaleTasks();
|
||||
for (AgentTeamEntity team : teamService.listTeams()) {
|
||||
for (AgentTeamEntity team : teamService.listAllTeams()) {
|
||||
if (TeamService.STATUS_ACTIVE.equals(team.getStatus())) {
|
||||
try {
|
||||
sweep(team.getId());
|
||||
|
||||
@ -43,16 +43,16 @@ public class TeamService {
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Transactional
|
||||
public AgentTeamEntity createTeam(String name, String description, Long leadAgentId,
|
||||
public AgentTeamEntity createTeam(Long workspaceId, String name, String description, Long leadAgentId,
|
||||
List<Long> memberAgentIds, String createdBy) {
|
||||
requireAgentExists(leadAgentId, "lead");
|
||||
requireAgentInWorkspace(leadAgentId, workspaceId, "lead");
|
||||
requireNotInAnyTeam(leadAgentId);
|
||||
if (memberAgentIds != null) {
|
||||
for (Long memberId : memberAgentIds) {
|
||||
if (memberId.equals(leadAgentId)) {
|
||||
throw new IllegalArgumentException("lead agent cannot also be listed as a member");
|
||||
}
|
||||
requireAgentExists(memberId, "member");
|
||||
requireAgentInWorkspace(memberId, workspaceId, "member");
|
||||
requireNotInAnyTeam(memberId);
|
||||
}
|
||||
}
|
||||
@ -60,6 +60,7 @@ public class TeamService {
|
||||
AgentTeamEntity team = new AgentTeamEntity();
|
||||
team.setName(name);
|
||||
team.setDescription(description);
|
||||
team.setWorkspaceId(workspaceId);
|
||||
team.setLeadAgentId(leadAgentId);
|
||||
team.setStatus(STATUS_ACTIVE);
|
||||
team.setTaskSeq(0);
|
||||
@ -77,23 +78,23 @@ public class TeamService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void addMember(Long teamId, Long agentId, String role) {
|
||||
AgentTeamEntity team = requireTeam(teamId);
|
||||
public void addMember(Long teamId, Long workspaceId, Long agentId, String role) {
|
||||
AgentTeamEntity team = requireTeam(teamId, workspaceId);
|
||||
if (TeamRole.LEAD.equals(role)) {
|
||||
throw new IllegalArgumentException("a team has exactly one lead; role must be member or reviewer");
|
||||
}
|
||||
if (agentId.equals(team.getLeadAgentId())) {
|
||||
throw new IllegalArgumentException("agent is already the team lead");
|
||||
}
|
||||
requireAgentExists(agentId, "member");
|
||||
requireAgentInWorkspace(agentId, workspaceId, "member");
|
||||
requireNotInAnyTeam(agentId);
|
||||
insertMember(teamId, agentId, role == null ? TeamRole.MEMBER : role);
|
||||
notifyTeamChanged(teamId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void removeMember(Long teamId, Long agentId) {
|
||||
AgentTeamEntity team = requireTeam(teamId);
|
||||
public void removeMember(Long teamId, Long workspaceId, Long agentId) {
|
||||
AgentTeamEntity team = requireTeam(teamId, workspaceId);
|
||||
if (agentId.equals(team.getLeadAgentId())) {
|
||||
throw new IllegalArgumentException("cannot remove the team lead; delete the team instead");
|
||||
}
|
||||
@ -106,8 +107,8 @@ public class TeamService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteTeam(Long teamId) {
|
||||
requireTeam(teamId);
|
||||
public void deleteTeam(Long teamId, Long workspaceId) {
|
||||
requireTeam(teamId, workspaceId);
|
||||
// Capture membership before it is wiped so every agent gets evicted.
|
||||
List<Long> agentIds = listMembers(teamId).stream()
|
||||
.map(AgentTeamMemberEntity::getAgentId).toList();
|
||||
@ -118,8 +119,8 @@ public class TeamService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AgentTeamEntity updateTeam(Long teamId, String name, String description, String settings) {
|
||||
AgentTeamEntity team = requireTeam(teamId);
|
||||
public AgentTeamEntity updateTeam(Long teamId, Long workspaceId, String name, String description, String settings) {
|
||||
AgentTeamEntity team = requireTeam(teamId, workspaceId);
|
||||
if (name != null) {
|
||||
team.setName(name);
|
||||
}
|
||||
@ -134,7 +135,14 @@ public class TeamService {
|
||||
return team;
|
||||
}
|
||||
|
||||
public List<AgentTeamEntity> listTeams() {
|
||||
public List<AgentTeamEntity> listTeams(Long workspaceId) {
|
||||
return teamMapper.selectList(Wrappers.<AgentTeamEntity>lambdaQuery()
|
||||
.eq(AgentTeamEntity::getWorkspaceId, workspaceId)
|
||||
.orderByDesc(AgentTeamEntity::getCreateTime));
|
||||
}
|
||||
|
||||
/** Internal scheduler view across all workspaces. Never expose through an HTTP endpoint. */
|
||||
public List<AgentTeamEntity> listAllTeams() {
|
||||
return teamMapper.selectList(Wrappers.<AgentTeamEntity>lambdaQuery()
|
||||
.orderByDesc(AgentTeamEntity::getCreateTime));
|
||||
}
|
||||
@ -143,6 +151,12 @@ public class TeamService {
|
||||
return teamMapper.selectById(teamId);
|
||||
}
|
||||
|
||||
public AgentTeamEntity getTeam(Long teamId, Long workspaceId) {
|
||||
return teamMapper.selectOne(Wrappers.<AgentTeamEntity>lambdaQuery()
|
||||
.eq(AgentTeamEntity::getId, teamId)
|
||||
.eq(AgentTeamEntity::getWorkspaceId, workspaceId));
|
||||
}
|
||||
|
||||
public List<AgentTeamMemberEntity> listMembers(Long teamId) {
|
||||
return memberMapper.selectList(Wrappers.<AgentTeamMemberEntity>lambdaQuery()
|
||||
.eq(AgentTeamMemberEntity::getTeamId, teamId)
|
||||
@ -154,6 +168,10 @@ public class TeamService {
|
||||
* builder to inject team context and by the task tool to scope board access.
|
||||
*/
|
||||
public Optional<AgentTeamEntity> getTeamForAgent(Long agentId) {
|
||||
AgentEntity agent = agentMapper.selectById(agentId);
|
||||
if (agent == null || agent.getWorkspaceId() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
AgentTeamMemberEntity member = memberMapper.selectOne(Wrappers.<AgentTeamMemberEntity>lambdaQuery()
|
||||
.eq(AgentTeamMemberEntity::getAgentId, agentId)
|
||||
.last("LIMIT 1"));
|
||||
@ -161,13 +179,20 @@ public class TeamService {
|
||||
return Optional.empty();
|
||||
}
|
||||
AgentTeamEntity team = teamMapper.selectById(member.getTeamId());
|
||||
if (team == null || !STATUS_ACTIVE.equals(team.getStatus())) {
|
||||
if (team == null || !STATUS_ACTIVE.equals(team.getStatus())
|
||||
|| !agent.getWorkspaceId().equals(team.getWorkspaceId())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(team);
|
||||
}
|
||||
|
||||
public boolean isMember(Long teamId, Long agentId) {
|
||||
AgentTeamEntity team = teamMapper.selectById(teamId);
|
||||
AgentEntity agent = agentMapper.selectById(agentId);
|
||||
if (team == null || agent == null || team.getWorkspaceId() == null
|
||||
|| !team.getWorkspaceId().equals(agent.getWorkspaceId())) {
|
||||
return false;
|
||||
}
|
||||
return memberMapper.selectCount(Wrappers.<AgentTeamMemberEntity>lambdaQuery()
|
||||
.eq(AgentTeamMemberEntity::getTeamId, teamId)
|
||||
.eq(AgentTeamMemberEntity::getAgentId, agentId)) > 0;
|
||||
@ -209,19 +234,22 @@ public class TeamService {
|
||||
memberMapper.insert(member);
|
||||
}
|
||||
|
||||
private AgentTeamEntity requireTeam(Long teamId) {
|
||||
AgentTeamEntity team = teamMapper.selectById(teamId);
|
||||
private AgentTeamEntity requireTeam(Long teamId, Long workspaceId) {
|
||||
AgentTeamEntity team = getTeam(teamId, workspaceId);
|
||||
if (team == null) {
|
||||
throw new IllegalArgumentException("team not found: " + teamId);
|
||||
}
|
||||
return team;
|
||||
}
|
||||
|
||||
private void requireAgentExists(Long agentId, String roleLabel) {
|
||||
private void requireAgentInWorkspace(Long agentId, Long workspaceId, String roleLabel) {
|
||||
AgentEntity agent = agentMapper.selectById(agentId);
|
||||
if (agent == null) {
|
||||
throw new IllegalArgumentException(roleLabel + " agent not found: " + agentId);
|
||||
}
|
||||
if (!workspaceId.equals(agent.getWorkspaceId())) {
|
||||
throw new IllegalArgumentException(roleLabel + " agent does not belong to the current workspace: " + agentId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
-- Bind every team to one workspace. Existing teams inherit the lead agent's
|
||||
-- workspace; rows with a missing/legacy lead remain in the default workspace.
|
||||
ALTER TABLE mate_agent_team
|
||||
ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
|
||||
|
||||
UPDATE mate_agent_team t
|
||||
SET workspace_id = COALESCE(
|
||||
(SELECT a.workspace_id FROM mate_agent a WHERE a.id = t.lead_agent_id),
|
||||
1
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_team_workspace
|
||||
ON mate_agent_team (workspace_id, create_time);
|
||||
@ -0,0 +1,13 @@
|
||||
-- Bind every team to one workspace. Existing teams inherit the lead agent's
|
||||
-- workspace; rows with a missing/legacy lead remain in the default workspace.
|
||||
ALTER TABLE mate_agent_team
|
||||
ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
|
||||
|
||||
UPDATE mate_agent_team t
|
||||
SET workspace_id = COALESCE(
|
||||
(SELECT a.workspace_id FROM mate_agent a WHERE a.id = t.lead_agent_id),
|
||||
1
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_team_workspace
|
||||
ON mate_agent_team (workspace_id, create_time);
|
||||
@ -0,0 +1,24 @@
|
||||
-- Bind every team to one workspace. Existing teams inherit the lead agent's
|
||||
-- workspace; rows with a missing/legacy lead remain in the default workspace.
|
||||
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'mate_agent_team'
|
||||
AND COLUMN_NAME = 'workspace_id');
|
||||
SET @s := IF(@c = 0,
|
||||
'ALTER TABLE mate_agent_team ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1 AFTER description',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
UPDATE mate_agent_team t
|
||||
JOIN mate_agent a ON a.id = t.lead_agent_id
|
||||
SET t.workspace_id = a.workspace_id
|
||||
WHERE a.workspace_id IS NOT NULL;
|
||||
|
||||
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'mate_agent_team'
|
||||
AND INDEX_NAME = 'idx_agent_team_workspace');
|
||||
SET @s := IF(@c = 0,
|
||||
'CREATE INDEX idx_agent_team_workspace ON mate_agent_team (workspace_id, create_time)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
@ -1,19 +1,26 @@
|
||||
package vip.mate.team.controller;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.auth.model.UserEntity;
|
||||
import vip.mate.auth.service.AuthService;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.config.WorkspaceAccessInterceptor;
|
||||
import vip.mate.team.model.TeamTaskCreateCommand;
|
||||
import vip.mate.team.model.AgentTeamEntity;
|
||||
import vip.mate.team.model.TeamTaskEntity;
|
||||
import vip.mate.team.service.TeamAnnounceService;
|
||||
import vip.mate.team.service.TeamDispatchService;
|
||||
import vip.mate.team.service.TeamEventChannel;
|
||||
import vip.mate.team.service.TeamService;
|
||||
import vip.mate.team.service.TeamTaskService;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
import vip.mate.workspace.core.service.WorkspaceService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -27,6 +34,8 @@ import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* Verifies that service-layer validation verdicts (IllegalArgumentException /
|
||||
@ -45,6 +54,8 @@ class TeamControllerTest {
|
||||
@Mock private TeamAnnounceService announceService;
|
||||
@Mock private TeamEventChannel eventChannel;
|
||||
@Mock private AgentMapper agentMapper;
|
||||
@Mock private WorkspaceService workspaceService;
|
||||
@Mock private AuthService authService;
|
||||
|
||||
private TeamController controller;
|
||||
|
||||
@ -52,6 +63,15 @@ class TeamControllerTest {
|
||||
void setUp() {
|
||||
controller = new TeamController(teamService, taskService, dispatchService,
|
||||
announceService, eventChannel, agentMapper);
|
||||
AgentTeamEntity team = new AgentTeamEntity();
|
||||
team.setId(TEAM_ID);
|
||||
team.setWorkspaceId(1L);
|
||||
org.mockito.Mockito.lenient().when(teamService.getTeam(TEAM_ID, 1L)).thenReturn(team);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearSecurityContext() {
|
||||
org.springframework.security.core.context.SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
private TeamTaskEntity task(Long teamId, String status) {
|
||||
@ -68,7 +88,7 @@ class TeamControllerTest {
|
||||
|
||||
@Test
|
||||
void createTeamSurfacesMembershipConflictAsReadableFailure() {
|
||||
when(teamService.createTeam(any(), any(), any(), any(), any()))
|
||||
when(teamService.createTeam(anyLong(), any(), any(), any(), any(), any()))
|
||||
.thenThrow(new IllegalStateException(
|
||||
"agent 5 already belongs to team 2; an agent can join only one team"));
|
||||
TeamController.CreateTeamRequest req = new TeamController.CreateTeamRequest();
|
||||
@ -83,7 +103,7 @@ class TeamControllerTest {
|
||||
|
||||
@Test
|
||||
void updateTeamSurfacesUnknownTeamAsReadableFailure() {
|
||||
when(teamService.updateTeam(eq(TEAM_ID), any(), any(), any()))
|
||||
when(teamService.updateTeam(eq(TEAM_ID), eq(1L), any(), any(), any()))
|
||||
.thenThrow(new IllegalArgumentException("team not found: " + TEAM_ID));
|
||||
|
||||
R<TeamController.TeamVO> r = controller.update(TEAM_ID, new TeamController.UpdateTeamRequest());
|
||||
@ -95,7 +115,7 @@ class TeamControllerTest {
|
||||
@Test
|
||||
void addMemberSurfacesValidationAsReadableFailure() {
|
||||
doThrow(new IllegalArgumentException("agent is already the team lead"))
|
||||
.when(teamService).addMember(TEAM_ID, 5L, "member");
|
||||
.when(teamService).addMember(TEAM_ID, 1L, 5L, "member");
|
||||
TeamController.MemberRequest req = new TeamController.MemberRequest();
|
||||
req.setAgentId(5L);
|
||||
req.setRole("member");
|
||||
@ -109,7 +129,7 @@ class TeamControllerTest {
|
||||
@Test
|
||||
void removeMemberSurfacesLeadProtectionAsReadableFailure() {
|
||||
doThrow(new IllegalArgumentException("cannot remove the team lead; delete the team instead"))
|
||||
.when(teamService).removeMember(TEAM_ID, 5L);
|
||||
.when(teamService).removeMember(TEAM_ID, 1L, 5L);
|
||||
|
||||
R<Void> r = controller.removeMember(TEAM_ID, 5L);
|
||||
|
||||
@ -240,4 +260,73 @@ class TeamControllerTest {
|
||||
assertEquals(500, r.getCode());
|
||||
assertEquals("task not found on this team's board", r.getMsg());
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossWorkspaceTeamIsRejectedBeforeTaskBoardRead() {
|
||||
when(teamService.getTeam(TEAM_ID, 1L)).thenReturn(null);
|
||||
|
||||
R<List<TeamController.TaskVO>> r = controller.listTasks(TEAM_ID, null, null, null);
|
||||
|
||||
assertEquals(500, r.getCode());
|
||||
assertEquals("team not found: 1", r.getMsg());
|
||||
verify(taskService, never()).listTasks(anyLong(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyEndpointDeclaresWorkspaceRole() {
|
||||
assertRole("list", "viewer");
|
||||
assertRole("get", "viewer", Long.class);
|
||||
assertRole("create", "admin", TeamController.CreateTeamRequest.class, java.security.Principal.class);
|
||||
assertRole("update", "admin", Long.class, TeamController.UpdateTeamRequest.class);
|
||||
assertRole("delete", "admin", Long.class);
|
||||
assertRole("addMember", "admin", Long.class, TeamController.MemberRequest.class);
|
||||
assertRole("removeMember", "admin", Long.class, Long.class);
|
||||
assertRole("listTasks", "viewer", Long.class, List.class, Integer.class, Integer.class);
|
||||
assertRole("getTask", "viewer", Long.class, Long.class);
|
||||
assertRole("createTask", "admin", Long.class, TeamController.CreateTaskRequest.class, java.security.Principal.class);
|
||||
assertRole("approve", "admin", Long.class, Long.class, java.security.Principal.class);
|
||||
assertRole("reject", "admin", Long.class, Long.class, TeamController.ReasonRequest.class, java.security.Principal.class);
|
||||
assertRole("retry", "admin", Long.class, Long.class, java.security.Principal.class);
|
||||
assertRole("cancel", "admin", Long.class, Long.class, TeamController.ReasonRequest.class, java.security.Principal.class);
|
||||
assertRole("taskEvents", "viewer", Long.class, Long.class);
|
||||
assertRole("events", "viewer", Long.class, Long.class);
|
||||
assertRole("comment", "admin", Long.class, Long.class, TeamController.CommentRequest.class, java.security.Principal.class);
|
||||
assertRole("taskStats", "viewer", Long.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void viewerCannotDeleteTeam() throws Exception {
|
||||
UserEntity viewer = new UserEntity();
|
||||
viewer.setId(42L);
|
||||
viewer.setUsername("viewer");
|
||||
viewer.setRole("user");
|
||||
when(authService.findByUsername("viewer")).thenReturn(viewer);
|
||||
when(workspaceService.hasPermissionCached(1L, 42L, "admin")).thenReturn(false);
|
||||
var authentication = new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(
|
||||
"viewer", "", List.of());
|
||||
org.springframework.security.core.context.SecurityContextHolder.getContext()
|
||||
.setAuthentication(authentication);
|
||||
var mockMvc = org.springframework.test.web.servlet.setup.MockMvcBuilders
|
||||
.standaloneSetup(controller)
|
||||
.addInterceptors(new WorkspaceAccessInterceptor(workspaceService, authService, agentMapper))
|
||||
.build();
|
||||
|
||||
mockMvc.perform(delete("/api/v1/teams/{id}", TEAM_ID)
|
||||
.header("X-Workspace-Id", "1"))
|
||||
.andExpect(status().isForbidden());
|
||||
|
||||
verify(teamService, never()).deleteTeam(anyLong(), anyLong());
|
||||
}
|
||||
|
||||
private void assertRole(String method, String role, Class<?>... parameterTypes) {
|
||||
try {
|
||||
RequireWorkspaceRole annotation = TeamController.class
|
||||
.getDeclaredMethod(method, parameterTypes)
|
||||
.getAnnotation(RequireWorkspaceRole.class);
|
||||
assertNotNull(annotation, method + " must require a workspace role");
|
||||
assertEquals(role, annotation.value(), method + " role");
|
||||
} catch (NoSuchMethodException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import vip.mate.team.repository.AgentTeamMapper;
|
||||
import vip.mate.team.repository.AgentTeamMemberMapper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
@ -58,6 +59,7 @@ class TeamServiceTest {
|
||||
a.setId(id);
|
||||
a.setName("agent-" + id);
|
||||
a.setAgentType(agentType);
|
||||
a.setWorkspaceId(1L);
|
||||
return a;
|
||||
}
|
||||
|
||||
@ -69,7 +71,80 @@ class TeamServiceTest {
|
||||
when(memberMapper.selectCount(any())).thenReturn(0L);
|
||||
|
||||
assertDoesNotThrow(() ->
|
||||
service.createTeam("组", null, LEAD_ID, List.of(MEMBER_ID), "admin"));
|
||||
verify(teamMapper).insert(any(AgentTeamEntity.class));
|
||||
service.createTeam(1L, "组", null, LEAD_ID, List.of(MEMBER_ID), "admin"));
|
||||
org.mockito.ArgumentCaptor<AgentTeamEntity> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(AgentTeamEntity.class);
|
||||
verify(teamMapper).insert(captor.capture());
|
||||
assertEquals(1L, captor.getValue().getWorkspaceId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMemberFromAnotherWorkspace() {
|
||||
AgentEntity lead = agent(LEAD_ID, "react");
|
||||
AgentEntity member = agent(MEMBER_ID, "react");
|
||||
member.setWorkspaceId(2L);
|
||||
when(agentMapper.selectById(LEAD_ID)).thenReturn(lead);
|
||||
when(agentMapper.selectById(MEMBER_ID)).thenReturn(member);
|
||||
|
||||
IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
|
||||
() -> service.createTeam(1L, "组", null, LEAD_ID, List.of(MEMBER_ID), "admin"));
|
||||
|
||||
assertEquals("member agent does not belong to the current workspace: 2", error.getMessage());
|
||||
verify(teamMapper, never()).insert(any(AgentTeamEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listTeamsAlwaysScopesByWorkspace() {
|
||||
when(teamMapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
service.listTeams(7L);
|
||||
|
||||
@SuppressWarnings("rawtypes")
|
||||
org.mockito.ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
|
||||
verify(teamMapper).selectList(captor.capture());
|
||||
com.baomidou.mybatisplus.core.conditions.Wrapper<?> wrapper = captor.getValue();
|
||||
assertTrue(wrapper.getSqlSegment().toLowerCase().contains("workspace"), wrapper.getSqlSegment());
|
||||
@SuppressWarnings("unchecked")
|
||||
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<AgentTeamEntity> lambda =
|
||||
(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<AgentTeamEntity>) wrapper;
|
||||
assertTrue(lambda.getParamNameValuePairs().containsValue(7L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void agentCannotResolveTeamOwnedByAnotherWorkspace() {
|
||||
AgentEntity agent = agent(LEAD_ID, "react");
|
||||
AgentTeamMemberEntity membership = new AgentTeamMemberEntity();
|
||||
membership.setTeamId(10L);
|
||||
AgentTeamEntity foreignTeam = new AgentTeamEntity();
|
||||
foreignTeam.setId(10L);
|
||||
foreignTeam.setWorkspaceId(2L);
|
||||
foreignTeam.setStatus(TeamService.STATUS_ACTIVE);
|
||||
when(agentMapper.selectById(LEAD_ID)).thenReturn(agent);
|
||||
when(memberMapper.selectOne(any())).thenReturn(membership);
|
||||
when(teamMapper.selectById(10L)).thenReturn(foreignTeam);
|
||||
|
||||
Optional<AgentTeamEntity> result = service.getTeamForAgent(LEAD_ID);
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void addMemberRejectsAgentFromAnotherWorkspace() {
|
||||
AgentTeamEntity team = new AgentTeamEntity();
|
||||
team.setId(10L);
|
||||
team.setLeadAgentId(LEAD_ID);
|
||||
team.setWorkspaceId(1L);
|
||||
AgentEntity foreignMember = agent(MEMBER_ID, "react");
|
||||
foreignMember.setWorkspaceId(2L);
|
||||
when(teamMapper.selectOne(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class)))
|
||||
.thenReturn(team);
|
||||
when(agentMapper.selectById(MEMBER_ID)).thenReturn(foreignMember);
|
||||
|
||||
IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
|
||||
() -> service.addMember(10L, 1L, MEMBER_ID, "member"));
|
||||
|
||||
assertEquals("member agent does not belong to the current workspace: 2", error.getMessage());
|
||||
verify(memberMapper, never()).insert(any(AgentTeamMemberEntity.class));
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,7 +20,9 @@ export function subscribeTeamEvents(
|
||||
const run = async () => {
|
||||
const headers: Record<string, string> = { Accept: 'text/event-stream' }
|
||||
const token = localStorage.getItem('token')
|
||||
const workspaceId = localStorage.getItem('mc-workspace-id')
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
if (workspaceId) headers['X-Workspace-Id'] = workspaceId
|
||||
|
||||
const res = await fetch(`/api/v1/teams/${teamId}/events`, {
|
||||
headers,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user