Browse Source

docs: 审核中心统一详情页与服务角色分流设计+实施计划

Xiaogang Liao 4 days ago
parent
commit
df875bf006

+ 853 - 0
docs/superpowers/plans/2026-10-19-review-center-unified-detail.md

@@ -0,0 +1,853 @@
+# 审核中心统一详情页审核 + 服务角色申请分流合并 实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 让审核中心 12 个 Tab 统一"点开详情抽屉 → 通过/驳回(驳回必填意见)",并把小程序服务角色申请按角色分流合并进对应 Tab,删除独立菜单。
+
+**Architecture:** 前端新增通用 `AuditDetailDrawer` 组件承载详情与审核动作;每个 Tab 保留原列表数据源,涉及服务角色的 5 个 Tab 额外拉取 `/api/service-role/admin/list` 并在前端合并、去重、排序。后端只做增量:列表过滤/用户信息补充、审核通过逻辑修复、统计合并、幂等迁移。
+
+**Tech Stack:** Vue 2 + Element UI + Jest(vue-jest);Spring Boot 2.7.18 + MyBatis-Plus + JUnit 5 + Mockito;MySQL 8.0。
+
+**Spec:** `docs/superpowers/specs/2026-10-19-review-center-unified-detail-design.md`
+
+## Global Constraints
+
+- 生产库是 MySQL 8.0.34,DDL 必须幂等(`ADD COLUMN IF NOT EXISTS` 不支持下,使用 `information_schema` 判断或 try/catch 忽略重复错误)。
+- 所有驳回操作:原因 trim 后为空必须阻止提交(前端 + 后端双重校验)。
+- 待退款 Tab 只有「中止退款」,不得出现"通过"。
+- 不改审核权限模型(不新增后端角色校验)。
+- 代码在远程 `root@111.228.6.214:/app/cfc`,生产在 `/app/pro/cfc`;部署前必须备份。
+- 每个任务结束提交一次 git commit。
+
+## Review Focus
+
+1. **多角色 JSON 匹配**:`service_role_applications.roles` 是 JSON 数组字符串,过滤 `vendor` 时不能把 `activity_provider` 漏掉,也不能把 `planner` 误匹配进来。
+2. **重复行**:同一 userId 同时有 `users.*_status=pending` 和 service_role 待审记录时,合并列表只能出现一行(优先 service_role)。
+3. **空驳回意见**:抽屉里点"确认驳回"但意见为空/全空格时,必须停留在抽屉并提示,不能发请求。
+4. **资金动作语义**:待退款 Tab 的「中止退款」只能作用于 `pending`/`processing`,不能把 `success`/`failed` 记录变成可操作。
+5. **已审核记录**:`approved`/`rejected` 记录打开抽屉时不得显示通过/驳回按钮,只展示审核结果。
+
+---
+
+## File Structure
+
+**后端(修改)**
+- `cfc-backend/src/main/java/com/etotem/cfc/controller/ServiceRoleApplicationController.java` — 列表过滤参数与权限保留
+- `cfc-backend/src/main/java/com/etotem/cfc/service/ServiceRoleApplicationService.java` — 新增带用户信息的列表方法
+- `cfc-backend/src/main/java/com/etotem/cfc/service/impl/ServiceRoleApplicationServiceImpl.java` — 过滤/富化 + approve 修复
+- `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReviewCenterController.java` — 统计合并 + knowledgePending
+- `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminActivityRegistrationController.java` — 驳回原因参数
+- `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCommissionController.java` — 中止退款原因参数
+- `cfc-backend/src/main/java/com/etotem/cfc/service/PendingRefundService.java` — 中止退款写入原因
+- `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminButlerController.java` — 管家用户申请列表接口
+- `cfc-backend/src/main/java/com/etotem/cfc/service/ActivityRegistrationService.java` — reject 增加 reason
+- `cfc-backend/src/main/java/com/etotem/cfc/entity/ActivityRegistration.java` — rejectReason 字段
+- `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` — 迁移 382-384
+
+**后端(新增测试)**
+- `cfc-backend/src/test/java/com/etotem/cfc/unit/ServiceRoleFilterTest.java`
+- `cfc-backend/src/test/java/com/etotem/cfc/unit/ServiceRoleApproveTest.java`
+- `cfc-backend/src/test/java/com/etotem/cfc/unit/ReviewCenterSummaryTest.java`
+- `cfc-backend/src/test/java/com/etotem/cfc/unit/ActivityRegistrationRejectTest.java`
+
+**前端(新增)**
+- `cfc-web/src/views/admin/review/components/AuditDetailDrawer.vue`
+- `cfc-web/src/views/admin/review/KnowledgeAdminAudit.vue`
+- `cfc-web/src/views/admin/review/__tests__/AuditDetailDrawer.spec.js`
+- `cfc-web/src/views/admin/review/__tests__/auditRowMapping.spec.js`
+- `cfc-web/src/utils/auditRowMapping.js`
+- `cfc-web/src/api/service-role.js`
+
+**前端(修改)**
+- 12 个 Tab:`cfc-web/src/views/admin/review/ArticleAudit.vue`、`ProductAudit.vue`、`GuideAudit.vue`、`NutritionistAudit.vue`、`VendorAudit.vue`、`ButlerAudit.vue`、`PackageAudit.vue`、`ActivityAudit.vue`、`ActivityRegistrationAudit.vue`、`WithdrawAudit.vue`、`PendingRefundAudit.vue`、`HierarchyAudit.vue`
+- `cfc-web/src/views/admin/ReviewCenter.vue`
+- `cfc-web/src/views/Layout.vue`
+- `cfc-web/src/router/index.js`
+- `cfc-web/src/api/activity.js`(活动报名驳回 reason)
+
+**前端(删除)**
+- `cfc-web/src/views/service/ServiceRoleApplications.vue`
+
+---
+
+## Task 1: 服务角色申请列表接口(role/status 过滤 + 用户信息)
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/ServiceRoleApplicationController.java:91-100`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ServiceRoleApplicationService.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/impl/ServiceRoleApplicationServiceImpl.java`
+- Test: `cfc-backend/src/test/java/com/etotem/cfc/unit/ServiceRoleFilterTest.java`
+
+**Interfaces:**
+- Produces:
+  - `public static List<String> ServiceRoleApplicationServiceImpl.parseRoleFilter(String roleParam)` — `null`/空 → 空列表;逗号分隔、trim、去重。
+  - `public static boolean ServiceRoleApplicationServiceImpl.matchesAnyRole(String rolesJson, List<String> targetRoles)` — `rolesJson` 解析失败或无交集 → false。
+  - `List<Map<String,Object>> ServiceRoleApplicationService.listApplicationsWithUserInfo(Integer status, List<String> roleFilter)`
+  - 返回行字段:原实体全部字段 + `userName`、`realName`、`phone`。
+
+- [ ] **Step 1: 写失败测试**
+
+```java
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.service.impl.ServiceRoleApplicationServiceImpl;
+import org.junit.jupiter.api.Test;
+import java.util.Arrays;
+import java.util.Collections;
+import static org.junit.jupiter.api.Assertions.*;
+
+class ServiceRoleFilterTest {
+    @Test
+    void parseRoleFilter_空和null返回空列表() {
+        assertEquals(Collections.emptyList(), ServiceRoleApplicationServiceImpl.parseRoleFilter(null));
+        assertEquals(Collections.emptyList(), ServiceRoleApplicationServiceImpl.parseRoleFilter("  "));
+    }
+
+    @Test
+    void parseRoleFilter_逗号分隔去空格去重() {
+        assertEquals(Arrays.asList("vendor", "activity_provider"),
+                ServiceRoleApplicationServiceImpl.parseRoleFilter(" vendor, activity_provider ,vendor "));
+    }
+
+    @Test
+    void matchesAnyRole_按JSON数组精确匹配() {
+        String json = "[\"activity_provider\"]";
+        assertTrue(ServiceRoleApplicationServiceImpl.matchesAnyRole(json, Arrays.asList("vendor", "activity_provider")));
+        assertFalse(ServiceRoleApplicationServiceImpl.matchesAnyRole(json, Arrays.asList("vendor")));
+        assertFalse(ServiceRoleApplicationServiceImpl.matchesAnyRole(json, Arrays.asList("planner")));
+        assertFalse(ServiceRoleApplicationServiceImpl.matchesAnyRole("not-json", Arrays.asList("vendor")));
+        assertFalse(ServiceRoleApplicationServiceImpl.matchesAnyRole(null, Arrays.asList("vendor")));
+    }
+}
+```
+
+- [ ] **Step 2: 运行确认失败**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q -Dtest=ServiceRoleFilterTest test`
+Expected: 编译失败 `cannot find symbol parseRoleFilter`.
+
+- [ ] **Step 3: 实现过滤与富化**
+
+在 `ServiceRoleApplicationServiceImpl` 加静态方法(解析用 `JSON.parseArray`,失败返回空列表)。新增 `listApplicationsWithUserInfo(Integer status, List<String> roleFilter)`:先 `this.list(status 条件)`,再 `matchesAnyRole` 过滤,最后批量 `userMapper.selectBatchIds` 填充 `userName/realName/phone`,按 `createdAt` 倒序。Controller `adminList` 增加 `@RequestParam(required=false) String role`,调用新方法。
+
+- [ ] **Step 4: 跑测试确认通过**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q -Dtest=ServiceRoleFilterTest test`
+Expected: `BUILD SUCCESS`,3 个测试通过。
+
+- [ ] **Step 5: 提交**
+
+```bash
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/controller/ServiceRoleApplicationController.java cfc-backend/src/main/java/com/etotem/cfc/service/ServiceRoleApplicationService.java cfc-backend/src/main/java/com/etotem/cfc/service/impl/ServiceRoleApplicationServiceImpl.java cfc-backend/src/test/java/com/etotem/cfc/unit/ServiceRoleFilterTest.java
+git commit -m "feat(service-role): 申请列表支持角色/状态过滤并补充用户信息"
+```
+
+---
+
+## Task 2: 修复服务角色审核通过逻辑
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/impl/ServiceRoleApplicationServiceImpl.java:83-148`
+- Test: `cfc-backend/src/test/java/com/etotem/cfc/unit/ServiceRoleApproveTest.java`
+
+**Interfaces:**
+- Consumes: `ServiceRoleApplicationServiceImpl` 的 `userMapper`、`guideMapper`、`butlerProfileMapper`,新增 `adminMapper`。
+- Produces: `public void applyApprovedRole(String role, ServiceRoleApplication app, User user, List<String> ownedRoles)`(`public`,测试在同模块不同包)。
+
+- [ ] **Step 1: 写失败测试**
+
+```java
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import com.etotem.cfc.service.impl.ServiceRoleApplicationServiceImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class ServiceRoleApproveTest {
+    private ServiceRoleApplicationServiceImpl service;
+    private GuideMapper guideMapper;
+    private ButlerProfileMapper butlerProfileMapper;
+    private AdminMapper adminMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new ServiceRoleApplicationServiceImpl();
+        guideMapper = mock(GuideMapper.class);
+        butlerProfileMapper = mock(ButlerProfileMapper.class);
+        adminMapper = mock(AdminMapper.class);
+        set("guideMapper", guideMapper);
+        set("butlerProfileMapper", butlerProfileMapper);
+        set("adminMapper", adminMapper);
+    }
+
+    private void set(String name, Object v) throws Exception {
+        Field f = ServiceRoleApplicationServiceImpl.class.getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(service, v);
+    }
+
+    @Test
+    void planner_通过后写teacher_status并创建admin记录() {
+        ServiceRoleApplication app = new ServiceRoleApplication();
+        app.setUserId(12L); app.setProvince("北京"); app.setCity("北京市");
+        User user = new User(); user.setId(12L); user.setPhone("13900000000"); user.setNickname("n");
+        List<String> roles = new ArrayList<>();
+        when(guideMapper.insert(any())).thenReturn(1);
+        when(adminMapper.selectOne(any())).thenReturn(null);
+        when(adminMapper.insert(any())).thenReturn(1);
+
+        service.applyApprovedRole("planner", app, user, roles);
+
+        assertEquals("approved", user.getTeacherStatus());
+        assertTrue(roles.contains("teacher"));
+        verify(guideMapper).insert(any(Guide.class));
+        verify(adminMapper).insert(any(Admin.class));
+    }
+
+    @Test
+    void butler_通过后写butler_status且profile状态为approved() {
+        ServiceRoleApplication app = new ServiceRoleApplication();
+        app.setUserId(12L);
+        User user = new User(); user.setId(12L);
+        List<String> roles = new ArrayList<>();
+        when(butlerProfileMapper.selectOne(any())).thenReturn(null);
+        when(butlerProfileMapper.insert(any())).thenReturn(1);
+
+        service.applyApprovedRole("butler", app, user, roles);
+
+        assertEquals("approved", user.getButlerStatus());
+        assertTrue(roles.contains("butler"));
+        verify(butlerProfileMapper).insert(argThat(p -> "approved".equals(p.getStatus())));
+    }
+}
+```
+
+- [ ] **Step 2: 运行确认失败**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q -Dtest=ServiceRoleApproveTest test`
+Expected: 编译失败 `cannot find symbol applyApprovedRole`.
+
+- [ ] **Step 3: 实现**
+
+把 `approve` 里的 `switch` 抽成 `applyApprovedRole`,并补齐:
+- `planner`:`user.setTeacherStatus("approved")`;插入 Guide;roles 加 teacher;按手机号 upsert `admins`(复用 `AdminController.approveGuide` 的字段:phone/username/realName/nickname/role=teacher/status=1)。
+- `butler`:`user.setButlerStatus("approved")`;按 `user_id` upsert `butler_profiles`,状态写 `approved`(已存在则更新,不存在则插入)。
+- `approve` 开头判空 `user == null` 返回 `Result.error("用户不存在")`。
+- 其余角色逻辑保持不变。
+
+- [ ] **Step 4: 跑测试确认通过**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q -Dtest=ServiceRoleApproveTest test`
+Expected: `BUILD SUCCESS`,2 个测试通过。
+
+- [ ] **Step 5: 提交**
+
+```bash
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/service/impl/ServiceRoleApplicationServiceImpl.java cfc-backend/src/test/java/com/etotem/cfc/unit/ServiceRoleApproveTest.java
+git commit -m "fix(service-role): 审核通过补齐规划师状态/admin记录与管家档案状态"
+```
+
+---
+
+## Task 3: 审核中心统计合并 + 知识管理员计数
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReviewCenterController.java`
+- Test: `cfc-backend/src/test/java/com/etotem/cfc/unit/ReviewCenterSummaryTest.java`
+
+**Interfaces:**
+- Produces: `public static int countPendingByRoles(List<ServiceRoleApplication> apps, String... roles)`。
+- `summary()` 返回新增 `knowledgePending`;`guidePending/nutritionistPending/vendorPending/butlerPending` 改为"原计数 + 服务角色待审数"。
+
+- [ ] **Step 1: 写失败测试**
+
+```java
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.controller.admin.ReviewCenterController;
+import com.etotem.cfc.entity.ServiceRoleApplication;
+import org.junit.jupiter.api.Test;
+import java.util.Arrays;
+import java.util.Collections;
+import static org.junit.jupiter.api.Assertions.*;
+
+class ReviewCenterSummaryTest {
+    private ServiceRoleApplication app(String roles, int status) {
+        ServiceRoleApplication a = new ServiceRoleApplication();
+        a.setRoles(roles); a.setStatus(status); return a;
+    }
+
+    @Test
+    void countPendingByRoles_只数status0且角色命中() {
+        List<ServiceRoleApplication> list = Arrays.asList(
+            app("[\"planner\"]", 0),
+            app("[\"planner\"]", 1),
+            app("[\"activity_provider\"]", 0),
+            app("[\"vendor\"]", 0));
+        assertEquals(1, ReviewCenterController.countPendingByRoles(list, "planner"));
+        assertEquals(2, ReviewCenterController.countPendingByRoles(list, "vendor", "activity_provider"));
+        assertEquals(0, ReviewCenterController.countPendingByRoles(Collections.emptyList(), "planner"));
+    }
+}
+```
+
+- [ ] **Step 2: 运行确认失败**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q -Dtest=ReviewCenterSummaryTest test`
+Expected: 编译失败 `cannot find symbol countPendingByRoles`.
+
+- [ ] **Step 3: 实现**
+
+注入 `ServiceRoleApplicationMapper`;`summary()` 一次性查 `status=0` 的申请列表,用 `countPendingByRoles` 计算各角色待审数并加到原计数;新增 `knowledgePending`。`guidePending` 原查询加 `.isNotNull("real_name")` 与列表口径一致。
+
+- [ ] **Step 4: 跑测试确认通过**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q -Dtest=ReviewCenterSummaryTest test`
+Expected: `BUILD SUCCESS`.
+
+- [ ] **Step 5: 提交**
+
+```bash
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReviewCenterController.java cfc-backend/src/test/java/com/etotem/cfc/unit/ReviewCenterSummaryTest.java
+git commit -m "feat(review-center): 统计合并服务角色待审数并新增知识管理员计数"
+```
+
+---
+
+## Task 4: 数据库迁移(驳回原因列、规划师假数据、菜单隐藏)
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/entity/ActivityRegistration.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+
+**Interfaces:**
+- Produces: `activity_registrations.reject_reason VARCHAR(500)`;`users.teacher_status` 默认值 NULL + 103 条假 pending 清空;`sys_menu` id=14 `visible=0`。
+
+- [ ] **Step 1: 写迁移(幂等)**
+
+在 `DatabaseInitializer.runMigrations()` 末尾追加迁移 382/383/384:
+- 382:`information_schema.columns` 判断后 `ALTER TABLE activity_registrations ADD COLUMN reject_reason VARCHAR(500) NULL COMMENT '驳回原因'`。
+- 383:`UPDATE users SET teacher_status = NULL WHERE teacher_status='pending' AND (role IS NULL OR role <> 'teacher')`;`ALTER TABLE users MODIFY teacher_status VARCHAR(20) DEFAULT NULL`;同步把建列语句(约 375 行)的 `DEFAULT 'pending'` 改为 `DEFAULT NULL`。
+- 384:`UPDATE sys_menu SET visible=0 WHERE path='/service-role-applications'`;`DELETE FROM sys_role_menu WHERE menu_id IN (SELECT id FROM sys_menu WHERE path='/service-role-applications')`;同步删除 `seedSysMenus()` 中 `serviceRoleApp` 条目。
+
+- [ ] **Step 2: 本地编译确认**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q -DskipTests compile`
+Expected: `BUILD SUCCESS`.
+
+- [ ] **Step 3: 部署后 SQL 验证(Task 15 一起执行)**
+
+```sql
+SHOW COLUMNS FROM activity_registrations LIKE 'reject_reason';
+SHOW COLUMNS FROM users LIKE 'teacher_status';              -- Default 应为 NULL
+SELECT COUNT(*) FROM users WHERE teacher_status='pending';   -- 应为 1(id=2)
+SELECT visible FROM sys_menu WHERE path='/service-role-applications'; -- 0
+```
+
+- [ ] **Step 4: 提交**
+
+```bash
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/entity/ActivityRegistration.java cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
+git commit -m "feat(db): 活动报名驳回原因列、规划师假数据清理、服务角色菜单隐藏"
+```
+
+---
+
+## Task 5: 活动报名驳回原因落库
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ActivityRegistrationService.java:113-121`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminActivityRegistrationController.java:38-48`
+- Modify: `cfc-web/src/api/activity.js:119-125`
+- Test: `cfc-backend/src/test/java/com/etotem/cfc/unit/ActivityRegistrationRejectTest.java`
+
+**Interfaces:**
+- Produces: `Result<Void> reject(Long registrationId, Long adminUserId, String reason)`;HTTP 参数 `{registrationId, reason}`。
+
+- [ ] **Step 1: 写失败测试**
+
+```java
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.ActivityRegistration;
+import com.etotem.cfc.mapper.ActivityRegistrationMapper;
+import com.etotem.cfc.service.ActivityRegistrationService;
+import org.junit.jupiter.api.Test;
+import java.lang.reflect.Field;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class ActivityRegistrationRejectTest {
+    @Test
+    void 驳回写入原因() throws Exception {
+        ActivityRegistrationService svc = new ActivityRegistrationService();
+        ActivityRegistrationMapper mapper = mock(ActivityRegistrationMapper.class);
+        Field f = ActivityRegistrationService.class.getDeclaredField("registrationMapper");
+        f.setAccessible(true); f.set(svc, mapper);
+        ActivityRegistration reg = new ActivityRegistration();
+        reg.setId(1L); reg.setStatus("pending");
+        when(mapper.selectById(1L)).thenReturn(reg);
+
+        svc.reject(1L, 9L, "信息不完整");
+
+        assertEquals("rejected", reg.getStatus());
+        assertEquals("信息不完整", reg.getRejectReason());
+        verify(mapper).updateById(reg);
+    }
+}
+```
+
+- [ ] **Step 2: 运行确认失败**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q -Dtest=ActivityRegistrationRejectTest test`
+Expected: 编译失败(无 3 参 reject / 无 getRejectReason)。
+
+- [ ] **Step 3: 实现**
+
+`ActivityRegistration` 加 `private String rejectReason;`;`reject` 增第三参并 `reg.setRejectReason(reason)`;Controller 从 `params.get("reason")` 传入;前端 `rejectRegistration` 已透传 data,调用处补 `reason`。
+
+- [ ] **Step 4: 跑测试确认通过**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q -Dtest=ActivityRegistrationRejectTest test`
+Expected: `BUILD SUCCESS`.
+
+- [ ] **Step 5: 提交**
+
+```bash
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/service/ActivityRegistrationService.java cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminActivityRegistrationController.java cfc-backend/src/main/java/com/etotem/cfc/entity/ActivityRegistration.java cfc-web/src/api/activity.js cfc-backend/src/test/java/com/etotem/cfc/unit/ActivityRegistrationRejectTest.java
+git commit -m "feat(activity): 报名驳回原因落库"
+```
+
+---
+
+## Task 6: 通用详情抽屉组件
+
+**Files:**
+- Create: `cfc-web/src/views/admin/review/components/AuditDetailDrawer.vue`
+- Create: `cfc-web/src/views/admin/review/__tests__/AuditDetailDrawer.spec.js`
+
+**Interfaces:**
+- Props:`visible:Boolean`、`title:String`、`status:[String,Number]`、`loading:Boolean`、`actionMode:String`(`approve-reject`|`cancel`)。
+- Events:`update:visible`、`approve`、`reject(reason)`、`cancel(reason)`。
+- 插槽:默认插槽放详情内容。
+- 待审核判定:`status === 'pending' || status === 0 || status === '0'`。
+
+- [ ] **Step 1: 写失败测试**
+
+```js
+import { shallowMount } from '@vue/test-utils'
+import AuditDetailDrawer from '../components/AuditDetailDrawer.vue'
+
+describe('AuditDetailDrawer', () => {
+  it('驳回意见为空时不发 reject', async () => {
+    const wrapper = shallowMount(AuditDetailDrawer, {
+      propsData: { visible: true, status: 'pending', actionMode: 'approve-reject' },
+      stubs: { 'el-drawer': true, 'el-button': true, 'el-input': true }
+    })
+    wrapper.vm.showRejectInput = true
+    wrapper.vm.rejectReason = '   '
+    wrapper.vm.confirmReject()
+    expect(wrapper.emitted().reject).toBeUndefined()
+  })
+
+  it('驳回意见非空时 emit reject(reason)', () => {
+    const wrapper = shallowMount(AuditDetailDrawer, {
+      propsData: { visible: true, status: 'pending', actionMode: 'approve-reject' },
+      stubs: { 'el-drawer': true, 'el-button': true, 'el-input': true }
+    })
+    wrapper.vm.showRejectInput = true
+    wrapper.vm.rejectReason = '材料不全'
+    wrapper.vm.confirmReject()
+    expect(wrapper.emitted().reject[0]).toEqual(['材料不全'])
+  })
+
+  it('cancel 模式不渲染通过按钮且原因必填', () => {
+    const wrapper = shallowMount(AuditDetailDrawer, {
+      propsData: { visible: true, status: 'processing', actionMode: 'cancel' },
+      stubs: { 'el-drawer': true, 'el-button': true, 'el-input': true }
+    })
+    expect(wrapper.vm.showApprove).toBe(false)
+    wrapper.vm.cancelReason = ''
+    wrapper.vm.confirmCancel()
+    expect(wrapper.emitted().cancel).toBeUndefined()
+  })
+})
+```
+
+- [ ] **Step 2: 运行确认失败**
+
+Run: `cd /app/cfc/cfc-web && npx jest src/views/admin/review/__tests__/AuditDetailDrawer.spec.js`
+Expected: 找不到组件,测试失败。
+
+- [ ] **Step 3: 实现组件**
+
+`el-drawer` 右侧 720px;头部标题 + 状态标签;默认插槽;底部按钮区按 `actionMode`/`isPending` 渲染;`confirmApprove` 走 `$confirm` 后 emit;`confirmReject`/`confirmCancel` 校验 trim 非空,空则 `$message.warning`。提交中 `loading` 禁用按钮。
+
+- [ ] **Step 4: 跑测试确认通过**
+
+Run: `cd /app/cfc/cfc-web && npx jest src/views/admin/review/__tests__/AuditDetailDrawer.spec.js`
+Expected: 3 passed.
+
+- [ ] **Step 5: 提交**
+
+```bash
+cd /app/cfc && git add cfc-web/src/views/admin/review/components/AuditDetailDrawer.vue cfc-web/src/views/admin/review/__tests__/AuditDetailDrawer.spec.js
+git commit -m "feat(review): 新增通用审核详情抽屉组件"
+```
+
+---
+
+## Task 7: 服务角色 API 与合并映射工具
+
+**Files:**
+- Create: `cfc-web/src/api/service-role.js`
+- Create: `cfc-web/src/utils/auditRowMapping.js`
+- Create: `cfc-web/src/views/admin/review/__tests__/auditRowMapping.spec.js`
+
+**Interfaces:**
+- `listServiceRoleApplications({ status, role })` → `POST /api/service-role/admin/list`。
+- `approveServiceRoleApplication(id)`、`rejectServiceRoleApplication(id, reason)`。
+- `mapServiceRoleRow(app, roleLabels)` → 统一行 `{source:'service_role', ...}`。
+- `mergeAuditRows(userRows, serviceRoleRows)` → 按 `submittedAt` 倒序、按 `userId` 去重(service_role 优先)。
+
+- [ ] **Step 1: 写失败测试**
+
+```js
+import { mergeAuditRows, mapServiceRoleRow } from '@/utils/auditRowMapping'
+
+describe('auditRowMapping', () => {
+  const sr = { id: 1, userId: 12, roles: '["activity_provider"]', status: 0, createdAt: '2026-10-19 21:29:28', userName: '他爹' }
+  const userRow = { id: 12, userId: 12, nickname: '他爹', status: 'pending', submittedAt: '2026-10-18 10:00:00' }
+
+  it('映射服务角色申请', () => {
+    const row = mapServiceRoleRow(sr, { activity_provider: '活动提供商' })
+    expect(row.source).toBe('service_role')
+    expect(row.applyId).toBe(1)
+    expect(row.roleLabel).toBe('活动提供商')
+    expect(row.status).toBe('pending')
+  })
+
+  it('同一 userId 优先保留 service_role 行', () => {
+    const merged = mergeAuditRows([userRow], [sr])
+    expect(merged).toHaveLength(1)
+    expect(merged[0].source).toBe('service_role')
+  })
+
+  it('按提交时间倒序', () => {
+    const older = { ...userRow, userId: 99, id: 99 }
+    const merged = mergeAuditRows([older], [sr])
+    expect(merged[0].source).toBe('service_role')
+  })
+})
+```
+
+- [ ] **Step 2: 运行确认失败**
+
+Run: `cd /app/cfc/cfc-web && npx jest src/views/admin/review/__tests__/auditRowMapping.spec.js`
+Expected: 模块不存在。
+
+- [ ] **Step 3: 实现**
+
+`mapServiceRoleRow` 解析 `roles` JSON(容错),`status` 0/1/2 → pending/approved/rejected;`mergeAuditRows` 去重后按时间倒序;API 文件按现有 `request` 风格封装。
+
+- [ ] **Step 4: 跑测试确认通过**
+
+Run: `cd /app/cfc/cfc-web && npx jest src/views/admin/review/__tests__/auditRowMapping.spec.js`
+Expected: 3 passed.
+
+- [ ] **Step 5: 提交**
+
+```bash
+cd /app/cfc && git add cfc-web/src/api/service-role.js cfc-web/src/utils/auditRowMapping.js cfc-web/src/views/admin/review/__tests__/auditRowMapping.spec.js
+git commit -m "feat(review): 服务角色 API 与合并映射工具"
+```
+
+---
+
+## Task 8: 文章审核、任务模板审核迁移到抽屉
+
+**Files:**
+- Modify: `cfc-web/src/views/admin/review/ArticleAudit.vue`
+- Modify: `cfc-web/src/views/admin/review/PackageAudit.vue`
+
+**Interfaces:**
+- Consumes: `AuditDetailDrawer`(Task 6)。
+
+- [ ] **Step 1: 改造 ArticleAudit**
+
+把现有 `el-dialog` 详情改为 `AuditDetailDrawer`,内容保留标题/分类/作者/时间/摘要/正文 HTML;操作列改为「审核/详情」按钮;通过 emit `approve` → 原 `handleAudit(row,'approved')`;驳回 emit `reject(reason)` → `adminArticleAudit({id, auditStatus:'rejected', auditReason: reason})`。保留批量通过/批量驳回。
+
+- [ ] **Step 2: 改造 PackageAudit**
+
+同 ArticleAudit:详情字段为名称/周期/价格/详细介绍/驳回原因;通过/驳回走原模板审核接口。
+
+- [ ] **Step 3: 构建验证**
+
+Run: `cd /app/cfc/cfc-web && npx vue-cli-service build --mode production 2>&1 | tail -5`
+Expected: `Build complete`。
+
+- [ ] **Step 4: 提交**
+
+```bash
+cd /app/cfc && git add cfc-web/src/views/admin/review/ArticleAudit.vue cfc-web/src/views/admin/review/PackageAudit.vue
+git commit -m "refactor(review): 文章与任务模板审核改用统一详情抽屉"
+```
+
+---
+
+## Task 9: 其余 6 个审核 Tab 接入详情抽屉
+
+**Files:**
+- Modify: `ProductAudit.vue`、`ActivityAudit.vue`、`ActivityRegistrationAudit.vue`、`WithdrawAudit.vue`、`PendingRefundAudit.vue`、`HierarchyAudit.vue`(均在 `cfc-web/src/views/admin/review/`)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCommissionController.java`、`cfc-backend/src/main/java/com/etotem/cfc/service/PendingRefundService.java`(中止退款原因)
+
+- [ ] **Step 1: ProductAudit**
+
+详情:商品名称/图片/价格/库存/分类/供应商/描述/驳回原因;通过/驳回走 `reviewProduct`。
+
+- [ ] **Step 2: ActivityAudit**
+
+详情:活动名称/封面/时间/地点/主办方/内容/驳回原因;通过 → `auditActivity({id, action:'publish'})`;驳回带 reason。
+
+- [ ] **Step 3: ActivityRegistrationAudit**
+
+详情:报名用户/活动/报名时间/状态/驳回原因;通过 `approveRegistration({registrationId})`;驳回 `rejectRegistration({registrationId, reason})`。
+
+- [ ] **Step 4: WithdrawAudit**
+
+详情:用户/金额/账户信息/申请时间/状态/驳回原因(复用 `remark`);通过 `auditWithdrawal(id,'approved')`;驳回 `auditWithdrawal(id,'rejected',reason)`。
+
+- [ ] **Step 5: PendingRefundAudit**
+
+`actionMode='cancel'`:仅 `pending`/`processing` 可操作;详情展示类型/订单号/用户/金额/尝试次数/失败原因/创建时间;中止原因写入 `remark`。后端改动:`AdminCommissionController.cancelRefund` 读取 `params.get("reason")`,`PendingRefundService.cancelPendingRefund(Long refundId, String remark)` 改为两参(`remark` 为空时默认 `"管理员中止退款"`),前端 `cancelPendingRefund({refundId, reason})`。
+
+- [ ] **Step 6: HierarchyAudit**
+
+详情:申请人/当前上级/目标上级/申请原因/状态;通过/驳回走 `reviewSupplyHierarchyChange`(补 reason)。
+
+- [ ] **Step 7: 构建验证**
+
+Run: `cd /app/cfc/cfc-web && npx vue-cli-service build --mode production 2>&1 | tail -5`
+Expected: `Build complete`。
+
+- [ ] **Step 8: 提交**
+
+```bash
+cd /app/cfc && git add cfc-web/src/views/admin/review/ProductAudit.vue cfc-web/src/views/admin/review/ActivityAudit.vue cfc-web/src/views/admin/review/ActivityRegistrationAudit.vue cfc-web/src/views/admin/review/WithdrawAudit.vue cfc-web/src/views/admin/review/PendingRefundAudit.vue cfc-web/src/views/admin/review/HierarchyAudit.vue
+git commit -m "refactor(review): 商品/活动/报名/提现/退款/上级审核接入统一详情抽屉"
+```
+
+---
+
+## Task 10: 规划师、营养师审核合并服务角色申请
+
+**Files:**
+- Modify: `GuideAudit.vue`、`NutritionistAudit.vue`
+
+**Interfaces:**
+- Consumes: `listServiceRoleApplications`、`mapServiceRoleRow`、`mergeAuditRows`(Task 7)。
+
+- [ ] **Step 1: GuideAudit**
+
+`loadData()` 并行拉取 `getGuideApplications({status, page:1, size:200})` 与 `listServiceRoleApplications({status:0, role:'planner'})`;合并后渲染统一列(申请人/类型/来源/状态/提交时间/操作);审核按 `source` 路由:service_role → `approveServiceRoleApplication(applyId)`/`rejectServiceRoleApplication(applyId, reason)`,否则原 `approveGuide/rejectGuide`;详情抽屉展示 §6.1 或 §6.2 字段。
+
+- [ ] **Step 2: NutritionistAudit**
+
+同 GuideAudit,角色 `nutritionist`,审核接口 `approveNutritionist/rejectNutritionist`。
+
+- [ ] **Step 3: 构建验证**
+
+Run: `cd /app/cfc/cfc-web && npx vue-cli-service build --mode production 2>&1 | tail -5`
+Expected: `Build complete`。
+
+- [ ] **Step 4: 提交**
+
+```bash
+cd /app/cfc && git add cfc-web/src/views/admin/review/GuideAudit.vue cfc-web/src/views/admin/review/NutritionistAudit.vue
+git commit -m "feat(review): 规划师/营养师审核合并服务角色申请"
+```
+
+---
+
+## Task 11: 服务商审核合并服务角色申请
+
+**Files:**
+- Modify: `VendorAudit.vue`
+
+- [ ] **Step 1: 改造**
+
+`loadData()` 并行拉取 `getVendorList({status, page:1, size:200})` 与 `listServiceRoleApplications({status:0, role:'vendor,activity_provider'})`;合并去重;列表增加「类型」列(入驻/活动提供商/服务商);详情抽屉展示入驻申请人信息或服务角色材料;审核按 `source` 路由到 `reviewVendor` 或 `approveServiceRoleApplication/rejectServiceRoleApplication`。
+
+- [ ] **Step 2: 构建验证**
+
+Run: `cd /app/cfc/cfc-web && npx vue-cli-service build --mode production 2>&1 | tail -5`
+Expected: `Build complete`。
+
+- [ ] **Step 3: 提交**
+
+```bash
+cd /app/cfc && git add cfc-web/src/views/admin/review/VendorAudit.vue
+git commit -m "feat(review): 服务商审核合并活动提供商/服务商服务角色申请"
+```
+
+---
+
+## Task 12: 管家审核数据源修复 + 合并
+
+**Files:**
+- Modify: `ButlerAudit.vue`
+
+- [ ] **Step 1: 改数据源**
+
+列表改为 `users.butler_status=pending` 的申请。后端新增接口 `POST /api/admin/butler/user-list`(`AdminButlerController`):入参 `{status, page, size}`,查 `users.butler_status=status`,返回 `{list, total}`,每行含 `userId/nickname/realName/phone/butlerStatus/butlerInfo/updatedAt`。详情解析 `butler_info`(tier/description);同时合并 `listServiceRoleApplications({status:0, role:'butler'})`。
+
+- [ ] **Step 2: 审核路由**
+
+`source='user'` → `reviewButler({userId, action})`;`service_role` → `approveServiceRoleApplication` / `rejectServiceRoleApplication`。保留接单状态开关(仅已通过记录显示)。
+
+- [ ] **Step 3: 构建验证**
+
+Run: `cd /app/cfc/cfc-web && npx vue-cli-service build --mode production 2>&1 | tail -5`
+Expected: `Build complete`。
+
+- [ ] **Step 4: 提交**
+
+```bash
+cd /app/cfc && git add cfc-web/src/views/admin/review/ButlerAudit.vue cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminButlerController.java
+git commit -m "fix(review): 管家审核改查 users.butler_status 并合并服务角色申请"
+```
+
+---
+
+## Task 13: 新增知识管理员审核 Tab
+
+**Files:**
+- Create: `cfc-web/src/views/admin/review/KnowledgeAdminAudit.vue`
+- Modify: `cfc-web/src/views/admin/ReviewCenter.vue`
+
+- [ ] **Step 1: 新建组件**
+
+列表只拉 `listServiceRoleApplications({status, role:'article_admin'})`;列:申请人/申请角色/状态/提交时间/操作;详情抽屉展示 §6.1 全部字段;通过/驳回走服务角色接口。
+
+- [ ] **Step 2: 注册到审核中心**
+
+`ReviewCenter.vue` 增加 `el-tab-pane label="知识管理员审核" name="knowledge"`、组件 import/注册、`summaryCards` 加 `{key:'knowledge', label:'知识管理员审核'}`、`keyMap.knowledge='knowledgePending'`。
+
+- [ ] **Step 3: 构建验证**
+
+Run: `cd /app/cfc/cfc-web && npx vue-cli-service build --mode production 2>&1 | tail -5`
+Expected: `Build complete`。
+
+- [ ] **Step 4: 提交**
+
+```bash
+cd /app/cfc && git add cfc-web/src/views/admin/review/KnowledgeAdminAudit.vue cfc-web/src/views/admin/ReviewCenter.vue
+git commit -m "feat(review): 新增知识管理员审核 Tab"
+```
+
+---
+
+## Task 14: 删除独立菜单与旧页面
+
+**Files:**
+- Modify: `cfc-web/src/views/Layout.vue`
+- Modify: `cfc-web/src/router/index.js`
+- Delete: `cfc-web/src/views/service/ServiceRoleApplications.vue`
+
+- [ ] **Step 1: Layout 删除菜单项**
+
+删除 `{ path: '/service-role-applications', label: '服务角色申请审核', ... }`。
+
+- [ ] **Step 2: 路由重定向**
+
+把 `path: 'service-role-applications'` 的组件改为重定向:保留路由名但 `redirect: '/review-center?tab=vendor'`(或直接删除该路由并加 `{path:'/service-role-applications', redirect:'/review-center?tab=vendor'}`)。
+
+- [ ] **Step 3: 删除旧页面**
+
+删除 `cfc-web/src/views/service/ServiceRoleApplications.vue`,全局搜索确认无引用(`grep -rn 'ServiceRoleApplications' cfc-web/src`)。
+
+- [ ] **Step 4: 构建验证**
+
+Run: `cd /app/cfc/cfc-web && npx vue-cli-service build --mode production 2>&1 | tail -5`
+Expected: `Build complete`。
+
+- [ ] **Step 5: 提交**
+
+```bash
+cd /app/cfc && git add -A cfc-web/src/views/Layout.vue cfc-web/src/router/index.js cfc-web/src/views/service/ServiceRoleApplications.vue
+git commit -m "refactor(review): 删除服务角色申请独立菜单并重定向到审核中心"
+```
+
+---
+
+## Task 15: 部署与验证
+
+**Files:** 无代码改动。
+
+- [ ] **Step 1: 备份**
+
+```bash
+ssh root@111.228.6.214 "tar -czf /app/pro/cfc_backup_review_$(date +%Y%m%d_%H%M%S).tar.gz -C /app pro/cfc"
+```
+
+- [ ] **Step 2: 后端打包部署**
+
+```bash
+ssh root@111.228.6.214 "cd /app/cfc/cfc-backend && mvn clean package -Dmaven.test.skip=true -Dmaven.compile.fork=true && cp target/cfc-backend-1.0.0.jar /app/pro/cfc/backend/cfc-backend-review-$(date +%Y%m%d).jar && cp target/cfc-backend-1.0.0.jar /app/pro/cfc/backend/cfc-backend-1.0.0.jar && systemctl restart cfc-backend"
+```
+
+- [ ] **Step 3: 后端健康检查**
+
+```bash
+ssh root@111.228.6.214 "sleep 20; systemctl is-active cfc-backend; ss -tlnp | grep 9082; grep $(systemctl show -p MainPID --value cfc-backend) /app/pro/cfc/backend/cfc-backend.log | grep -c ERROR; curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost:9082/api/vendor/status"
+```
+Expected: `active`、监听 9082、ERROR 数 0、HTTP 200/401。
+
+- [ ] **Step 4: 迁移验证**
+
+执行 Task 4 Step 3 的 SQL,确认列存在、默认值 NULL、pending 数量 1、菜单 visible=0。
+
+- [ ] **Step 5: 前端构建部署**
+
+```bash
+ssh root@111.228.6.214 "cd /app/cfc/cfc-web && npm run build && rsync -a --delete dist/ /app/pro/cfc/frontend/"
+```
+
+- [ ] **Step 6: 管理端冒烟**
+
+```bash
+ssh root@111.228.6.214 "curl -s -o /dev/null -w '%{http_code}' -k https://cfc.etotem.com.cn/admin/"
+```
+Expected: 200。再用 admin 账号打开 `/review-center` 逐 Tab 检查:详情抽屉可打开、驳回空意见被拦截、知识管理员 Tab 存在、服务角色申请出现在服务商审核。
+
+- [ ] **Step 7: 服务角色端到端**
+
+用 user12 的 `activity_provider` 申请在「服务商审核」中通过,然后验证:
+
+```sql
+SELECT activity_provider_status, roles FROM users WHERE id=12;
+```
+Expected: `approved`,`roles` 含 `activity_manager`。
+
+- [ ] **Step 8: git 收尾**
+
+```bash
+ssh root@111.228.6.214 "cd /app/cfc && git add -A && git commit -m 'chore: build artifacts' 2>/dev/null; git push origin cfclub"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:** §2 目标 1(12 Tab 抽屉)→ Task 6/8/9;目标 2(分流合并)→ Task 1/10/11/12/13;目标 3(approve 修复)→ Task 2;目标 4(假数据)→ Task 4;§5.1 待退款例外 → Task 9 Step 5;§5.4 菜单 → Task 4/14;§7.1 → Task 2;§7.2 → Task 1;§7.3 → Task 3;§7.5 → Task 4/5;验证计划 → Task 15。
+
+**Type consistency:** `applyApprovedRole(String, ServiceRoleApplication, User, List<String>)` 在 Task 2 定义并在测试中使用;`listApplicationsWithUserInfo(Integer, List<String>)` 在 Task 1 定义,前端 Task 10/11/12/13 通过 HTTP 消费;`mergeAuditRows(userRows, serviceRoleRows)` 在 Task 7 定义并在 Task 10/11 使用;`AuditDetailDrawer` 的 `actionMode` 在 Task 6 定义,Task 9 Step 5 使用 `cancel`。
+
+**Review Focus 对应测试:** 多角色 JSON → Task 1 `matchesAnyRole` 测试;重复行 → Task 7 `mergeAuditRows` 测试;空驳回意见 → Task 6 测试;资金动作 → Task 9 Step 5 实现约束(构建验证);已审核只读 → Task 6 `showActions` 实现(构建验证)。

+ 255 - 0
docs/superpowers/specs/2026-10-19-review-center-unified-detail-design.md

@@ -0,0 +1,255 @@
+# 审核中心统一详情页审核 + 服务角色申请分流合并
+
+- 日期:2026-10-19
+- 状态:待批准(已按用户决策更新)
+- 涉及端:cfc-web(管理端)、cfc-backend、生产库迁移
+
+## 1. 背景
+
+当前审核中心总览(`/review-center`)有 12 个 Tab,但交互方式不统一:
+
+- 只有「文章审核」「任务模板审核」是"点开详情 → 在详情里通过/驳回";
+- 其余 10 个 Tab 都是表格行内直接点"通过/拒绝",管理员看不到申请人提交的完整材料;
+- 小程序提交的「服务角色申请」(身份证正反面、资质证书、地区地址,存 `service_role_applications` 表)在独立菜单「服务角色申请审核」里,和审核中心割裂;
+- 审核中心里的「规划师/营养师/管家/服务商审核」查的是 `users` / `butler_profiles` 的审核状态,而小程序提交的申请还在 `service_role_applications` 表里(审核通过后才写 `users`),所以**小程序提交的申请在审核中心完全看不到**。
+
+## 2. 目标
+
+1. 审核中心 12 个 Tab 统一为:列表点开 → 右侧抽屉展示提交的全部信息 → 在抽屉内通过/驳回;**驳回必须填写意见**。
+2. 把「服务角色申请」按角色分流合并进对应 Tab,删除独立菜单:
+   - 服务商 / 活动提供商 → 服务商审核
+   - 规划师 → 规划师审核
+   - 营养师 → 营养师审核
+   - 管家 → 管家审核
+   - 知识管理员 → 新增「知识管理员审核」Tab
+3. 修复服务角色申请审核通过后状态/权限写入不完整的问题(§7.1)。
+4. 清理规划师"假数据"(§7.5)。
+
+## 3. 非目标
+
+- 不改审核权限模型(本次不新增后端角色校验)。
+- 不改小程序端申请页。
+- 不做审核操作日志审计表(沿用现有 `reviewedBy/reviewedAt/rejectReason` 字段)。
+
+## 4. 现状对照
+
+| Tab | 列表数据源 | 状态字段 | 审核接口 | 已有详情 |
+|---|---|---|---|---|
+| 文章审核 | 文章表(draft) | `auditStatus` | `adminArticleAudit` | ✅ 弹窗 |
+| 商品审核 | 商品表 | `status` | `reviewProduct` | ❌ |
+| 规划师审核 | `users.teacher_status` | `teacherStatus` | `approveGuide/rejectGuide` | ❌ |
+| 营养师审核 | `users.nutritionist_status` | `nutritionistStatus` | `approveNutritionist/rejectNutritionist` | ❌ |
+| 服务商审核 | `users.vendor_status` | `vendorStatus` | `reviewVendor` | ❌ |
+| 管家审核 | 现查 `butler_profiles`(0 行,**坏**) | `status` | `reviewButler` | ❌ |
+| 任务模板审核 | 任务模板表 | `status` | 模板审核接口 | ✅ 弹窗 |
+| 活动审核 | 活动表 | `auditStatus` | `auditActivity` | ❌ |
+| 活动报名审核 | 报名表 | `status` | `approveRegistration/rejectRegistration` | ❌ |
+| 提现审核 | 提现表 | `status` | `auditWithdrawal` | ❌ |
+| 待退款审核 | `pending_refund` | `status` | `cancelPendingRefund` | ❌ |
+| 更换上级审核 | 上级变更表 | `status` | `reviewSupplyHierarchyChange` | ❌ |
+| **服务角色申请**(将被合并) | `service_role_applications` | `status` 0/1/2 | `/api/service-role/admin/approve|reject` | 部分 |
+
+## 5. 方案
+
+### 5.1 统一详情抽屉
+
+新建 `cfc-web/src/views/admin/review/components/AuditDetailDrawer.vue`:
+
+- 右侧 `el-drawer`,宽度 720px,`destroy-on-close`。
+- 结构:标题(类型 + 申请人)→ 状态标签 → 详情内容(slot)→ 已审核时展示驳回原因 → 底部固定操作栏。
+- Props:`visible`、`title`、`status`、`loading`、`showActions`(仅待审核为 true)、`actionMode`(`approve-reject` 默认 / `cancel` 用于待退款)。
+- Events:`update:visible`、`approve`、`reject`、`cancel`。
+- 驳回交互:点「驳回」在抽屉底部展开意见输入框,**trim 后为空不允许提交**;确认后触发 `reject(reason)`。
+- 通过/驳回均需二次确认(`$confirm`),提交中按钮 loading 防重复。
+- **待退款 Tab 例外**:不套通过/驳回,底部只保留「中止退款」按钮(`actionMode='cancel'`),仅 `pending`/`processing` 可操作,终止原因必填,写入 `pending_refund.remark`。
+
+每个 Tab:
+- 操作列改为一个 **「详情 / 审核」** 按钮(待审核显示「审核」,已审核显示「详情」),点击打开抽屉。
+- 已审核记录只读展示,不显示通过/驳回。
+- 原表格列保留(便于快速浏览),抽屉展示完整材料。
+
+### 5.2 服务角色申请分流合并(B 方案)
+
+**后端** `ServiceRoleApplicationController.adminList` 增加参数:
+
+- `role`(可选):按申请角色过滤,逗号分隔 OR;`vendor` 查询同时匹配 `vendor` 与 `activity_provider`。
+- `status`(可选):0/1/2,默认全部(列表筛选用)。
+- 返回体补充 `userName`、`realName`、`phone`(关联 `users` 批量填充)。
+
+**前端** 每个合并 Tab 同时加载两个数据源并合并:
+
+| Tab | 数据源 A | 数据源 B(service_role) |
+|---|---|---|
+| 规划师审核 | `users.teacher_status=pending` | `role=planner` |
+| 营养师审核 | `users.nutritionist_status=pending` | `role=nutritionist` |
+| 服务商审核 | `users.vendor_status=pending` | `role=vendor,activity_provider` |
+| 管家审核 | `users.butler_status=pending`(**修复**,解析 `users.butler_info`) | `role=butler` |
+| 知识管理员审核(新) | 无 | `role=article_admin` |
+
+统一行结构(前端映射):
+
+```js
+{
+  source: 'user' | 'butler' | 'service_role',
+  applyId: Number|null,
+  userId: Number,
+  nickname: String,
+  realName: String,
+  phone: String,
+  roleType: String,
+  roleLabel: String,
+  status: 'pending'|'approved'|'rejected',
+  submittedAt: String,
+  raw: Object
+}
+```
+
+- **去重**:同一 `userId` 同时存在 A、B 两类记录时,优先展示 `service_role` 行(带「服务角色申请」来源标签),避免同一人出现两行。
+- **多角色申请**:一条申请含多个角色时,在每个对应 Tab 各显示一次(角色标签不同);审核一次授予全部角色(沿用现有 `approve(id)` 行为)。
+- **分页**:A、B 都以 `size=200` 全量拉取,前端合并、按 `submittedAt` 倒序、前端分页(审核申请量小)。筛选条件变化时重新拉取。
+- **历史记录**:状态筛选「全部/待审核/已通过/已驳回」对 B 源同样生效,删除独立菜单后仍可查历史。
+
+**审核动作路由**:
+
+- `source === 'service_role'` → `/api/service-role/admin/approve`(`{id}`)/ `reject`(`{id, reason}`)
+- `source === 'user'` → 原 Tab 接口(guide/nutritionist/vendor)
+- `source === 'butler'` → `/api/admin/butler/review`
+
+**统计卡片**(`ReviewCenterController.summary`):
+
+- `guidePending` = users(teacher_status=pending, real_name 非空) + service_role(planner, status=0)
+- `nutritionistPending` + service_role(nutritionist, status=0)
+- `vendorPending` + service_role(vendor|activity_provider, status=0)
+- `butlerPending` = users(butler_status=pending) + service_role(butler, status=0)
+- 新增 `knowledgePending` = service_role(article_admin, status=0)
+
+### 5.3 新增「知识管理员审核」Tab
+
+- `ReviewCenter.vue` 增加 Tab + 统计卡片。
+- 详情展示服务角色申请的完整材料。
+- 通过 → `/api/service-role/admin/approve`;驳回 → `/api/service-role/admin/reject`。
+
+### 5.4 删除独立菜单
+
+- 生产 `sys_menu` id=14(`/service-role-applications`)迁移为 `visible=0`(保留可回滚),并同步删除 `sys_role_menu` 中该菜单授权。
+- `DatabaseInitializer.seedSysMenus()` 移除 `serviceRoleApp` 条目,避免新环境重建。
+- `Layout.vue` 兜底菜单删除「服务角色申请审核」。
+- 路由 `/service-role-applications` 重定向到 `/review-center?tab=vendor`。
+- 删除 `cfc-web/src/views/service/ServiceRoleApplications.vue`(不再引用)。
+
+## 6. 各 Tab 详情字段
+
+### 6.1 服务角色申请(规划师/营养师/服务商/活动提供商/管家/知识管理员)
+
+- 申请角色(多标签)
+- 昵称 / 真实姓名 / 手机号
+- 身份证号
+- 身份证正反面图片(`el-image` + 大图预览)
+- 资质证书图片(多图预览)
+- 所在地区(省市区街道 + 详细地址)
+- 申请时间
+- 审核状态 / 驳回原因(已审核时)
+
+### 6.2 嵌入 users 表的申请(规划师/营养师/服务商)
+
+- 昵称、真实姓名、手机号、申请角色、状态、提交/更新时间
+- 服务商额外:服务商类型(`vendorTypeLabel`)
+
+### 6.3 管家
+
+- 数据源改为 `users.butler_status`,详情从 `users.butler_info` 解析:
+- 用户、等级(tier)、申请说明(description)、状态、接单状态
+
+### 6.4 文章
+
+- 标题、分类、作者、状态、创建/发布时间、摘要、正文(HTML 渲染)、驳回原因
+
+### 6.5 商品
+
+- 商品名称、图片、价格、库存、分类、供应商、描述、驳回原因
+
+### 6.6 任务模板
+
+- 名称、周期、价格、状态、详细介绍、驳回原因
+
+### 6.7 活动
+
+- 活动名称、封面、开始/结束时间、地点、主办方、活动内容、状态、驳回原因
+
+### 6.8 活动报名
+
+- 报名用户、活动名称、报名时间、状态、驳回原因(新增列)
+
+### 6.9 提现
+
+- 用户、金额、账户信息、申请时间、状态、驳回原因(新增列)
+
+### 6.10 待退款
+
+- 类型、订单号、提现申请 ID、用户、退款金额、实退金额、状态、尝试次数、失败原因、创建时间、中止原因(`remark`)
+
+### 6.11 更换上级
+
+- 申请人、当前上级、目标上级、申请原因、状态
+
+## 7. 后端改动
+
+### 7.1 修复 `ServiceRoleApplicationServiceImpl.approve`
+
+- `planner` 分支:补 `user.setTeacherStatus("approved")`;复用 `approveGuide` 的逻辑同步创建/更新 `admins` 记录,保证规划师可登录管理端。
+- `butler` 分支:插入/更新 `butler_profiles` 时状态统一为 `approved`(与其他业务查询一致),按 `user_id` 幂等(唯一键)。
+- `user` 为 null 时返回错误,避免 NPE。
+- 保持 `@Transactional`。
+
+### 7.2 `ServiceRoleApplicationController`
+
+- `admin/list` 支持 `role`、`status` 过滤。
+- 返回体补 `userName`、`realName`、`phone`。
+
+### 7.3 `ReviewCenterController.summary`
+
+- 按 §5.2 合并服务角色申请计数,新增 `knowledgePending`。
+
+### 7.4 其他审核接口
+
+- 不改接口签名;活动报名 / 提现驳回接口增加可选 `reason` 参数并落库(见 §7.5)。
+
+### 7.5 数据库迁移(幂等,`DatabaseInitializer`)
+
+1. `activity_registrations` 增加 `reject_reason VARCHAR(500) NULL`。
+2. `withdrawal_requests` 增加 `reject_reason VARCHAR(500) NULL`。
+3. **规划师假数据清理**:
+   - `UPDATE users SET teacher_status = NULL WHERE teacher_status = 'pending' AND (role IS NULL OR role <> 'teacher')`(清理 103 条默认值假数据,保留 id=2 role=teacher 测试账号)。
+   - `ALTER TABLE users MODIFY teacher_status VARCHAR(20) DEFAULT NULL`(新用户不再默认 pending)。
+   - 同步修改 `DatabaseInitializer` 中 `ADD COLUMN teacher_status ... DEFAULT 'pending'` 为 `DEFAULT NULL`。
+   - 规划师列表与统计统一口径:`teacher_status='pending' AND real_name IS NOT NULL`(清理后与统计一致)。
+
+## 8. 交互细节
+
+- 抽屉打开期间禁止列表滚动(el-drawer 默认行为)。
+- 通过/驳回成功后:关闭抽屉、刷新列表、刷新统计卡片。
+- 驳回意见:必填,trim 后为空不允许提交。
+- 已审核记录:抽屉只读,展示审核人/审核时间/驳回原因。
+- 批量操作(文章、任务模板):保留现有批量通过/批量驳回;批量驳回也必须填意见。
+- 错误提示:接口失败用 `$message.error`,不关闭抽屉。
+
+## 9. 兼容与回滚
+
+- 旧路由 `/service-role-applications` 重定向,避免书签失效。
+- 后端接口均为增量参数/字段,向后兼容。
+- 数据库迁移幂等;假数据清理只影响 `teacher_status='pending'` 且非 teacher 角色的记录。
+- 部署前备份 `/app/pro/cfc`;后端 jar 保留上一版;管理端 `dist` 由 git + rsync 部署,可回滚。
+
+## 10. 验证计划
+
+1. 12 个 Tab 逐一打开详情抽屉,确认材料完整、通过/驳回可用、驳回意见必填。
+2. 待退款 Tab:确认只有「中止退款」,处理中状态可操作,原因写入 `remark`。
+3. 服务角色分流:user12 的 activity_provider 申请出现在「服务商审核」,通过后:
+   - `users.activity_provider_status=approved`
+   - `users.roles` 含 `activity_manager`
+   - 小程序活动中心出现「管理」Tab
+4. 规划师申请通过后:`users.teacher_status=approved`、`admins` 记录存在、可登录管理端。
+5. 规划师假数据清理后:列表、统计卡片均为真实数量(1 条测试账号),无 104 条虚高。
+6. 驳回:意见写入 `reject_reason`,申请状态变为已驳回,抽屉可回看。
+7. 统计卡片数量与实际待审核条数一致。
+8. 回归:原有 12 Tab 的列表加载、筛选、分页正常。