Sfoglia il codice sorgente

merge(activity): T35 活动管理团队合入 cfclub(activities.teamId + 活动团队创建/成员管理/服务指派 ACTIVITY_SUPPORT + 前端团队管理页)

Sisyphus Agent 1 settimana fa
parent
commit
61369949ac

+ 73 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3403,6 +3403,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移436: products 表添加营养数据字段(热量/蛋白质/脂肪/碳水/膳食纤维/营养标签)
         migrateProductNutritionFields();
+
+        // 迁移437: T35 活动管理团队 —— activities.team_id + activity_team/activity_team_member/service_assignment 表
+        migrateActivityTeam();
     }
 
     /**
@@ -3538,6 +3541,76 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         }
     }
 
+    /**
+     * 迁移437:T35 活动管理团队。
+     *
+     * <p>activities.team_id 关联 activity_team;activity_team/activity_team_member 承载团队与成员;
+     * service_assignment 为通用服务指派表(service_type=ACTIVITY_SUPPORT),供活动运营服务指派使用,
+     * 与 T34 服务指派机制同一张表,后续服务类型只需新增 service_type 取值。
+     * 全部 DDL 幂等(ensureColumn / CREATE TABLE IF NOT EXISTS)。</p>
+     */
+    private void migrateActivityTeam() {
+        try {
+            ensureColumn("activities", "team_id",
+                    "BIGINT DEFAULT NULL COMMENT '活动管理团队ID(activity_team.id), NULL=无团队; 团队成员可协作运营(迁移437/T35)'");
+            log.info("迁移437: activities.team_id 列已就绪");
+        } catch (Exception e) {
+            log.warn("迁移437: activities.team_id 列检查失败: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_team (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "name VARCHAR(100) NOT NULL COMMENT '团队名称', " +
+                    "description VARCHAR(500) DEFAULT NULL COMMENT '团队简介', " +
+                    "owner_id BIGINT NOT NULL COMMENT '团长userId', " +
+                    "status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active/disabled', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                    "INDEX idx_owner (owner_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动管理团队'");
+            log.info("迁移437: 已创建 activity_team 表");
+        } catch (Exception e) {
+            log.warn("迁移437: 创建 activity_team 表失败(可能已存在): {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_team_member (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "team_id BIGINT NOT NULL COMMENT '团队ID(activity_team.id)', " +
+                    "user_id BIGINT NOT NULL COMMENT '成员userId', " +
+                    "role VARCHAR(32) NOT NULL DEFAULT 'operator' COMMENT 'owner(团长)/manager(运营主管)/operator(运营成员)', " +
+                    "status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active(在团)/removed(已移出)', " +
+                    "joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "UNIQUE KEY uk_team_user (team_id, user_id), " +
+                    "INDEX idx_user (user_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动团队成员'");
+            log.info("迁移437: 已创建 activity_team_member 表");
+        } catch (Exception e) {
+            log.warn("迁移437: 创建 activity_team_member 表失败(可能已存在): {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS service_assignment (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "service_type VARCHAR(32) NOT NULL COMMENT '服务类型: ACTIVITY_SUPPORT(活动运营)', " +
+                    "target_type VARCHAR(32) NOT NULL COMMENT '目标类型: ACTIVITY(具体活动)/ACTIVITY_TEAM(团队整体)', " +
+                    "target_id BIGINT NOT NULL COMMENT '目标ID', " +
+                    "team_id BIGINT DEFAULT NULL COMMENT '关联团队ID(activity_team.id)', " +
+                    "assignee_id BIGINT NOT NULL COMMENT '被指派人userId', " +
+                    "role VARCHAR(32) NOT NULL DEFAULT 'support' COMMENT 'lead/support/checkin/content', " +
+                    "status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active/revoked', " +
+                    "remark VARCHAR(255) DEFAULT NULL COMMENT '备注', " +
+                    "assigned_by BIGINT DEFAULT NULL COMMENT '指派操作人userId', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                    "UNIQUE KEY uk_service_assign (service_type, target_type, target_id, assignee_id, role), " +
+                    "INDEX idx_team (team_id), " +
+                    "INDEX idx_assignee (assignee_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务指派(活动运营ACTIVITY_SUPPORT, T34机制复用)'");
+            log.info("迁移437: 已创建 service_assignment 表");
+        } catch (Exception e) {
+            log.warn("迁移437: 创建 service_assignment 表失败(可能已存在): {}", e.getMessage());
+        }
+    }
+
     private void migrateHealthDimensionsTable() {
         try {
             if (tableExists("health_dimension_score")) {

+ 11 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityController.java

@@ -7,6 +7,7 @@ import com.etotem.cfc.entity.Activity;
 import com.etotem.cfc.service.ActivityAdminService;
 import com.etotem.cfc.service.ActivityRegistrationService;
 import com.etotem.cfc.service.ActivityService;
+import com.etotem.cfc.service.ActivityTeamService;
 import org.springframework.web.bind.annotation.*;
 import javax.annotation.Resource;
 import java.util.List;
@@ -19,6 +20,7 @@ public class ActivityController {
     @Resource private ActivityService activityService;
     @Resource private ActivityRegistrationService registrationService;
     @Resource private ActivityAdminService activityAdminService;
+    @Resource private ActivityTeamService activityTeamService;
 
     private boolean hasActivityManageRole(String role, List<String> roles) {
         if ("admin".equals(role) || "activity_admin".equals(role)) return true;
@@ -49,6 +51,11 @@ public class ActivityController {
         if (!hasActivityManageRole(role, roles) && userId == null) {
             return Result.error("普通用户需登录后创建活动");
         }
+        // T35:团队活动须由团队成员创建(团长/主管/运营成员均可)
+        if (activity.getTeamId() != null && !hasActivityManageRole(role, roles)
+                && !activityTeamService.isActiveMember(activity.getTeamId(), userId)) {
+            return Result.error("只有团队在团成员可以创建团队活动");
+        }
         if (userId != null) activity.setVendorId(userId);
         return activityService.create(activity, userId);
     }
@@ -61,7 +68,10 @@ public class ActivityController {
         if (!hasActivityManageRole(role, roles)) {
             Activity existing = activityService.getById(activity.getId());
             if (existing == null) return Result.error("活动不存在");
-            if (userId == null || existing.getCreatorId() == null || !existing.getCreatorId().equals(userId)) {
+            boolean isCreator = userId != null && existing.getCreatorId() != null && existing.getCreatorId().equals(userId);
+            // T35:团队在团成员 / 服务被指派人可协作编辑团队活动
+            boolean teamCollaborator = !isCreator && activityTeamService.isActivityCollaborator(existing, userId);
+            if (!isCreator && !teamCollaborator) {
                 return Result.error("只有创建人或管理员可以修改活动");
             }
         }

+ 241 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityTeamController.java

@@ -0,0 +1,241 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.AdminRoles;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.entity.ActivityTeam;
+import com.etotem.cfc.entity.ActivityTeamMember;
+import com.etotem.cfc.entity.ServiceAssignment;
+import com.etotem.cfc.service.ActivityTeamService;
+import com.etotem.cfc.util.ParamUtils;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 活动管理团队(T35 规格 §2.2/§5)—— 团队创建/成员管理/服务指派(ACTIVITY_SUPPORT)。
+ *
+ * <p>权限:平台活动角色(admin/activity_admin/activity_manager)可管理全部;
+ * 团队团长/运营主管(owner/manager)可管理本团队;普通在团成员可查看与协作。
+ * 团队数据独立于 supply_system(避免活动运营人员误入商品分销同体系判定)。</p>
+ */
+@RestController
+@RequestMapping("/api/activity/team")
+public class ActivityTeamController {
+
+    @Resource
+    private ActivityTeamService activityTeamService;
+
+    private boolean isAdmin(String role, List<String> roles) {
+        if (role == null) {
+            return false;
+        }
+        if ("admin".equals(role) || "activity_admin".equals(role)) {
+            return true;
+        }
+        return AdminRoles.hasRole(roles, "activity_manager") || AdminRoles.hasAnyPlatformRole(roles);
+    }
+
+    /** 校验当前用户是否可管理该团队;不可管理则抛出 IllegalArgumentException */
+    private void assertManageable(Long teamId, Long userId, String role, List<String> roles) {
+        if (isAdmin(role, roles)) {
+            return;
+        }
+        if (userId == null || !activityTeamService.isTeamManager(teamId, userId)) {
+            throw new IllegalArgumentException("无权管理该团队");
+        }
+    }
+
+    @PostMapping("/create")
+    public Result<ActivityTeam> create(@RequestBody Map<String, Object> params,
+                                       @RequestAttribute(value = "userId", required = false) Long userId,
+                                       @RequestAttribute(value = "role", required = false) String role,
+                                       @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        String name = (String) params.get("name");
+        String description = (String) params.get("description");
+        // 管理员可代建;普通用户以自己的 userId 建团
+        Long ownerId = userId;
+        if (isAdmin(role, roles)) {
+            Long adminOwner = ParamUtils.getLong(params.get("ownerId"));
+            if (adminOwner != null) {
+                ownerId = adminOwner;
+            }
+        }
+        if (ownerId == null) {
+            return Result.error("请登录后再创建团队");
+        }
+        try {
+            return Result.success(activityTeamService.createTeam(name, description, ownerId));
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/list")
+    public Result<List<ActivityTeam>> list(@RequestAttribute(value = "userId", required = false) Long userId,
+                                           @RequestAttribute(value = "role", required = false) String role,
+                                           @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        boolean includeAll = isAdmin(role, roles);
+        return Result.success(activityTeamService.listTeams(userId, includeAll));
+    }
+
+    @PostMapping("/detail")
+    public Result<Map<String, Object>> detail(@RequestBody Map<String, Object> params,
+                                              @RequestAttribute(value = "userId", required = false) Long userId,
+                                              @RequestAttribute(value = "role", required = false) String role,
+                                              @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        Long teamId = ParamUtils.getLong(params.get("teamId"));
+        if (teamId == null) {
+            return Result.error("teamId不能为空");
+        }
+        ActivityTeam team = activityTeamService.getTeam(teamId);
+        if (team == null) {
+            return Result.error("团队不存在");
+        }
+        if (!isAdmin(role, roles) && !activityTeamService.isActiveMember(teamId, userId)) {
+            return Result.error("无权查看该团队");
+        }
+        Map<String, Object> data = new HashMap<>();
+        data.put("team", team);
+        data.put("members", activityTeamService.listMembers(teamId));
+        return Result.success(data);
+    }
+
+    @PostMapping("/member/list")
+    public Result<List<ActivityTeamMember>> memberList(@RequestBody Map<String, Object> params,
+                                                       @RequestAttribute(value = "userId", required = false) Long userId,
+                                                       @RequestAttribute(value = "role", required = false) String role,
+                                                       @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        Long teamId = ParamUtils.getLong(params.get("teamId"));
+        if (teamId == null) {
+            return Result.error("teamId不能为空");
+        }
+        if (!isAdmin(role, roles) && !activityTeamService.isActiveMember(teamId, userId)) {
+            return Result.error("无权查看该团队");
+        }
+        return Result.success(activityTeamService.listMembers(teamId));
+    }
+
+    @PostMapping("/member/add")
+    public Result<ActivityTeamMember> addMember(@RequestBody Map<String, Object> params,
+                                                @RequestAttribute(value = "userId", required = false) Long userId,
+                                                @RequestAttribute(value = "role", required = false) String role,
+                                                @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        Long teamId = ParamUtils.getLong(params.get("teamId"));
+        Long targetUserId = ParamUtils.getLong(params.get("userId"));
+        String memberRole = (String) params.get("role");
+        if (teamId == null || targetUserId == null) {
+            return Result.error("teamId与userId不能为空");
+        }
+        try {
+            assertManageable(teamId, userId, role, roles);
+            return Result.success(activityTeamService.addMember(teamId, targetUserId, memberRole));
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/member/remove")
+    public Result<String> removeMember(@RequestBody Map<String, Object> params,
+                                       @RequestAttribute(value = "userId", required = false) Long userId,
+                                       @RequestAttribute(value = "role", required = false) String role,
+                                       @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        Long teamId = ParamUtils.getLong(params.get("teamId"));
+        Long targetUserId = ParamUtils.getLong(params.get("userId"));
+        if (teamId == null || targetUserId == null) {
+            return Result.error("teamId与userId不能为空");
+        }
+        try {
+            assertManageable(teamId, userId, role, roles);
+            boolean removed = activityTeamService.removeMember(teamId, targetUserId);
+            return removed ? Result.success("已移出成员", null) : Result.error("成员不存在");
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/assign")
+    public Result<ServiceAssignment> assign(@RequestBody Map<String, Object> params,
+                                            @RequestAttribute(value = "userId", required = false) Long userId,
+                                            @RequestAttribute(value = "role", required = false) String role,
+                                            @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        Long teamId = ParamUtils.getLong(params.get("teamId"));
+        String targetType = (String) params.get("targetType");
+        Long targetId = ParamUtils.getLong(params.get("targetId"));
+        Long assigneeId = ParamUtils.getLong(params.get("assigneeId"));
+        String assignRole = (String) params.get("role");
+        String remark = (String) params.get("remark");
+        if (teamId == null || assigneeId == null) {
+            return Result.error("teamId与assigneeId不能为空");
+        }
+        try {
+            assertManageable(teamId, userId, role, roles);
+            return Result.success(activityTeamService.assignService(
+                    teamId, targetType, targetId, assigneeId, assignRole, remark, userId));
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/assign/list")
+    public Result<List<ServiceAssignment>> assignList(@RequestBody Map<String, Object> params,
+                                                      @RequestAttribute(value = "userId", required = false) Long userId,
+                                                      @RequestAttribute(value = "role", required = false) String role,
+                                                      @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        Long teamId = ParamUtils.getLong(params.get("teamId"));
+        String targetType = (String) params.get("targetType");
+        Long targetId = ParamUtils.getLong(params.get("targetId"));
+        if (teamId == null) {
+            return Result.error("teamId不能为空");
+        }
+        if (!isAdmin(role, roles) && !activityTeamService.isActiveMember(teamId, userId)) {
+            return Result.error("无权查看该团队");
+        }
+        return Result.success(activityTeamService.listAssignments(teamId, targetType, targetId));
+    }
+
+    @PostMapping("/assign/revoke")
+    public Result<String> revokeAssignment(@RequestBody Map<String, Object> params,
+                                           @RequestAttribute(value = "userId", required = false) Long userId,
+                                           @RequestAttribute(value = "role", required = false) String role,
+                                           @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        Long id = ParamUtils.getLong(params.get("id"));
+        Long teamId = ParamUtils.getLong(params.get("teamId"));
+        if (id == null) {
+            return Result.error("id不能为空");
+        }
+        try {
+            if (teamId != null) {
+                assertManageable(teamId, userId, role, roles);
+            } else if (!isAdmin(role, roles)) {
+                return Result.error("无权撤销该指派");
+            }
+            boolean revoked = activityTeamService.revokeAssignment(id);
+            return revoked ? Result.success("已撤销指派", null) : Result.error("指派记录不存在");
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/activities")
+    public Result<List<Activity>> teamActivities(@RequestBody Map<String, Object> params,
+                                                 @RequestAttribute(value = "userId", required = false) Long userId,
+                                                 @RequestAttribute(value = "role", required = false) String role,
+                                                 @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        Long teamId = ParamUtils.getLong(params.get("teamId"));
+        if (teamId == null) {
+            return Result.error("teamId不能为空");
+        }
+        if (!isAdmin(role, roles) && !activityTeamService.isActiveMember(teamId, userId)) {
+            return Result.error("无权查看该团队");
+        }
+        return Result.success(activityTeamService.listTeamActivities(teamId));
+    }
+}

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Activity.java

@@ -133,6 +133,9 @@ public class Activity implements Serializable {
     /** 创建时选择的海报模板ID(小程序端海报模板选择,迁移388) */
     private Long posterTemplateId;
 
+    /** 活动管理团队ID(activity_team.id),NULL=无团队;团队成员可协作运营本活动(T35 规格 §2.2/§5) */
+    private Long teamId;
+
     private Date createdAt;
 
     private Date updatedAt;

+ 40 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ActivityTeam.java

@@ -0,0 +1,40 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 活动管理团队(activity_team 表)。
+ *
+ * <p>T35 规格 §2.2/§5:新增「活动管理团队」类型,{@code activities.team_id} 关联本表。
+ * 团队成员({@link ActivityTeamMember})可协作运营团队名下活动,服务指派走
+ * {@link ServiceAssignment}(serviceType=ACTIVITY_SUPPORT)。</p>
+ */
+@Data
+@TableName("activity_team")
+public class ActivityTeam implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 团队名称 */
+    private String name;
+
+    /** 团队简介 */
+    private String description;
+
+    /** 团长 userId(创建人,不可被移出团队) */
+    private Long ownerId;
+
+    /** active(正常)/disabled(已停用) */
+    private String status;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ActivityTeamMember.java

@@ -0,0 +1,35 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 活动团队成员(activity_team_member 表,唯一键 (team_id, user_id))。
+ *
+ * <p>角色:owner(团长)/manager(运营主管)/operator(运营成员)。
+ * 仅 owner/manager 可管理成员与服务指派;operator 只可查看与协作。</p>
+ */
+@Data
+@TableName("activity_team_member")
+public class ActivityTeamMember implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long teamId;
+
+    private Long userId;
+
+    /** owner(团长)/manager(运营主管)/operator(运营成员) */
+    private String role;
+
+    /** active(在团)/removed(已移出) */
+    private String status;
+
+    private Date joinedAt;
+}

+ 55 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ServiceAssignment.java

@@ -0,0 +1,55 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 服务指派记录(service_assignment 表,唯一键 (service_type,target_type,target_id,assignee_id,role))。
+ *
+ * <p>T35 复用服务指派机制(T34 的 ACTIVITY_SUPPORT 类型):
+ * serviceType=ACTIVITY_SUPPORT 时 targetType 为 ACTIVITY(具体活动) 或 ACTIVITY_TEAM(团队整体)。
+ * 被指派人在指派有效期内即成为该活动的协作者,可参与活动运营(编辑/报名审核等)。</p>
+ */
+@Data
+@TableName("service_assignment")
+public class ServiceAssignment implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 服务类型: ACTIVITY_SUPPORT(活动运营服务指派) */
+    private String serviceType;
+
+    /** 目标类型: ACTIVITY(具体活动)/ACTIVITY_TEAM(团队整体) */
+    private String targetType;
+
+    /** 目标ID(targetType=ACTIVITY → activities.id;ACTIVITY_TEAM → activity_team.id) */
+    private Long targetId;
+
+    /** 关联团队ID(可选) */
+    private Long teamId;
+
+    /** 被指派人 userId */
+    private Long assigneeId;
+
+    /** lead(统筹)/support(运营支持)/checkin(签到执行)/content(内容运维) */
+    private String role;
+
+    /** active(生效中)/revoked(已撤销) */
+    private String status;
+
+    /** 备注 */
+    private String remark;
+
+    /** 指派操作人 userId */
+    private Long assignedBy;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ActivityTeamMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ActivityTeam;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ActivityTeamMapper extends BaseMapper<ActivityTeam> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ActivityTeamMemberMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ActivityTeamMember;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ActivityTeamMemberMapper extends BaseMapper<ActivityTeamMember> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ServiceAssignmentMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ServiceAssignment;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ServiceAssignmentMapper extends BaseMapper<ServiceAssignment> {
+}

+ 437 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityTeamService.java

@@ -0,0 +1,437 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.entity.ActivityTeam;
+import com.etotem.cfc.entity.ActivityTeamMember;
+import com.etotem.cfc.entity.ServiceAssignment;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ActivityMapper;
+import com.etotem.cfc.mapper.ActivityTeamMapper;
+import com.etotem.cfc.mapper.ActivityTeamMemberMapper;
+import com.etotem.cfc.mapper.ServiceAssignmentMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * 活动管理团队(T35 规格 §2.2/§5)。
+ *
+ * <p>职责:
+ * <ol>
+ *   <li>活动团队创建与成员管理(activity_team / activity_team_member);</li>
+ *   <li>服务指派(复用服务指派机制,service_type=ACTIVITY_SUPPORT,
+ *       见 {@link ServiceAssignment}),把团队成员指派为某活动(或团队整体)的运营协作者;</li>
+ *   <li>团队成员协作判定:团队 active 成员或活动服务指派人可协作运营该活动。</li>
+ * </ol>
+ *
+ * <p>说明:团队数据独立于 supply_system(供应体系)——供应体系成员会被商品分销同体系判定
+ * 复用(见 ProductService 的同体系判定),活动运营人员若混入其中会误获分销价,故单列。</p>
+ */
+@Service
+public class ActivityTeamService extends ServiceImpl<ActivityTeamMapper, ActivityTeam> {
+
+    /** 服务指派类型:活动运营服务(T34 服务指派机制的 ACTIVITY_SUPPORT) */
+    public static final String SERVICE_TYPE_ACTIVITY_SUPPORT = "ACTIVITY_SUPPORT";
+
+    /** 指派目标类型:具体活动 */
+    public static final String TARGET_TYPE_ACTIVITY = "ACTIVITY";
+
+    /** 指派目标类型:团队整体 */
+    public static final String TARGET_TYPE_ACTIVITY_TEAM = "ACTIVITY_TEAM";
+
+    /** 团队成员角色 */
+    public static final String MEMBER_ROLE_OWNER = "owner";
+    public static final String MEMBER_ROLE_MANAGER = "manager";
+    public static final String MEMBER_ROLE_OPERATOR = "operator";
+
+    /** 服务指派角色 */
+    public static final String ASSIGN_ROLE_LEAD = "lead";
+    public static final String ASSIGN_ROLE_SUPPORT = "support";
+    public static final String ASSIGN_ROLE_CHECKIN = "checkin";
+    public static final String ASSIGN_ROLE_CONTENT = "content";
+
+    private static final Set<String> MEMBER_ROLES = new HashSet<>(Arrays.asList(
+            MEMBER_ROLE_OWNER, MEMBER_ROLE_MANAGER, MEMBER_ROLE_OPERATOR));
+    private static final Set<String> ASSIGN_ROLES = new HashSet<>(Arrays.asList(
+            ASSIGN_ROLE_LEAD, ASSIGN_ROLE_SUPPORT, ASSIGN_ROLE_CHECKIN, ASSIGN_ROLE_CONTENT));
+    private static final Set<String> TARGET_TYPES = new HashSet<>(Arrays.asList(
+            TARGET_TYPE_ACTIVITY, TARGET_TYPE_ACTIVITY_TEAM));
+
+    @Resource
+    private ActivityTeamMemberMapper activityTeamMemberMapper;
+
+    @Resource
+    private ServiceAssignmentMapper serviceAssignmentMapper;
+
+    @Resource
+    private ActivityMapper activityMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    // ==================== 团队 ====================
+
+    /**
+     * 创建活动团队,创建人自动成为团长成员(owner)
+     */
+    @Transactional
+    public ActivityTeam createTeam(String name, String description, Long ownerId) {
+        if (name == null || name.trim().isEmpty()) {
+            throw new IllegalArgumentException("团队名称不能为空");
+        }
+        if (ownerId == null) {
+            throw new IllegalArgumentException("创建人不能为空");
+        }
+        ActivityTeam team = new ActivityTeam();
+        team.setName(name.trim());
+        team.setDescription(description);
+        team.setOwnerId(ownerId);
+        team.setStatus("active");
+        team.setCreatedAt(new Date());
+        team.setUpdatedAt(new Date());
+        this.save(team);
+
+        ActivityTeamMember member = new ActivityTeamMember();
+        member.setTeamId(team.getId());
+        member.setUserId(ownerId);
+        member.setRole(MEMBER_ROLE_OWNER);
+        member.setStatus("active");
+        member.setJoinedAt(new Date());
+        activityTeamMemberMapper.insert(member);
+        return team;
+    }
+
+    /**
+     * 我所在的团队(团长或 active 成员)。includeAll=true 时返回全部团队(管理端视角)。
+     */
+    public List<ActivityTeam> listTeams(Long userId, boolean includeAll) {
+        if (includeAll) {
+            return this.list(new LambdaQueryWrapper<ActivityTeam>()
+                    .orderByDesc(ActivityTeam::getId));
+        }
+        if (userId == null) {
+            return new ArrayList<>();
+        }
+        Set<Long> teamIds = new HashSet<>();
+        List<ActivityTeamMember> memberships = activityTeamMemberMapper.selectList(
+                new LambdaQueryWrapper<ActivityTeamMember>()
+                        .eq(ActivityTeamMember::getUserId, userId)
+                        .eq(ActivityTeamMember::getStatus, "active"));
+        for (ActivityTeamMember m : memberships) {
+            if (m.getTeamId() != null) {
+                teamIds.add(m.getTeamId());
+            }
+        }
+        // 防御:团长可能有成员记录缺失
+        List<ActivityTeam> owned = this.list(new LambdaQueryWrapper<ActivityTeam>()
+                .eq(ActivityTeam::getOwnerId, userId));
+        for (ActivityTeam t : owned) {
+            teamIds.add(t.getId());
+        }
+        if (teamIds.isEmpty()) {
+            return new ArrayList<>();
+        }
+        return this.list(new LambdaQueryWrapper<ActivityTeam>()
+                .in(ActivityTeam::getId, teamIds)
+                .orderByDesc(ActivityTeam::getId));
+    }
+
+    public ActivityTeam getTeam(Long teamId) {
+        return teamId == null ? null : this.getById(teamId);
+    }
+
+    // ==================== 成员 ====================
+
+    public List<ActivityTeamMember> listMembers(Long teamId) {
+        if (teamId == null) {
+            return new ArrayList<>();
+        }
+        return activityTeamMemberMapper.selectList(new LambdaQueryWrapper<ActivityTeamMember>()
+                .eq(ActivityTeamMember::getTeamId, teamId)
+                .eq(ActivityTeamMember::getStatus, "active")
+                .orderByAsc(ActivityTeamMember::getId));
+    }
+
+    public ActivityTeamMember getMembership(Long teamId, Long userId) {
+        if (teamId == null || userId == null) {
+            return null;
+        }
+        return activityTeamMemberMapper.selectOne(new LambdaQueryWrapper<ActivityTeamMember>()
+                .eq(ActivityTeamMember::getTeamId, teamId)
+                .eq(ActivityTeamMember::getUserId, userId)
+                .last("LIMIT 1"));
+    }
+
+    /** 是否为团队在团成员 */
+    public boolean isActiveMember(Long teamId, Long userId) {
+        ActivityTeamMember member = getMembership(teamId, userId);
+        return member != null && "active".equals(member.getStatus());
+    }
+
+    /** 是否可管理团队(团长或运营主管) */
+    public boolean isTeamManager(Long teamId, Long userId) {
+        ActivityTeamMember member = getMembership(teamId, userId);
+        if (member == null || !"active".equals(member.getStatus())) {
+            return false;
+        }
+        return MEMBER_ROLE_OWNER.equals(member.getRole()) || MEMBER_ROLE_MANAGER.equals(member.getRole());
+    }
+
+    /**
+     * 添加团队成员(幂等:已在团则更新角色;已移出则重新激活)
+     */
+    @Transactional
+    public ActivityTeamMember addMember(Long teamId, Long userId, String role) {
+        if (teamId == null) {
+            throw new IllegalArgumentException("团队不能为空");
+        }
+        if (userId == null) {
+            throw new IllegalArgumentException("成员用户不能为空");
+        }
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            throw new IllegalArgumentException("用户不存在");
+        }
+        String memberRole = normalizeMemberRole(role);
+        ActivityTeamMember existing = getMembership(teamId, userId);
+        if (existing != null) {
+            existing.setRole(memberRole);
+            existing.setStatus("active");
+            existing.setJoinedAt(new Date());
+            activityTeamMemberMapper.updateById(existing);
+            return existing;
+        }
+        ActivityTeamMember member = new ActivityTeamMember();
+        member.setTeamId(teamId);
+        member.setUserId(userId);
+        member.setRole(memberRole);
+        member.setStatus("active");
+        member.setJoinedAt(new Date());
+        activityTeamMemberMapper.insert(member);
+        return member;
+    }
+
+    /**
+     * 移出团队成员(团长不可被移出)
+     */
+    @Transactional
+    public boolean removeMember(Long teamId, Long userId) {
+        ActivityTeam team = getTeam(teamId);
+        if (team == null) {
+            throw new IllegalArgumentException("团队不存在");
+        }
+        if (team.getOwnerId() != null && team.getOwnerId().equals(userId)) {
+            throw new IllegalArgumentException("团长不能被移出团队");
+        }
+        ActivityTeamMember member = getMembership(teamId, userId);
+        if (member == null) {
+            return false;
+        }
+        member.setStatus("removed");
+        activityTeamMemberMapper.updateById(member);
+        // 同步撤销该成员在本团队下的活动服务指派
+        List<ServiceAssignment> assignments = serviceAssignmentMapper.selectList(
+                new LambdaQueryWrapper<ServiceAssignment>()
+                        .eq(ServiceAssignment::getTeamId, teamId)
+                        .eq(ServiceAssignment::getAssigneeId, userId)
+                        .eq(ServiceAssignment::getServiceType, SERVICE_TYPE_ACTIVITY_SUPPORT)
+                        .eq(ServiceAssignment::getStatus, "active"));
+        for (ServiceAssignment assignment : assignments) {
+            assignment.setStatus("revoked");
+            assignment.setUpdatedAt(new Date());
+            serviceAssignmentMapper.updateById(assignment);
+        }
+        return true;
+    }
+
+    private String normalizeMemberRole(String role) {
+        if (role == null || role.trim().isEmpty()) {
+            return MEMBER_ROLE_OPERATOR;
+        }
+        String normalized = role.trim().toLowerCase();
+        if (!MEMBER_ROLES.contains(normalized)) {
+            throw new IllegalArgumentException("成员角色不合法: " + role);
+        }
+        return normalized;
+    }
+
+    // ==================== 服务指派(ACTIVITY_SUPPORT) ====================
+
+    /**
+     * 指派服务:把团队成员指派为某活动(或团队整体)的运营协作者。
+     *
+     * @param teamId     团队ID
+     * @param targetType ACTIVITY / ACTIVITY_TEAM
+     * @param targetId   目标ID
+     * @param assigneeId 被指派人 userId(须为团队在团成员)
+     * @param role       指派角色 lead/support/checkin/content
+     */
+    @Transactional
+    public ServiceAssignment assignService(Long teamId, String targetType, Long targetId,
+                                           Long assigneeId, String role, String remark, Long operatorId) {
+        if (teamId == null) {
+            throw new IllegalArgumentException("团队不能为空");
+        }
+        if (targetType == null || !TARGET_TYPES.contains(targetType.trim().toUpperCase())) {
+            throw new IllegalArgumentException("指派目标类型不合法");
+        }
+        String normalizedTargetType = targetType.trim().toUpperCase();
+        if (targetId == null) {
+            throw new IllegalArgumentException("指派目标不能为空");
+        }
+        if (assigneeId == null) {
+            throw new IllegalArgumentException("被指派人不能为空");
+        }
+        String assignRole = normalizeAssignRole(role);
+        if (getTeam(teamId) == null) {
+            throw new IllegalArgumentException("团队不存在");
+        }
+        if (TARGET_TYPE_ACTIVITY.equals(normalizedTargetType)) {
+            if (activityMapper.selectById(targetId) == null) {
+                throw new IllegalArgumentException("活动不存在");
+            }
+        } else if (getTeam(targetId) == null) {
+            throw new IllegalArgumentException("目标团队不存在");
+        }
+        if (!isActiveMember(teamId, assigneeId)) {
+            throw new IllegalArgumentException("被指派人须为团队在团成员");
+        }
+
+        ServiceAssignment existing = serviceAssignmentMapper.selectOne(
+                new LambdaQueryWrapper<ServiceAssignment>()
+                        .eq(ServiceAssignment::getServiceType, SERVICE_TYPE_ACTIVITY_SUPPORT)
+                        .eq(ServiceAssignment::getTargetType, normalizedTargetType)
+                        .eq(ServiceAssignment::getTargetId, targetId)
+                        .eq(ServiceAssignment::getAssigneeId, assigneeId)
+                        .eq(ServiceAssignment::getRole, assignRole)
+                        .last("LIMIT 1"));
+        Date now = new Date();
+        if (existing != null) {
+            existing.setStatus("active");
+            existing.setTeamId(teamId);
+            existing.setRemark(remark);
+            existing.setAssignedBy(operatorId);
+            existing.setUpdatedAt(now);
+            serviceAssignmentMapper.updateById(existing);
+            return existing;
+        }
+        ServiceAssignment assignment = new ServiceAssignment();
+        assignment.setServiceType(SERVICE_TYPE_ACTIVITY_SUPPORT);
+        assignment.setTargetType(normalizedTargetType);
+        assignment.setTargetId(targetId);
+        assignment.setTeamId(teamId);
+        assignment.setAssigneeId(assigneeId);
+        assignment.setRole(assignRole);
+        assignment.setStatus("active");
+        assignment.setRemark(remark);
+        assignment.setAssignedBy(operatorId);
+        assignment.setCreatedAt(now);
+        assignment.setUpdatedAt(now);
+        serviceAssignmentMapper.insert(assignment);
+        return assignment;
+    }
+
+    /**
+     * 查询服务指派记录。targetId 为空时按 teamId 查全部。
+     */
+    public List<ServiceAssignment> listAssignments(Long teamId, String targetType, Long targetId) {
+        LambdaQueryWrapper<ServiceAssignment> wrapper = new LambdaQueryWrapper<ServiceAssignment>()
+                .eq(ServiceAssignment::getServiceType, SERVICE_TYPE_ACTIVITY_SUPPORT)
+                .eq(ServiceAssignment::getStatus, "active");
+        if (teamId != null) {
+            wrapper.eq(ServiceAssignment::getTeamId, teamId);
+        }
+        if (targetType != null && !targetType.trim().isEmpty()) {
+            wrapper.eq(ServiceAssignment::getTargetType, targetType.trim().toUpperCase());
+        }
+        if (targetId != null) {
+            wrapper.eq(ServiceAssignment::getTargetId, targetId);
+        }
+        wrapper.orderByDesc(ServiceAssignment::getId);
+        return serviceAssignmentMapper.selectList(wrapper);
+    }
+
+    /**
+     * 撤销服务指派
+     */
+    @Transactional
+    public boolean revokeAssignment(Long assignmentId) {
+        if (assignmentId == null) {
+            throw new IllegalArgumentException("指派记录不能为空");
+        }
+        ServiceAssignment assignment = serviceAssignmentMapper.selectById(assignmentId);
+        if (assignment == null) {
+            return false;
+        }
+        assignment.setStatus("revoked");
+        assignment.setUpdatedAt(new Date());
+        return serviceAssignmentMapper.updateById(assignment) > 0;
+    }
+
+    private String normalizeAssignRole(String role) {
+        if (role == null || role.trim().isEmpty()) {
+            return ASSIGN_ROLE_SUPPORT;
+        }
+        String normalized = role.trim().toLowerCase();
+        if (!ASSIGN_ROLES.contains(normalized)) {
+            throw new IllegalArgumentException("指派角色不合法: " + role);
+        }
+        return normalized;
+    }
+
+    // ==================== 协作判定 ====================
+
+    /**
+     * 是否为该活动的运营协作者:团队在团成员,或该活动(或其团队)的服务指派人。
+     */
+    public boolean isActivityCollaborator(Activity activity, Long userId) {
+        if (activity == null || userId == null) {
+            return false;
+        }
+        if (activity.getTeamId() != null && isActiveMember(activity.getTeamId(), userId)) {
+            return true;
+        }
+        return isAssigneeOfActivity(activity, userId);
+    }
+
+    /**
+     * 是否被指派参与该活动(服务指派 ACTIVITY_SUPPORT,status=active)
+     */
+    public boolean isAssigneeOfActivity(Activity activity, Long userId) {
+        if (activity == null || userId == null) {
+            return false;
+        }
+        Long count = serviceAssignmentMapper.selectCount(new LambdaQueryWrapper<ServiceAssignment>()
+                .eq(ServiceAssignment::getServiceType, SERVICE_TYPE_ACTIVITY_SUPPORT)
+                .eq(ServiceAssignment::getStatus, "active")
+                .eq(ServiceAssignment::getAssigneeId, userId)
+                .and(w -> w
+                        .and(x -> x.eq(ServiceAssignment::getTargetType, TARGET_TYPE_ACTIVITY)
+                                .eq(ServiceAssignment::getTargetId, activity.getId()))
+                        .or(x -> x.eq(ServiceAssignment::getTargetType, TARGET_TYPE_ACTIVITY_TEAM)
+                                .eq(ServiceAssignment::getTargetId, activity.getTeamId() == null ? -1L : activity.getTeamId()))));
+        return count != null && count > 0;
+    }
+
+    /**
+     * 团队名下活动列表
+     */
+    public List<Activity> listTeamActivities(Long teamId) {
+        if (teamId == null) {
+            return new ArrayList<>();
+        }
+        return activityMapper.selectList(new LambdaQueryWrapper<Activity>()
+                .eq(Activity::getTeamId, teamId)
+                .orderByDesc(Activity::getId));
+    }
+}

+ 44 - 0
cfc-backend/src/main/resources/schema.sql

@@ -2161,6 +2161,7 @@ CREATE TABLE IF NOT EXISTS activities (
     visibility VARCHAR(20) DEFAULT 'public' COMMENT '浏览权限:public/login/private',
     visible_to TEXT COMMENT '私密指定人群JSON',
     poster_template_id BIGINT NULL COMMENT '创建时选择的海报模板ID(小程序端海报模板选择,迁移388)',
+    team_id          BIGINT NULL COMMENT '活动管理团队ID(activity_team.id), NULL=无团队; 团队成员可协作运营(迁移437/T35)',
     created_at       DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at       DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_dimension (dimension_code),
@@ -2196,6 +2197,49 @@ ALTER TABLE activities
   ADD COLUMN creator_id BIGINT DEFAULT NULL
     COMMENT '创建人userId(普通用户发布者, admin创建可为空)';
 
+-- ===== T35 活动管理团队(迁移437) =====
+-- 活动团队:activities.team_id 关联 activity_team.id;团队成员可协作运营团队名下活动
+CREATE TABLE IF NOT EXISTS activity_team (
+    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
+    name        VARCHAR(100) NOT NULL COMMENT '团队名称',
+    description VARCHAR(500) DEFAULT NULL COMMENT '团队简介',
+    owner_id    BIGINT NOT NULL COMMENT '团长userId',
+    status      VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active/disabled',
+    created_at  DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at  DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_owner (owner_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动管理团队';
+
+CREATE TABLE IF NOT EXISTS activity_team_member (
+    id        BIGINT AUTO_INCREMENT PRIMARY KEY,
+    team_id   BIGINT NOT NULL COMMENT '团队ID(activity_team.id)',
+    user_id   BIGINT NOT NULL COMMENT '成员userId',
+    role      VARCHAR(32) NOT NULL DEFAULT 'operator' COMMENT 'owner(团长)/manager(运营主管)/operator(运营成员)',
+    status    VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active(在团)/removed(已移出)',
+    joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_team_user (team_id, user_id),
+    INDEX idx_user (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动团队成员';
+
+-- 服务指派(通用表,T34 服务指派机制复用;本期 service_type=ACTIVITY_SUPPORT 活动运营服务)
+CREATE TABLE IF NOT EXISTS service_assignment (
+    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
+    service_type VARCHAR(32) NOT NULL COMMENT '服务类型: ACTIVITY_SUPPORT(活动运营)',
+    target_type VARCHAR(32) NOT NULL COMMENT '目标类型: ACTIVITY(具体活动)/ACTIVITY_TEAM(团队整体)',
+    target_id   BIGINT NOT NULL COMMENT '目标ID',
+    team_id     BIGINT DEFAULT NULL COMMENT '关联团队ID(activity_team.id)',
+    assignee_id BIGINT NOT NULL COMMENT '被指派人userId',
+    role        VARCHAR(32) NOT NULL DEFAULT 'support' COMMENT 'lead/support/checkin/content',
+    status      VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active/revoked',
+    remark      VARCHAR(255) DEFAULT NULL COMMENT '备注',
+    assigned_by BIGINT DEFAULT NULL COMMENT '指派操作人userId',
+    created_at  DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at  DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_service_assign (service_type, target_type, target_id, assignee_id, role),
+    INDEX idx_team (team_id),
+    INDEX idx_assignee (assignee_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务指派(活动运营ACTIVITY_SUPPORT, T34机制复用)';
+
 -- ===== v1.3 家庭成员体系 =====
 
 -- 手动添加的家庭成员表(替代children表用于新成员)

+ 288 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ActivityTeamServiceUnitTest.java

@@ -0,0 +1,288 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.entity.ActivityTeam;
+import com.etotem.cfc.entity.ActivityTeamMember;
+import com.etotem.cfc.entity.ServiceAssignment;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ActivityMapper;
+import com.etotem.cfc.mapper.ActivityTeamMapper;
+import com.etotem.cfc.mapper.ActivityTeamMemberMapper;
+import com.etotem.cfc.mapper.ServiceAssignmentMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.ActivityTeamService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * T35 活动管理团队(activity_team / activity_team_member / service_assignment ACTIVITY_SUPPORT)单元测试。
+ *
+ * <p>覆盖:团队创建自动挂团长成员、成员幂等增改、团长不可移出且移出时级联撤销服务指派、
+ * 服务指派前置校验(目标存在 + 被指派人须在团)、活动协作判定(团队成员/被指派人)。</p>
+ */
+class ActivityTeamServiceUnitTest {
+
+    private ActivityTeamService service;
+    private ActivityTeamMapper teamMapper;
+    private ActivityTeamMemberMapper memberMapper;
+    private ServiceAssignmentMapper assignmentMapper;
+    private ActivityMapper activityMapper;
+    private UserMapper userMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new ActivityTeamService();
+        teamMapper = mock(ActivityTeamMapper.class);
+        memberMapper = mock(ActivityTeamMemberMapper.class);
+        assignmentMapper = mock(ServiceAssignmentMapper.class);
+        activityMapper = mock(ActivityMapper.class);
+        userMapper = mock(UserMapper.class);
+        inject(service, "baseMapper", teamMapper);
+        inject(service, "activityTeamMemberMapper", memberMapper);
+        inject(service, "serviceAssignmentMapper", assignmentMapper);
+        inject(service, "activityMapper", activityMapper);
+        inject(service, "userMapper", userMapper);
+    }
+
+    // ==================== 团队创建 ====================
+
+    @Test
+    void createTeam_创建成功并自动成为团长成员() {
+        when(teamMapper.insert(any(ActivityTeam.class))).thenAnswer(inv -> {
+            ActivityTeam t = inv.getArgument(0);
+            t.setId(11L);
+            return 1;
+        });
+        when(memberMapper.insert(any(ActivityTeamMember.class))).thenReturn(1);
+
+        ActivityTeam team = service.createTeam("活动运营一组", "负责线下活动", 100L);
+
+        assertEquals(11L, team.getId());
+        assertEquals("活动运营一组", team.getName());
+        assertEquals(100L, team.getOwnerId());
+        assertEquals("active", team.getStatus());
+
+        ArgumentCaptor<ActivityTeamMember> captor = ArgumentCaptor.forClass(ActivityTeamMember.class);
+        verify(memberMapper).insert(captor.capture());
+        ActivityTeamMember owner = captor.getValue();
+        assertEquals(11L, owner.getTeamId());
+        assertEquals(100L, owner.getUserId());
+        assertEquals(ActivityTeamService.MEMBER_ROLE_OWNER, owner.getRole());
+        assertEquals("active", owner.getStatus());
+    }
+
+    @Test
+    void createTeam_名称为空抛异常() {
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> service.createTeam("  ", null, 100L));
+        assertEquals("团队名称不能为空", ex.getMessage());
+        verify(teamMapper, never()).insert(any(ActivityTeam.class));
+    }
+
+    // ==================== 成员管理 ====================
+
+    @Test
+    void addMember_用户不存在抛异常() {
+        when(userMapper.selectById(999L)).thenReturn(null);
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> service.addMember(11L, 999L, "operator"));
+        assertEquals("用户不存在", ex.getMessage());
+    }
+
+    @Test
+    void addMember_已移出成员重新入团并更新角色() {
+        User user = new User();
+        user.setId(200L);
+        when(userMapper.selectById(200L)).thenReturn(user);
+
+        ActivityTeamMember removed = new ActivityTeamMember();
+        removed.setId(5L);
+        removed.setTeamId(11L);
+        removed.setUserId(200L);
+        removed.setRole(ActivityTeamService.MEMBER_ROLE_OPERATOR);
+        removed.setStatus("removed");
+        when(memberMapper.selectOne(any())).thenReturn(removed);
+        when(memberMapper.updateById(any(ActivityTeamMember.class))).thenReturn(1);
+
+        ActivityTeamMember result = service.addMember(11L, 200L, "manager");
+
+        assertEquals(5L, result.getId());
+        assertEquals(ActivityTeamService.MEMBER_ROLE_MANAGER, result.getRole());
+        assertEquals("active", result.getStatus());
+        verify(memberMapper).updateById(any(ActivityTeamMember.class));
+        verify(memberMapper, never()).insert(any(ActivityTeamMember.class));
+    }
+
+    @Test
+    void removeMember_团长不可被移出() {
+        ActivityTeam team = new ActivityTeam();
+        team.setId(11L);
+        team.setOwnerId(100L);
+        when(teamMapper.selectById(11L)).thenReturn(team);
+
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> service.removeMember(11L, 100L));
+        assertEquals("团长不能被移出团队", ex.getMessage());
+        verify(memberMapper, never()).updateById(any(ActivityTeamMember.class));
+    }
+
+    @Test
+    void removeMember_移出成员时级联撤销其活动服务指派() {
+        ActivityTeam team = new ActivityTeam();
+        team.setId(11L);
+        team.setOwnerId(100L);
+        when(teamMapper.selectById(11L)).thenReturn(team);
+
+        ActivityTeamMember member = new ActivityTeamMember();
+        member.setId(7L);
+        member.setTeamId(11L);
+        member.setUserId(200L);
+        member.setRole(ActivityTeamService.MEMBER_ROLE_OPERATOR);
+        member.setStatus("active");
+        when(memberMapper.selectOne(any())).thenReturn(member);
+        when(memberMapper.updateById(any(ActivityTeamMember.class))).thenReturn(1);
+
+        ServiceAssignment assignment = new ServiceAssignment();
+        assignment.setId(3L);
+        assignment.setStatus("active");
+        when(assignmentMapper.selectList(any())).thenReturn(Collections.singletonList(assignment));
+        when(assignmentMapper.updateById(any(ServiceAssignment.class))).thenReturn(1);
+
+        assertTrue(service.removeMember(11L, 200L));
+
+        assertEquals("removed", member.getStatus());
+        assertEquals("revoked", assignment.getStatus());
+        verify(assignmentMapper).updateById(assignment);
+    }
+
+    // ==================== 服务指派(ACTIVITY_SUPPORT) ====================
+
+    @Test
+    void assignService_被指派人非在团成员时拒绝() {
+        ActivityTeam team = new ActivityTeam();
+        team.setId(11L);
+        team.setOwnerId(100L);
+        when(teamMapper.selectById(11L)).thenReturn(team);
+        Activity activity = new Activity();
+        activity.setId(50L);
+        when(activityMapper.selectById(50L)).thenReturn(activity);
+        when(memberMapper.selectOne(any())).thenReturn(null); // 非团队成员
+
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> service.assignService(11L, "ACTIVITY", 50L, 200L, "support", null, 100L));
+        assertEquals("被指派人须为团队在团成员", ex.getMessage());
+        verify(assignmentMapper, never()).insert(any(ServiceAssignment.class));
+    }
+
+    @Test
+    void assignService_新指派写入ACTIVITY_SUPPORT类型() {
+        ActivityTeam team = new ActivityTeam();
+        team.setId(11L);
+        team.setOwnerId(100L);
+        when(teamMapper.selectById(11L)).thenReturn(team);
+
+        Activity activity = new Activity();
+        activity.setId(50L);
+        activity.setTeamId(11L);
+        when(activityMapper.selectById(50L)).thenReturn(activity);
+
+        ActivityTeamMember member = new ActivityTeamMember();
+        member.setTeamId(11L);
+        member.setUserId(200L);
+        member.setRole(ActivityTeamService.MEMBER_ROLE_OPERATOR);
+        member.setStatus("active");
+        when(memberMapper.selectOne(any())).thenReturn(member);
+
+        when(assignmentMapper.selectOne(any())).thenReturn(null);
+        when(assignmentMapper.insert(any(ServiceAssignment.class))).thenAnswer(inv -> {
+            ServiceAssignment a = inv.getArgument(0);
+            a.setId(88L);
+            return 1;
+        });
+
+        ServiceAssignment result = service.assignService(
+                11L, "ACTIVITY", 50L, 200L, "checkin", "负责签到", 100L);
+
+        assertEquals(88L, result.getId());
+        assertEquals(ActivityTeamService.SERVICE_TYPE_ACTIVITY_SUPPORT, result.getServiceType());
+        assertEquals(ActivityTeamService.TARGET_TYPE_ACTIVITY, result.getTargetType());
+        assertEquals(50L, result.getTargetId());
+        assertEquals(11L, result.getTeamId());
+        assertEquals(200L, result.getAssigneeId());
+        assertEquals("checkin", result.getRole());
+        assertEquals("active", result.getStatus());
+        assertEquals(100L, result.getAssignedBy());
+    }
+
+    @Test
+    void assignService_目标类型不合法抛异常() {
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> service.assignService(11L, "ORDER", 1L, 200L, "support", null, 100L));
+        assertEquals("指派目标类型不合法", ex.getMessage());
+    }
+
+    // ==================== 协作判定 ====================
+
+    @Test
+    void isActivityCollaborator_团队在团成员为true() {
+        Activity activity = new Activity();
+        activity.setId(50L);
+        activity.setTeamId(11L);
+
+        ActivityTeamMember member = new ActivityTeamMember();
+        member.setTeamId(11L);
+        member.setUserId(200L);
+        member.setRole(ActivityTeamService.MEMBER_ROLE_OPERATOR);
+        member.setStatus("active");
+        when(memberMapper.selectOne(any())).thenReturn(member);
+
+        assertTrue(service.isActivityCollaborator(activity, 200L));
+    }
+
+    @Test
+    void isActivityCollaborator_非团队成员且无指派为false() {
+        Activity activity = new Activity();
+        activity.setId(50L);
+        activity.setTeamId(11L);
+        when(memberMapper.selectOne(any())).thenReturn(null);
+        when(assignmentMapper.selectCount(any())).thenReturn(0L);
+
+        assertFalse(service.isActivityCollaborator(activity, 300L));
+    }
+
+    @Test
+    void listTeams_includeAll为true时返回全部团队() {
+        List<ActivityTeam> all = Arrays.asList(new ActivityTeam(), new ActivityTeam());
+        when(teamMapper.selectList(any())).thenReturn(all);
+
+        List<ActivityTeam> result = service.listTeams(null, true);
+
+        assertEquals(2, result.size());
+        verify(memberMapper, never()).selectList(any());
+    }
+
+    private static void inject(Object target, String fieldName, Object value) throws Exception {
+        Class<?> type = target.getClass();
+        while (type != null) {
+            try {
+                Field f = type.getDeclaredField(fieldName);
+                f.setAccessible(true);
+                f.set(target, value);
+                return;
+            } catch (NoSuchFieldException e) {
+                type = type.getSuperclass();
+            }
+        }
+        throw new NoSuchFieldException(fieldName);
+    }
+}

+ 53 - 0
cfc-web/src/api/activityTeam.js

@@ -0,0 +1,53 @@
+import request from '@/utils/request'
+
+// ===== T35 活动管理团队 =====
+
+// 创建活动团队(body: { name, description })
+export function createActivityTeam(data) {
+  return request({ url: '/api/activity/team/create', method: 'post', data })
+}
+
+// 我的团队列表(管理员返回全部)
+export function listActivityTeams() {
+  return request({ url: '/api/activity/team/list', method: 'post', data: {} })
+}
+
+// 团队详情(team + members)
+export function activityTeamDetail(data) {
+  return request({ url: '/api/activity/team/detail', method: 'post', data })
+}
+
+// 团队成员列表
+export function activityTeamMembers(data) {
+  return request({ url: '/api/activity/team/member/list', method: 'post', data })
+}
+
+// 添加团队成员(body: { teamId, userId, role })
+export function addActivityTeamMember(data) {
+  return request({ url: '/api/activity/team/member/add', method: 'post', data })
+}
+
+// 移出团队成员(body: { teamId, userId })
+export function removeActivityTeamMember(data) {
+  return request({ url: '/api/activity/team/member/remove', method: 'post', data })
+}
+
+// 服务指派(ACTIVITY_SUPPORT;body: { teamId, targetType, targetId, assigneeId, role, remark })
+export function assignActivityService(data) {
+  return request({ url: '/api/activity/team/assign', method: 'post', data })
+}
+
+// 服务指派列表(body: { teamId, targetType?, targetId? })
+export function listActivityAssignments(data) {
+  return request({ url: '/api/activity/team/assign/list', method: 'post', data })
+}
+
+// 撤销服务指派(body: { id, teamId })
+export function revokeActivityAssignment(data) {
+  return request({ url: '/api/activity/team/assign/revoke', method: 'post', data })
+}
+
+// 团队名下活动列表(body: { teamId })
+export function activityTeamActivities(data) {
+  return request({ url: '/api/activity/team/activities', method: 'post', data })
+}

+ 6 - 0
cfc-web/src/router/index.js

@@ -683,6 +683,12 @@ const routes = [
         name: 'Activities',
         component: () => import('@/views/admin/Activities.vue'),
         meta: { title: '活动列表', perm: 'activity:list,activity:self' }
+      },
+      {
+        path: 'activity-teams',
+        name: 'ActivityTeams',
+        component: () => import('@/views/admin/ActivityTeams.vue'),
+        meta: { title: '活动团队', perm: 'activity:list,activity:self' }
       },
         {
           path: 'activity-edit',

+ 1 - 0
cfc-web/src/views/admin/Activities.vue

@@ -4,6 +4,7 @@
       <PageHeader slot="header" title="活动管理">
         
         <el-button type="success" size="mini" icon="el-icon-plus" @click="$router.push('/activity-edit')">创建活动</el-button>
+        <el-button type="primary" size="mini" icon="el-icon-s-claim" style="margin-left: 8px;" @click="$router.push('/activity-teams')">团队管理</el-button>
       
 </PageHeader>
 

+ 456 - 0
cfc-web/src/views/admin/ActivityTeams.vue

@@ -0,0 +1,456 @@
+<template>
+  <div class="activity-teams admin-page">
+    <el-card>
+      <PageHeader slot="header" title="活动团队管理">
+        <el-button type="success" size="mini" icon="el-icon-plus" @click="openCreateDialog">创建团队</el-button>
+      </PageHeader>
+
+      <el-table :data="teams" v-loading="loading" border stripe class="min-width-900" height="100%">
+        <el-table-column prop="id" label="ID" width="80" />
+        <el-table-column prop="name" label="团队名称" width="180" />
+        <el-table-column prop="description" label="简介" />
+        <el-table-column prop="ownerId" label="团长ID" width="100" />
+        <el-table-column prop="status" label="状态" width="100">
+          <template slot-scope="{ row }">
+            <el-tag v-if="row.status === 'active'" type="success">正常</el-tag>
+            <el-tag v-else type="danger">已停用</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="200">
+          <template slot-scope="{ row }">
+            <el-button size="mini" type="primary" @click="openDetail(row.id)">查看详情</el-button>
+            <el-button size="mini" type="danger" @click="confirmDelete(row.id)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- 创建团队对话框 -->
+    <el-dialog :visible.sync="createDialog" title="创建活动团队" width="500px" :before-close="handleCloseCreate">
+      <el-form :model="createForm" label-width="80px">
+        <el-form-item label="团队名称" prop="name">
+          <el-input v-model="createForm.name" placeholder="请输入团队名称" maxlength="100" />
+        </el-form-item>
+        <el-form-item label="团队简介" prop="description">
+          <el-input v-model="createForm.description" type="textarea" placeholder="请输入团队简介" rows="3" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="createDialog = false">取消</el-button>
+        <el-button type="primary" @click="submitCreate" :loading="createLoading">确定</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 团队详情对话框 -->
+    <el-dialog :visible.sync="detailDialog" title="团队详情" width="800px" :before-close="handleCloseDetail">
+      <el-tabs v-model="detailTab" @tab-click="handleTabClick">
+        <el-tab-pane label="成员管理" name="members">
+          <el-table :data="detailMembers" v-loading="detailLoading" border stripe class="min-width-700" height="300">
+            <el-table-column prop="id" label="ID" width="80" />
+            <el-table-column prop="userId" label="用户ID" width="100" />
+            <el-table-column prop="role" label="角色" width="140">
+              <template slot-scope="{ row }">
+                <el-tag size="mini" :type="roleTagType(row.role)">{{ roleLabel(row.role) }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column prop="status" label="状态" width="100">
+              <template slot-scope="{ row }">
+                <el-tag v-if="row.status === 'active'" type="success" size="mini">在团</el-tag>
+                <el-tag v-else type="danger" size="mini">已移出</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column prop="joinedAt" label="加入时间" width="180" />
+            <el-table-column label="操作" width="150">
+              <template slot-scope="{ row }">
+                <el-button v-if="row.status === 'active' && row.role !== 'owner'" size="mini" type="danger" @click="removeMember(row.userId)">移出</el-button>
+                <span v-else class="muted">—</span>
+              </template>
+            </el-table-column>
+          </el-table>
+          <div class="dialog-footer-row">
+            <el-button type="primary" size="mini" @click="openAddMemberDialog">添加成员</el-button>
+          </div>
+        </el-tab-pane>
+
+        <el-tab-pane label="服务指派" name="assignments">
+          <el-table :data="detailAssignments" v-loading="detailLoading" border stripe class="min-width-700" height="300">
+            <el-table-column prop="id" label="ID" width="80" />
+            <el-table-column prop="targetType" label="目标类型" width="130">
+              <template slot-scope="{ row }">
+                <el-tag size="mini" :type="row.targetType === 'ACTIVITY' ? 'primary' : 'info'">{{ row.targetType }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column prop="targetId" label="目标ID" width="100" />
+            <el-table-column prop="assigneeId" label="被指派人ID" width="120" />
+            <el-table-column prop="role" label="指派角色" width="130">
+              <template slot-scope="{ row }">
+                <el-tag size="mini" :type="assignTagType(row.role)">{{ assignRoleLabel(row.role) }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column prop="remark" label="备注" />
+            <el-table-column prop="status" label="状态" width="100">
+              <template slot-scope="{ row }">
+                <el-tag v-if="row.status === 'active'" type="success" size="mini">生效</el-tag>
+                <el-tag v-else type="danger" size="mini">已撤销</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" width="100">
+              <template slot-scope="{ row }">
+                <el-button v-if="row.status === 'active'" size="mini" type="danger" @click="revokeAssignment(row.id)">撤销</el-button>
+                <span v-else class="muted">—</span>
+              </template>
+            </el-table-column>
+          </el-table>
+          <div class="dialog-footer-row">
+            <el-button type="primary" size="mini" @click="openAssignDialog">新增指派</el-button>
+          </div>
+        </el-tab-pane>
+
+        <el-tab-pane label="团队活动" name="activities">
+          <el-table :data="detailActivities" v-loading="detailLoading" border stripe class="min-width-700" height="300">
+            <el-table-column prop="id" label="ID" width="80" />
+            <el-table-column prop="title" label="活动标题" />
+            <el-table-column prop="status" label="状态" width="120" />
+            <el-table-column prop="startTime" label="开始时间" width="180" />
+            <el-table-column prop="location" label="地点" width="180" />
+          </el-table>
+        </el-tab-pane>
+      </el-tabs>
+    </el-dialog>
+
+    <!-- 添加成员对话框 -->
+    <el-dialog :visible.sync="addMemberDialog" title="添加团队成员" width="500px" :before-close="handleCloseAddMember">
+      <el-form :model="addMemberForm" label-width="80px">
+        <el-form-item label="用户ID" prop="userId">
+          <el-input v-model.number="addMemberForm.userId" type="number" placeholder="请输入用户ID" />
+        </el-form-item>
+        <el-form-item label="角色" prop="role">
+          <el-select v-model="addMemberForm.role" placeholder="请选择角色" clearable>
+            <el-option label="团长" value="owner" />
+            <el-option label="运营主管" value="manager" />
+            <el-option label="运营成员" value="operator" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="addMemberDialog = false">取消</el-button>
+        <el-button type="primary" @click="submitAddMember" :loading="addMemberLoading">确定</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 新增服务指派对话框 -->
+    <el-dialog :visible.sync="assignDialog" title="新增服务指派" width="550px" :before-close="handleCloseAssign">
+      <el-form :model="assignForm" label-width="110px">
+        <el-form-item label="目标类型" prop="targetType">
+          <el-select v-model="assignForm.targetType" placeholder="请选择" clearable>
+            <el-option label="具体活动" value="ACTIVITY" />
+            <el-option label="团队整体" value="ACTIVITY_TEAM" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="目标ID" prop="targetId">
+          <el-input v-model.number="assignForm.targetId" type="number" placeholder="请输入目标ID" />
+        </el-form-item>
+        <el-form-item label="被指派人ID" prop="assigneeId">
+          <el-input v-model.number="assignForm.assigneeId" type="number" placeholder="请输入被指派人用户ID" />
+        </el-form-item>
+        <el-form-item label="指派角色" prop="role">
+          <el-select v-model="assignForm.role" placeholder="请选择" clearable>
+            <el-option label="统筹" value="lead" />
+            <el-option label="运营支持" value="support" />
+            <el-option label="签到执行" value="checkin" />
+            <el-option label="内容运维" value="content" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="备注" prop="remark">
+          <el-input v-model="assignForm.remark" type="textarea" rows="2" placeholder="备注" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="assignDialog = false">取消</el-button>
+        <el-button type="primary" @click="submitAssign" :loading="assignLoading">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  createActivityTeam,
+  listActivityTeams,
+  activityTeamDetail,
+  activityTeamMembers,
+  addActivityTeamMember,
+  removeActivityTeamMember,
+  assignActivityService,
+  listActivityAssignments,
+  revokeActivityAssignment,
+  activityTeamActivities
+} from '@/api/activityTeam'
+
+export default {
+  name: 'ActivityTeams',
+  data() {
+    return {
+      loading: false,
+      teams: [],
+
+      createDialog: false,
+      createLoading: false,
+      createForm: { name: '', description: '' },
+
+      detailDialog: false,
+      detailLoading: false,
+      detailTab: 'members',
+      currentTeamId: null,
+      detailMembers: [],
+      detailAssignments: [],
+      detailActivities: [],
+
+      addMemberDialog: false,
+      addMemberLoading: false,
+      addMemberForm: { userId: null, role: 'operator' },
+
+      assignDialog: false,
+      assignLoading: false,
+      assignForm: { targetType: 'ACTIVITY', targetId: null, assigneeId: null, role: 'support', remark: '' }
+    }
+  },
+  created() {
+    this.loadTeams()
+  },
+  methods: {
+    // ===== 团队列表 =====
+    async loadTeams() {
+      this.loading = true
+      try {
+        const res = await listActivityTeams()
+        if (res.code === 200) {
+          this.teams = res.data || []
+        } else {
+          this.$message.error(res.message || '加载失败')
+        }
+      } catch (e) {
+        this.$message.error('网络错误')
+      } finally {
+        this.loading = false
+      }
+    },
+
+    // ===== 创建团队 =====
+    openCreateDialog() {
+      this.createForm = { name: '', description: '' }
+      this.createDialog = true
+    },
+    handleCloseCreate(done) {
+      this.createDialog = false
+      done()
+    },
+    async submitCreate() {
+      this.createLoading = true
+      try {
+        const res = await createActivityTeam(this.createForm)
+        if (res.code === 200) {
+          this.$message.success('创建成功')
+          this.createDialog = false
+          this.loadTeams()
+        } else {
+          this.$message.error(res.message || '创建失败')
+        }
+      } catch (e) {
+        this.$message.error('网络错误')
+      } finally {
+        this.createLoading = false
+      }
+    },
+
+    // ===== 团队详情 =====
+    openDetail(teamId) {
+      this.currentTeamId = teamId
+      this.detailTab = 'members'
+      this.detailDialog = true
+      this.loadTeamDetail()
+    },
+    handleCloseDetail(done) {
+      this.detailDialog = false
+      this.currentTeamId = null
+      this.detailMembers = []
+      this.detailAssignments = []
+      this.detailActivities = []
+      done()
+    },
+    async loadTeamDetail() {
+      this.detailLoading = true
+      try {
+        const res = await activityTeamDetail({ teamId: this.currentTeamId })
+        if (res.code === 200 && res.data) {
+          this.detailMembers = res.data.members || []
+        } else {
+          this.$message.error(res.message || '加载详情失败')
+        }
+        await this.loadAssignments()
+        await this.loadTeamActivities()
+      } catch (e) {
+        this.$message.error('网络错误')
+      } finally {
+        this.detailLoading = false
+      }
+    },
+    async handleTabClick(tab) {
+      if (tab.name === 'assignments') {
+        await this.loadAssignments()
+      } else if (tab.name === 'activities') {
+        await this.loadTeamActivities()
+      }
+    },
+    async loadAssignments() {
+      const res = await listActivityAssignments({ teamId: this.currentTeamId })
+      if (res.code === 200) {
+        this.detailAssignments = res.data || []
+      }
+    },
+    async loadTeamActivities() {
+      const res = await activityTeamActivities({ teamId: this.currentTeamId })
+      if (res.code === 200) {
+        this.detailActivities = res.data || []
+      }
+    },
+
+    // ===== 成员管理 =====
+    openAddMemberDialog() {
+      this.addMemberForm = { userId: null, role: 'operator' }
+      this.addMemberDialog = true
+    },
+    handleCloseAddMember(done) {
+      this.addMemberDialog = false
+      done()
+    },
+    async submitAddMember() {
+      this.addMemberLoading = true
+      try {
+        const res = await addActivityTeamMember({
+          teamId: this.currentTeamId,
+          userId: this.addMemberForm.userId,
+          role: this.addMemberForm.role
+        })
+        if (res.code === 200) {
+          this.$message.success('添加成功')
+          this.addMemberDialog = false
+          this.loadTeamDetail()
+        } else {
+          this.$message.error(res.message || '添加失败')
+        }
+      } catch (e) {
+        this.$message.error('网络错误')
+      } finally {
+        this.addMemberLoading = false
+      }
+    },
+    async removeMember(userId) {
+      try {
+        await this.$confirm('确定移出该成员?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
+        const res = await removeActivityTeamMember({ teamId: this.currentTeamId, userId })
+        if (res.code === 200) {
+          this.$message.success('已移出')
+          this.loadTeamDetail()
+        } else {
+          this.$message.error(res.message || '移出失败')
+        }
+      } catch (e) {
+        if (e !== 'cancel') this.$message.error('网络错误')
+      }
+    },
+
+    // ===== 服务指派 =====
+    openAssignDialog() {
+      this.assignForm = { targetType: 'ACTIVITY', targetId: null, assigneeId: null, role: 'support', remark: '' }
+      this.assignDialog = true
+    },
+    handleCloseAssign(done) {
+      this.assignDialog = false
+      done()
+    },
+    async submitAssign() {
+      this.assignLoading = true
+      try {
+        const res = await assignActivityService({
+          teamId: this.currentTeamId,
+          targetType: this.assignForm.targetType,
+          targetId: this.assignForm.targetId,
+          assigneeId: this.assignForm.assigneeId,
+          role: this.assignForm.role,
+          remark: this.assignForm.remark
+        })
+        if (res.code === 200) {
+          this.$message.success('指派成功')
+          this.assignDialog = false
+          this.loadAssignments()
+        } else {
+          this.$message.error(res.message || '指派失败')
+        }
+      } catch (e) {
+        this.$message.error('网络错误')
+      } finally {
+        this.assignLoading = false
+      }
+    },
+    async revokeAssignment(id) {
+      try {
+        await this.$confirm('确定撤销该指派?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
+        const res = await revokeActivityAssignment({ id, teamId: this.currentTeamId })
+        if (res.code === 200) {
+          this.$message.success('已撤销')
+          this.loadAssignments()
+        } else {
+          this.$message.error(res.message || '撤销失败')
+        }
+      } catch (e) {
+        if (e !== 'cancel') this.$message.error('网络错误')
+      }
+    },
+
+    // ===== 删除团队(后端未提供 delete,仅前端提示)=====
+    async confirmDelete(teamId) {
+      try {
+        await this.$confirm('删除团队将清空成员与指派,确定删除?', '警告', {
+          confirmButtonText: '确定删除',
+          cancelButtonText: '取消',
+          type: 'warning',
+          dangerouslyUseHTMLString: true
+        })
+        // 后端暂无 delete 接口,提示后端需补充
+        this.$message.warning('后端暂未提供删除接口,请联系开发')
+      } catch (e) {
+        // cancel
+      }
+    },
+
+    // ===== 显示辅助 =====
+    roleLabel(role) {
+      const map = { owner: '团长', manager: '运营主管', operator: '运营成员' }
+      return map[role] || role
+    },
+    roleTagType(role) {
+      const map = { owner: 'danger', manager: 'warning', operator: 'primary' }
+      return map[role] || 'info'
+    },
+    assignRoleLabel(role) {
+      const map = { lead: '统筹', support: '运营支持', checkin: '签到执行', content: '内容运维' }
+      return map[role] || role
+    },
+    assignTagType(role) {
+      const map = { lead: 'danger', support: 'primary', checkin: 'warning', content: 'info' }
+      return map[role] || 'info'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.dialog-footer-row {
+  padding-top: 12px;
+}
+.muted {
+  color: #909399;
+  font-size: 12px;
+}
+</style>

+ 30 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -2044,6 +2044,36 @@ Web 管理端定义活动海报模板(迁移381 `activity_poster_templates` 
 
 ---
 
+### 4.56 活动管理团队(`ActivityTeamController`,前缀 `/api/activity/team`)—— T35
+
+> **T35 规格 §2.2/§5**:新增「活动管理团队」类型,`activities.team_id` 关联 `activity_team`;团队成员可协作运营团队名下活动,复用服务指派机制(`service_type=ACTIVITY_SUPPORT`,见 T34)。团队数据独立于 supply_system(供应体系),避免活动运营人员误入商品分销同体系判定。
+
+**权限规则:**
+- 平台活动角色(admin / activity_admin / activity_manager)可管理全部团队;
+- 团队团长/运营主管(owner/manager)可管理本团队;
+- 普通在团成员可查看与协作(编辑活动/报名审核/签到等)。
+
+| 路径 | 说明 |
+|---|---|
+| `POST /api/activity/team/create` | 创建活动团队(body: `{name, description?}`,管理员可指定 `ownerId`;普通用户以自己 userId 建团,自动成为团长 owner) |
+| `POST /api/activity/team/list` | 我的团队列表(管理员返回全部;普通用户返回在团团队),含 id/name/description/ownerId/status |
+| `POST /api/activity/team/detail` | 团队详情(body: `{teamId}`),返回 team + members |
+| `POST /api/activity/team/member/list` | 团队成员列表(body: `{teamId}`) |
+| `POST /api/activity/team/member/add` | 添加成员(body: `{teamId, userId, role?}`,role 可选:owner/manager/operator,默认 operator;幂等:已在团则更新角色) |
+| `POST /api/activity/team/member/remove` | 移出成员(body: `{teamId, userId}`,团长不可被移出;移出时级联撤销该成员在团内的 ACTIVITY_SUPPORT 指派) |
+| `POST /api/activity/team/assign` | 服务指派(ACTIVITY_SUPPORT,body: `{teamId, targetType, targetId, assigneeId, role, remark?}`,targetType: ACTIVITY/ACTIVITY_TEAM,role: lead/support/checkin/content;被指派人须为团队在团成员) |
+| `POST /api/activity/team/assign/list` | 服务指派列表(body: `{teamId, targetType?, targetId?}`) |
+| `POST /api/activity/team/assign/revoke` | 撤销服务指派(body: `{id, teamId?}`) |
+| `POST /api/activity/team/activities` | 团队名下活动列表(body: `{teamId}`) |
+
+**ActivityController 权限扩展(T35 协作判定):**
+- `/api/activity/create`:团队活动(`teamId` 非空)须由团队在团成员创建;
+- `/api/activity/update`:团队活动的编辑权限扩展到团队在团成员 + 该活动(或其团队)的 ACTIVITY_SUPPORT 被指派人。
+
+*文档更新:2026-11-01(T35 活动管理团队:活动团队创建/成员管理/服务指派、ActivityController 协作权限扩展)*
+
+---
+
 ## 4.48 组织管理(机构/企业组织)
 
 平台角色(admin/operator/finance)可管理所有组织并查看全量统计;组织管理员(`users.roles` 含 `org_admin`)登录管理端后默认跳转自身组织统计大屏,仅能访问本组织数据。实现方案见规格 `docs/superpowers/specs/2026-10-22-org-admin-management-design.md`。