فهرست منبع

docs: superpowers PROJECT-OVERVIEW and service-role-apply design + plan

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Xiaogang Liao 3 ماه پیش
والد
کامیت
fadd674a05

+ 4 - 2
docs/superpowers/PROJECT-OVERVIEW.md

@@ -1,7 +1,7 @@
 # 浠艾福 项目全景 — 阶段性需求与设计汇总
 
-**文档版本:** v2.1
-**日期:** 2026-07-28
+**文档版本:** v2.2
+**日期:** 2026-07-30
 **状态:** 已确认(v2.1 Phase 2-4 全栈完成)
 **维护:** 所有需求变更需更新本文档
 
@@ -311,6 +311,7 @@
 | `2026-07-12-dimension-features-energy-map.md` | ✅ 已实施(设计参考,能量映射由EnergySourceConfig配置驱动) | 维度功能能量映射 |
 | `2026-07-22-active-health-six-stage-design.md` | ✅ 已实施(P0-P3 全栈完成) | 主动健康六阶模型改进总设计 |
 | `2026-07-10-certainty-ppt-design.md` | 🔄 已废弃(一次性PPT设计,无代码实现) | 确定性叙事PPT |
+| `2026-07-30-service-role-apply-design.md` | 🆕 设计待实现 | 服务角色统一申请(规划师/营养师/管家/服务商/文章管理员/活动提供商) |
 
 ### 实施计划(plans/)
 
@@ -372,6 +373,7 @@
 | `2026-07-25-e2e-test-rearchitecture-design.md` | ✅ Phase 1 complete | E2E 测试体系重构设计 |
 | `2026-07-25-e2e-test-execution-summary.md` | ✅ Phase 1 complete | E2E 测试执行总览 |
 | `2026-07-24-cfc-web-responsive-design.md` | ✅ 已实施 | 管理后台手机自适应设计 |
+| `2026-07-30-service-role-apply-design.md` | 🆕 设计待实现 | 服务角色统一申请设计 |
 | `2026-07-22-active-health-six-stage-design.md` | 🟡 当前 | 主动健康六阶模型改进总设计 |
 
 ---

+ 1367 - 0
docs/superpowers/plans/2026-07-30-service-role-apply.md

@@ -0,0 +1,1367 @@
+# 服务角色统一申请 — 实施计划
+
+> **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:** 在"我的"页面增加统一的服务角色申请入口,支持多选申请规划师/营养师/管家/服务商/文章管理员/活动提供商,上传身份证和资质证书,填写联系地址,提交后台审核。
+
+**Architecture:** 新建统一 `service_role_applications` 表,一张表涵盖所有角色申请。前端统一申请页一次性提交,后端审核通过后按角色分别同步到现有各角色系统(Guide/User.butlerStatus/User.nutritionistStatus/User.vendorStatus 等)。
+
+**Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus / uni-app Vue 2 小程序 / Vue 2 + Element UI Web管理端
+
+## Global Constraints
+
+- 所有接口使用 `@PostMapping`,禁止 `@GetMapping/@PutMapping/@DeleteMapping`
+- 响应统一使用 `Result<T>` (code/message/data)
+- DI 使用 `@Resource` 代替 `@Autowired`
+- 小程序禁止可选链 `?.`(用 `&&` 替代)、禁止 CSS Grid(用 flexbox)
+- DI 字段名必须与类型默认 Bean Name 一致
+- 新表名使用 `service_role_applications`
+- 迁移编号从 111 开始(当前最新迁移 110)
+- 所有迁移必须幂等(try-catch 忽略已存在)
+
+---
+
+## 文件结构
+
+### 新建文件
+
+| 文件 | 用途 |
+|------|------|
+| `cfc-backend/.../entity/ServiceRoleApplication.java` | 实体类 |
+| `cfc-backend/.../mapper/ServiceRoleApplicationMapper.java` | Mapper 接口 |
+| `cfc-backend/.../dto/ServiceRoleApplyDTO.java` | 申请提交 DTO |
+| `cfc-backend/.../service/ServiceRoleApplicationService.java` | Service 接口 |
+| `cfc-backend/.../service/impl/ServiceRoleApplicationServiceImpl.java` | Service 实现 |
+| `cfc-backend/.../controller/ServiceRoleApplicationController.java` | Controller |
+| `cfc-frontend/pages/profile/service-role-apply.vue` | 统一申请页 |
+| `cfc-web/src/views/service/ServiceRoleApplications.vue` | 管理端审核页 |
+
+### 修改文件
+
+| 文件 | 修改内容 |
+|------|----------|
+| `cfc-backend/.../entity/User.java` | 添加 `articleAdminStatus`、`activityProviderStatus` 字段 |
+| `cfc-backend/.../config/DatabaseInitializer.java` | 添加迁移 111-113 |
+| `cfc-backend/src/main/resources/schema.sql` | 添加 `service_role_applications` 表 + User 表新字段 |
+| `cfc-frontend/pages/profile/components/ProfileMenu.vue` | 添加"服务角色申请"菜单项 |
+| `cfc-frontend/utils/api.js` | 新增 API 函数 |
+| `cfc-frontend/pages.json` | 注册新页面到 profile 子包 |
+| `cfc-web/src/router/index.js` | 添加审核页面路由 |
+
+---
+
+### Task 1: 数据库迁移 — 建表 & User 加字段
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` (追加迁移 111-113)
+- Modify: `cfc-backend/src/main/resources/schema.sql`
+
+**Interfaces:**
+- Consumes: (none)
+- Produces: `service_role_applications` 表存在,User 表有 `article_admin_status` 和 `activity_provider_status` 列
+
+- [ ] **Step 1: 在 schema.sql 追加新表定义**
+
+在 `schema.sql` 末尾追加:
+
+```sql
+-- ============================================================
+-- 服务角色申请表
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `service_role_applications` (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `user_id` int(11) NOT NULL COMMENT '申请人ID',
+  `roles` varchar(255) NOT NULL COMMENT '申请角色,JSON数组 e.g. ["planner","nutritionist"]',
+  `id_card` varchar(18) DEFAULT '' COMMENT '身份证号',
+  `id_card_images` text COMMENT '身份证照片,JSON数组[正面URL,反面URL]',
+  `certificate_images` text COMMENT '资质证书照片,JSON数组',
+  `province` varchar(50) DEFAULT '' COMMENT '省',
+  `city` varchar(50) DEFAULT '' COMMENT '市',
+  `district` varchar(50) DEFAULT '' COMMENT '区',
+  `street` varchar(50) DEFAULT '' COMMENT '街道',
+  `address_detail` varchar(200) DEFAULT '' COMMENT '详细地址',
+  `status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '0-pending, 1-approved, 2-rejected',
+  `reject_reason` varchar(500) DEFAULT '' COMMENT '驳回原因',
+  `reviewed_by` int(11) DEFAULT NULL COMMENT '审核人ID',
+  `reviewed_at` datetime DEFAULT NULL COMMENT '审核时间',
+  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_user_id` (`user_id`),
+  KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务角色申请表';
+```
+
+在 `users` 表的 `CREATE TABLE` 语句中(或表定义注释区)添加字段注释。schema.sql 中 users 表定义在 `-- nutritionist_status` 注释附近追加:
+
+```sql
+-- ALTER TABLE users ADD COLUMN article_admin_status VARCHAR(20) DEFAULT NULL COMMENT '文章管理员状态: pending/approved/rejected';
+-- ALTER TABLE users ADD COLUMN activity_provider_status VARCHAR(20) DEFAULT NULL COMMENT '活动提供商状态: pending/approved/rejected';
+```
+
+- [ ] **Step 2: 在 DatabaseInitializer 添加迁移 111-113**
+
+在 `runMigrations()` 方法末尾(最后一个 `// 迁移` 之后)追加:
+
+```java
+// 迁移111: 创建 service_role_applications 表(服务角色统一申请)
+try {
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS service_role_applications (" +
+            "id int(11) NOT NULL AUTO_INCREMENT, " +
+            "user_id int(11) NOT NULL COMMENT '申请人ID', " +
+            "roles varchar(255) NOT NULL COMMENT '申请角色,JSON数组', " +
+            "id_card varchar(18) DEFAULT '' COMMENT '身份证号', " +
+            "id_card_images text COMMENT '身份证照片,JSON数组', " +
+            "certificate_images text COMMENT '资质证书照片,JSON数组', " +
+            "province varchar(50) DEFAULT '' COMMENT '省', " +
+            "city varchar(50) DEFAULT '' COMMENT '市', " +
+            "district varchar(50) DEFAULT '' COMMENT '区', " +
+            "street varchar(50) DEFAULT '' COMMENT '街道', " +
+            "address_detail varchar(200) DEFAULT '' COMMENT '详细地址', " +
+            "status tinyint(4) NOT NULL DEFAULT 0 COMMENT '0-pending 1-approved 2-rejected', " +
+            "reject_reason varchar(500) DEFAULT '' COMMENT '驳回原因', " +
+            "reviewed_by int(11) DEFAULT NULL COMMENT '审核人ID', " +
+            "reviewed_at datetime DEFAULT NULL COMMENT '审核时间', " +
+            "created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
+            "updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+            "PRIMARY KEY (id), " +
+            "KEY idx_user_id (user_id), " +
+            "KEY idx_status (status)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务角色申请表'");
+    log.info("已创建service_role_applications表");
+} catch (Exception e) {
+    // 表已存在,忽略错误
+}
+
+// 迁移112: users表添加article_admin_status字段(文章管理员状态)
+try {
+    jdbcTemplate.execute("ALTER TABLE users ADD COLUMN article_admin_status VARCHAR(20) DEFAULT NULL COMMENT '文章管理员状态: pending/approved/rejected'");
+    log.info("已添加article_admin_status列到users表");
+} catch (Exception e) {
+    // 列已存在,忽略错误
+}
+
+// 迁移113: users表添加activity_provider_status字段(活动提供商状态)
+try {
+    jdbcTemplate.execute("ALTER TABLE users ADD COLUMN activity_provider_status VARCHAR(20) DEFAULT NULL COMMENT '活动提供商状态: pending/approved/rejected'");
+    log.info("已添加activity_provider_status列到users表");
+} catch (Exception e) {
+    // 列已存在,忽略错误
+}
+```
+
+- [ ] **Step 3: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+Expected: BUILD SUCCESS
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add cfc-backend/src/main/resources/schema.sql cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
+git commit -m "feat(db): add service_role_applications table and User status fields"
+```
+
+---
+
+### Task 2: 后端 Entity + Mapper + DTO
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/ServiceRoleApplication.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/ServiceRoleApplicationMapper.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/dto/ServiceRoleApplyDTO.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/entity/User.java`
+
+**Interfaces:**
+- Consumes: `service_role_applications` 表 (Task 1)
+- Produces: `ServiceRoleApplication` entity, `ServiceRoleApplicationMapper`, `ServiceRoleApplyDTO`, updated `User` entity
+
+- [ ] **Step 1: 创建 ServiceRoleApplication 实体**
+
+`cfc-backend/src/main/java/com/etotem/cfc/entity/ServiceRoleApplication.java`:
+
+```java
+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.time.LocalDateTime;
+
+@Data
+@TableName("service_role_applications")
+public class ServiceRoleApplication {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long userId;
+    private String roles;
+    private String idCard;
+    private String idCardImages;
+    private String certificateImages;
+    private String province;
+    private String city;
+    private String district;
+    private String street;
+    private String addressDetail;
+    private Integer status;
+    private String rejectReason;
+    private Long reviewedBy;
+    private LocalDateTime reviewedAt;
+    private LocalDateTime createdAt;
+    private LocalDateTime updatedAt;
+}
+```
+
+- [ ] **Step 2: 创建 Mapper 接口**
+
+`cfc-backend/src/main/java/com/etotem/cfc/mapper/ServiceRoleApplicationMapper.java`:
+
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ServiceRoleApplication;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ServiceRoleApplicationMapper extends BaseMapper<ServiceRoleApplication> {
+}
+```
+
+- [ ] **Step 3: 创建 DTO**
+
+`cfc-backend/src/main/java/com/etotem/cfc/dto/ServiceRoleApplyDTO.java`:
+
+```java
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import javax.validation.constraints.NotEmpty;
+import java.util.List;
+
+@Data
+public class ServiceRoleApplyDTO {
+    @NotEmpty(message = "至少选择一个角色")
+    private List<String> roles;
+    private String idCard;
+    private List<String> idCardImages;
+    private List<String> certificateImages;
+    private String province;
+    private String city;
+    private String district;
+    private String street;
+    private String addressDetail;
+}
+```
+
+- [ ] **Step 4: 修改 User 实体添加新字段**
+
+在 `User.java` 中添加两个新字段(放在 `nutritionistStatus` 附近):
+
+```java
+/** 文章管理员状态: pending/approved/rejected */
+private String articleAdminStatus;
+
+/** 活动提供商状态: pending/approved/rejected */
+private String activityProviderStatus;
+```
+
+- [ ] **Step 5: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+Expected: BUILD SUCCESS
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/ServiceRoleApplication.java \
+       cfc-backend/src/main/java/com/etotem/cfc/mapper/ServiceRoleApplicationMapper.java \
+       cfc-backend/src/main/java/com/etotem/cfc/dto/ServiceRoleApplyDTO.java \
+       cfc-backend/src/main/java/com/etotem/cfc/entity/User.java
+git commit -m "feat(backend): add ServiceRoleApplication entity, mapper, DTO + User fields"
+```
+
+---
+
+### Task 3: 后端 Service 接口 + 实现
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/ServiceRoleApplicationService.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/impl/ServiceRoleApplicationServiceImpl.java`
+
+**Interfaces:**
+- Consumes: `ServiceRoleApplication` entity, `ServiceRoleApplicationMapper`, `ServiceRoleApplyDTO` (Task 2)
+- Produces: `ServiceRoleApplicationService` with `submit()`, `getMyApplication()`, `approve()`, `reject()` methods
+
+- [ ] **Step 1: 创建 Service 接口**
+
+`cfc-backend/src/main/java/com/etotem/cfc/service/ServiceRoleApplicationService.java`:
+
+```java
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.ServiceRoleApplyDTO;
+
+public interface ServiceRoleApplicationService {
+    Result submit(Long userId, ServiceRoleApplyDTO dto);
+    Result getMyApplication(Long userId);
+    Result approve(Long id, Long reviewerId);
+    Result reject(Long id, String reason, Long reviewerId);
+}
+```
+
+- [ ] **Step 2: 创建 Service 实现**
+
+`cfc-backend/src/main/java/com/etotem/cfc/service/impl/ServiceRoleApplicationServiceImpl.java`:
+
+```java
+package com.etotem.cfc.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.ServiceRoleApplyDTO;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import com.etotem.cfc.service.ServiceRoleApplicationService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Service
+public class ServiceRoleApplicationServiceImpl
+        extends ServiceImpl<ServiceRoleApplicationMapper, ServiceRoleApplication>
+        implements ServiceRoleApplicationService {
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private GuideMapper guideMapper;
+
+    @Resource
+    private ButlerProfileMapper butlerProfileMapper;
+
+    @Override
+    public Result submit(Long userId, ServiceRoleApplyDTO dto) {
+        // 检查是否已有 pending 申请
+        ServiceRoleApplication existing = this.getOne(
+                new LambdaQueryWrapper<ServiceRoleApplication>()
+                        .eq(ServiceRoleApplication::getUserId, userId)
+                        .eq(ServiceRoleApplication::getStatus, 0));
+        if (existing != null) {
+            return Result.error("您已提交过申请,请等待审核");
+        }
+
+        ServiceRoleApplication app = new ServiceRoleApplication();
+        app.setUserId(userId);
+        app.setRoles(JSON.toJSONString(dto.getRoles()));
+        app.setIdCard(dto.getIdCard());
+        app.setIdCardImages(dto.getIdCardImages() != null ? JSON.toJSONString(dto.getIdCardImages()) : null);
+        app.setCertificateImages(dto.getCertificateImages() != null ? JSON.toJSONString(dto.getCertificateImages()) : null);
+        app.setProvince(dto.getProvince());
+        app.setCity(dto.getCity());
+        app.setDistrict(dto.getDistrict());
+        app.setStreet(dto.getStreet());
+        app.setAddressDetail(dto.getAddressDetail());
+        app.setStatus(0); // pending
+        this.save(app);
+        return Result.success("申请提交成功,请等待审核");
+    }
+
+    @Override
+    public Result getMyApplication(Long userId) {
+        ServiceRoleApplication app = this.getOne(
+                new LambdaQueryWrapper<ServiceRoleApplication>()
+                        .eq(ServiceRoleApplication::getUserId, userId)
+                        .orderByDesc(ServiceRoleApplication::getCreatedAt)
+                        .last("LIMIT 1"));
+        return Result.success(app);
+    }
+
+    @Override
+    @Transactional
+    public Result approve(Long id, Long reviewerId) {
+        ServiceRoleApplication app = this.getById(id);
+        if (app == null) {
+            return Result.error("申请记录不存在");
+        }
+        if (app.getStatus() != 0) {
+            return Result.error("该申请已处理");
+        }
+
+        app.setStatus(1); // approved
+        app.setReviewedBy(reviewerId);
+        app.setReviewedAt(LocalDateTime.now());
+        this.updateById(app);
+
+        // 同步各角色状态
+        List<String> roles = JSON.parseArray(app.getRoles(), String.class);
+        User user = userMapper.selectById(app.getUserId());
+
+        for (String role : roles) {
+            switch (role) {
+                case "planner":
+                    // 创建 Guide 记录
+                    Guide guide = new Guide();
+                    guide.setUserId(app.getUserId());
+                    guide.setStatus("active");
+                    guide.setProvince(app.getProvince());
+                    guide.setCity(app.getCity());
+                    guide.setDistrict(app.getDistrict());
+                    guide.setStreet(app.getStreet());
+                    guideMapper.insert(guide);
+                    break;
+                case "nutritionist":
+                    user.setNutritionistStatus("approved");
+                    break;
+                case "butler":
+                    user.setButlerStatus("approved");
+                    // 创建 ButlerProfile
+                    ButlerProfile bp = new ButlerProfile();
+                    bp.setUserId(app.getUserId());
+                    bp.setStatus("active");
+                    butlerProfileMapper.insert(bp);
+                    break;
+                case "vendor":
+                    user.setVendorStatus("approved");
+                    break;
+                case "article_admin":
+                    user.setArticleAdminStatus("approved");
+                    break;
+                case "activity_provider":
+                    user.setActivityProviderStatus("approved");
+                    break;
+            }
+        }
+        userMapper.updateById(user);
+
+        return Result.success("审核通过");
+    }
+
+    @Override
+    @Transactional
+    public Result reject(Long id, String reason, Long reviewerId) {
+        ServiceRoleApplication app = this.getById(id);
+        if (app == null) {
+            return Result.error("申请记录不存在");
+        }
+        if (app.getStatus() != 0) {
+            return Result.error("该申请已处理");
+        }
+
+        app.setStatus(2); // rejected
+        app.setRejectReason(reason);
+        app.setReviewedBy(reviewerId);
+        app.setReviewedAt(LocalDateTime.now());
+        this.updateById(app);
+        return Result.success("已驳回");
+    }
+}
+```
+
+- [ ] **Step 3: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+Expected: BUILD SUCCESS
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ServiceRoleApplicationService.java \
+       cfc-backend/src/main/java/com/etotem/cfc/service/impl/ServiceRoleApplicationServiceImpl.java
+git commit -m "feat(backend): add ServiceRoleApplicationService with submit/approve/reject"
+```
+
+---
+
+### Task 4: 后端 Controller + 图片上传
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/ServiceRoleApplicationController.java`
+
+**Interfaces:**
+- Consumes: `ServiceRoleApplicationService` (Task 3)
+- Produces: `POST /api/service-role/apply`, `POST /api/service-role/my-apply`, `POST /api/service-role/upload-image`
+
+- [ ] **Step 1: 创建 Controller**
+
+`cfc-backend/src/main/java/com/etotem/cfc/controller/ServiceRoleApplicationController.java`:
+
+```java
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.ServiceRoleApplyDTO;
+import com.etotem.cfc.service.ServiceRoleApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Resource;
+import javax.validation.Valid;
+import java.io.File;
+import java.io.IOException;
+import java.util.Map;
+import java.util.UUID;
+
+@Tag(name = "服务角色申请", description = "统一服务角色申请入口")
+@RestController
+@RequestMapping("/api/service-role")
+public class ServiceRoleApplicationController {
+
+    @Resource
+    private ServiceRoleApplicationService serviceRoleApplicationService;
+
+    @Value("${upload.path:/data/uploads}")
+    private String uploadPath;
+
+    @PostMapping("/apply")
+    @Operation(summary = "提交服务角色申请")
+    public Result apply(@RequestAttribute("userId") Long userId,
+                        @Valid @RequestBody ServiceRoleApplyDTO dto) {
+        return serviceRoleApplicationService.submit(userId, dto);
+    }
+
+    @PostMapping("/my-apply")
+    @Operation(summary = "查询我的申请记录")
+    public Result myApply(@RequestAttribute("userId") Long userId) {
+        return serviceRoleApplicationService.getMyApplication(userId);
+    }
+
+    @PostMapping("/upload-image")
+    @Operation(summary = "上传图片(身份证/资质证书)")
+    public Result uploadImage(@RequestAttribute("userId") Long userId,
+                              @RequestParam("file") MultipartFile file) {
+        if (file.isEmpty()) {
+            return Result.error("请选择文件");
+        }
+        try {
+            String dir = uploadPath + "/service-role/" + userId;
+            File dirFile = new File(dir);
+            if (!dirFile.exists()) {
+                dirFile.mkdirs();
+            }
+            String ext = file.getOriginalFilename();
+            ext = ext != null && ext.contains(".") ? ext.substring(ext.lastIndexOf(".")) : ".jpg";
+            String fileName = UUID.randomUUID().toString() + ext;
+            File dest = new File(dirFile, fileName);
+            file.transferTo(dest);
+            String url = "/uploads/service-role/" + userId + "/" + fileName;
+            return Result.success(Map.of("url", url));
+        } catch (IOException e) {
+            return Result.error("上传失败: " + e.getMessage());
+        }
+    }
+}
+```
+
+Note: 检查路由重复:
+```bash
+grep -rn '@PostMapping\|@GetMapping\|@PutMapping\|@DeleteMapping' src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep service-role
+```
+Expected: only `/api/service-role/*` routes, no duplicates.
+
+- [ ] **Step 2: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+Expected: BUILD SUCCESS
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/ServiceRoleApplicationController.java
+git commit -m "feat(backend): add ServiceRoleApplicationController with apply/upload endpoints"
+```
+
+---
+
+### Task 5: 前端 API 函数 + 页面注册
+
+**Files:**
+- Modify: `cfc-frontend/utils/api.js`
+- Modify: `cfc-frontend/pages.json`
+
+**Interfaces:**
+- Consumes: Backend endpoints (Task 4)
+- Produces: API functions `submitServiceRoleApply`, `getMyServiceRoleApply`, `uploadServiceRoleImage`; page registered in `pages.json`
+
+- [ ] **Step 1: 在 api.js 添加 API 函数**
+
+在 `cfc-frontend/utils/api.js` 末尾(与其他 apply 函数放在一起)追加:
+
+```javascript
+// 服务角色统一申请
+export const submitServiceRoleApply = (data) => {
+    return request('/api/service-role/apply', 'POST', data)
+}
+
+export const getMyServiceRoleApply = () => {
+    return request('/api/service-role/my-apply', 'POST', {})
+}
+
+export const uploadServiceRoleImage = (filePath) => {
+    return new Promise((resolve, reject) => {
+        uni.uploadFile({
+            url: BASE_URL + '/api/service-role/upload-image',
+            filePath: filePath,
+            name: 'file',
+            header: {
+                'Authorization': 'Bearer ' + uni.getStorageSync('token')
+            },
+            success: (res) => {
+                if (res.statusCode === 200) {
+                    const data = JSON.parse(res.data)
+                    resolve(data)
+                } else {
+                    reject(new Error('上传失败'))
+                }
+            },
+            fail: reject
+        })
+    })
+}
+```
+
+- [ ] **Step 2: 在 pages.json 注册新页面**
+
+在 `pages.json` 的 profile 子包 pages 数组中追加:
+
+```json
+{
+  "path": "service-role-apply",
+  "style": {
+    "navigationBarTitleText": "服务角色申请"
+  }
+}
+```
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add cfc-frontend/utils/api.js cfc-frontend/pages.json
+git commit -m "feat(frontend): add API functions and page registration for service role apply"
+```
+
+---
+
+### Task 6: 前端统一申请页
+
+**Files:**
+- Create: `cfc-frontend/pages/profile/service-role-apply.vue`
+
+**Interfaces:**
+- Consumes: API functions from Task 5, `<AddressPicker>` component from cfc-frontend
+- Produces: Full apply page with role multi-select, ID card upload, certificate upload, address picker, submit
+
+- [ ] **Step 1: 创建 service-role-apply.vue**
+
+```vue
+<template>
+  <view class="page">
+    <!-- 角色选择 -->
+    <view class="section">
+      <view class="section-title">选择申请角色 <text class="required">*</text></view>
+      <view class="role-grid">
+        <view
+          v-for="role in roleList"
+          :key="role.key"
+          class="role-card"
+          :class="{ selected: selectedRoles.includes(role.key) }"
+          @click="toggleRole(role.key)"
+        >
+          <text class="role-icon">{{ role.icon }}</text>
+          <text class="role-name">{{ role.name }}</text>
+          <text v-if="selectedRoles.includes(role.key)" class="check-mark">✓</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 身份证信息 -->
+    <view class="section">
+      <view class="section-title">身份证信息</view>
+      <input class="input" v-model="form.idCard" placeholder="请输入身份证号码" maxlength="18" />
+      <view class="upload-row">
+        <view class="upload-item" @click="chooseIdCardImage('front')">
+          <image v-if="form.idCardFront" :src="form.idCardFront" mode="aspectFill" class="upload-preview" />
+          <view v-else class="upload-placeholder">
+            <text class="upload-icon">+</text>
+            <text class="upload-label">身份证正面</text>
+          </view>
+        </view>
+        <view class="upload-item" @click="chooseIdCardImage('back')">
+          <image v-if="form.idCardBack" :src="form.idCardBack" mode="aspectFill" class="upload-preview" />
+          <view v-else class="upload-placeholder">
+            <text class="upload-icon">+</text>
+            <text class="upload-label">身份证反面</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 资质证书 -->
+    <view class="section">
+      <view class="section-title">资质证书</view>
+      <view class="cert-list">
+        <view v-for="(img, idx) in form.certificateImages" :key="idx" class="cert-item">
+          <image :src="img" mode="aspectFill" class="cert-preview" />
+          <text class="cert-del" @click="removeCert(idx)">×</text>
+        </view>
+        <view class="cert-add" @click="chooseCertificate">
+          <text class="upload-icon">+</text>
+          <text class="upload-label">添加证书</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 联系地址 -->
+    <view class="section">
+      <view class="section-title">联系地址 <text class="required">*</text></view>
+      <AddressPicker ref="addressPicker" @change="onAddressChange" />
+      <input class="input" v-model="form.addressDetail" placeholder="详细地址(门牌号等)" />
+    </view>
+
+    <!-- 提交按钮 -->
+    <button class="submit-btn" :disabled="submitting" :loading="submitting" @click="handleSubmit">
+      {{ submitting ? '提交中...' : '提交申请' }}
+    </button>
+  </view>
+</template>
+
+<script>
+import { submitServiceRoleApply, getMyServiceRoleApply, uploadServiceRoleImage } from '../../../utils/api'
+import AddressPicker from '../../../components/AddressPicker.vue'
+
+export default {
+  components: { AddressPicker },
+  data() {
+    return {
+      roleList: [
+        { key: 'planner', name: '规划师', icon: '📋' },
+        { key: 'nutritionist', name: '营养师', icon: '🥗' },
+        { key: 'butler', name: '管家', icon: '🔔' },
+        { key: 'vendor', name: '服务商', icon: '🏪' },
+        { key: 'article_admin', name: '文章管理员', icon: '📝' },
+        { key: 'activity_provider', name: '活动提供商', icon: '🎪' }
+      ],
+      selectedRoles: [],
+      form: {
+        idCard: '',
+        idCardFront: '',
+        idCardBack: '',
+        certificateImages: [],
+        province: '',
+        city: '',
+        district: '',
+        street: '',
+        addressDetail: ''
+      },
+      submitting: false
+    }
+  },
+  onLoad() {
+    this.checkExistingApply()
+  },
+  methods: {
+    async checkExistingApply() {
+      try {
+        const res = await getMyServiceRoleApply()
+        if (res.code === 200 && res.data && res.data.status === 0) {
+          uni.showModal({
+            title: '提示',
+            content: '您已提交过申请,请等待审核',
+            showCancel: false,
+            success: () => uni.navigateBack()
+          })
+        }
+      } catch (e) {}
+    },
+    toggleRole(key) {
+      const idx = this.selectedRoles.indexOf(key)
+      if (idx > -1) {
+        this.selectedRoles.splice(idx, 1)
+      } else {
+        this.selectedRoles.push(key)
+      }
+    },
+    chooseIdCardImage(side) {
+      uni.chooseImage({
+        count: 1,
+        sizeType: ['compressed'],
+        sourceType: ['album', 'camera'],
+        success: async (res) => {
+          uni.showLoading({ title: '上传中...' })
+          try {
+            const uploadRes = await uploadServiceRoleImage(res.tempFilePaths[0])
+            uni.hideLoading()
+            if (uploadRes.code === 200) {
+              if (side === 'front') {
+                this.form.idCardFront = uploadRes.data.url
+              } else {
+                this.form.idCardBack = uploadRes.data.url
+              }
+              uni.showToast({ title: '上传成功', icon: 'success' })
+            } else {
+              uni.showToast({ title: uploadRes.message || '上传失败', icon: 'none' })
+            }
+          } catch (e) {
+            uni.hideLoading()
+            uni.showToast({ title: '上传失败', icon: 'none' })
+          }
+        }
+      })
+    },
+    async chooseCertificate() {
+      uni.chooseImage({
+        count: 9,
+        sizeType: ['compressed'],
+        sourceType: ['album', 'camera'],
+        success: async (res) => {
+          uni.showLoading({ title: '上传中...' })
+          try {
+            for (const filePath of res.tempFilePaths) {
+              const uploadRes = await uploadServiceRoleImage(filePath)
+              if (uploadRes.code === 200) {
+                this.form.certificateImages.push(uploadRes.data.url)
+              }
+            }
+            uni.hideLoading()
+            uni.showToast({ title: '上传成功', icon: 'success' })
+          } catch (e) {
+            uni.hideLoading()
+            uni.showToast({ title: '上传失败', icon: 'none' })
+          }
+        }
+      })
+    },
+    removeCert(idx) {
+      this.form.certificateImages.splice(idx, 1)
+    },
+    onAddressChange(e) {
+      this.form.province = e.province || ''
+      this.form.city = e.city || ''
+      this.form.district = e.district || ''
+      this.form.street = e.street || ''
+    },
+    async handleSubmit() {
+      if (this.selectedRoles.length === 0) {
+        uni.showToast({ title: '请至少选择一个角色', icon: 'none' })
+        return
+      }
+      if (!this.form.idCardFront || !this.form.idCardBack) {
+        uni.showToast({ title: '请上传身份证正反面照片', icon: 'none' })
+        return
+      }
+      if (!this.form.province) {
+        uni.showToast({ title: '请选择联系地址', icon: 'none' })
+        return
+      }
+
+      this.submitting = true
+      uni.showLoading({ title: '提交中...' })
+      try {
+        const res = await submitServiceRoleApply({
+          roles: this.selectedRoles,
+          idCard: this.form.idCard,
+          idCardImages: [this.form.idCardFront, this.form.idCardBack],
+          certificateImages: this.form.certificateImages,
+          province: this.form.province,
+          city: this.form.city,
+          district: this.form.district,
+          street: this.form.street,
+          addressDetail: this.form.addressDetail
+        })
+        uni.hideLoading()
+        if (res.code === 200) {
+          uni.showModal({
+            title: '提交成功',
+            content: '申请已提交,请等待后台审核',
+            showCancel: false,
+            success: () => uni.navigateBack()
+          })
+        } else {
+          uni.showToast({ title: res.message || '提交失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.hideLoading()
+        uni.showToast({ title: '提交失败,请重试', icon: 'none' })
+      } finally {
+        this.submitting = false
+      }
+    }
+  }
+}
+</script>
+
+<style>
+.page {
+  min-height: 100vh;
+  background: #f5f5f5;
+  padding: 20rpx 30rpx 100rpx;
+}
+.section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 30rpx;
+  margin-bottom: 20rpx;
+}
+.section-title {
+  font-size: 28rpx;
+  font-weight: bold;
+  margin-bottom: 20rpx;
+  color: #333;
+}
+.required {
+  color: #ff4444;
+}
+.role-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20rpx;
+}
+.role-card {
+  width: calc(33.33% - 14rpx);
+  background: #f8f8f8;
+  border-radius: 12rpx;
+  padding: 24rpx 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  position: relative;
+  border: 2rpx solid transparent;
+}
+.role-card.selected {
+  background: #fff7ed;
+  border-color: #f97316;
+}
+.role-icon {
+  font-size: 40rpx;
+  margin-bottom: 8rpx;
+}
+.role-name {
+  font-size: 24rpx;
+  color: #333;
+}
+.check-mark {
+  position: absolute;
+  top: 8rpx;
+  right: 12rpx;
+  color: #f97316;
+  font-size: 28rpx;
+  font-weight: bold;
+}
+.input {
+  width: 100%;
+  height: 72rpx;
+  border: 2rpx solid #eee;
+  border-radius: 8rpx;
+  padding: 0 20rpx;
+  font-size: 26rpx;
+  box-sizing: border-box;
+  margin-bottom: 16rpx;
+}
+.upload-row {
+  display: flex;
+  gap: 20rpx;
+}
+.upload-item {
+  flex: 1;
+  height: 200rpx;
+  border: 2rpx dashed #ddd;
+  border-radius: 12rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+}
+.upload-placeholder {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.upload-icon {
+  font-size: 48rpx;
+  color: #ccc;
+}
+.upload-label {
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 8rpx;
+}
+.upload-preview {
+  width: 100%;
+  height: 100%;
+}
+.cert-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+.cert-item {
+  width: 160rpx;
+  height: 160rpx;
+  position: relative;
+  border-radius: 8rpx;
+  overflow: hidden;
+}
+.cert-preview {
+  width: 100%;
+  height: 100%;
+}
+.cert-del {
+  position: absolute;
+  top: 4rpx;
+  right: 4rpx;
+  width: 32rpx;
+  height: 32rpx;
+  background: rgba(0,0,0,0.5);
+  color: #fff;
+  border-radius: 50%;
+  text-align: center;
+  line-height: 32rpx;
+  font-size: 24rpx;
+}
+.cert-add {
+  width: 160rpx;
+  height: 160rpx;
+  border: 2rpx dashed #ddd;
+  border-radius: 8rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+}
+.submit-btn {
+  width: 100%;
+  height: 88rpx;
+  background: linear-gradient(135deg, #f97316, #fb923c);
+  color: #fff;
+  font-size: 32rpx;
+  border-radius: 44rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  position: fixed;
+  bottom: 40rpx;
+  left: 30rpx;
+  width: calc(100% - 60rpx);
+}
+.submit-btn[disabled] {
+  opacity: 0.6;
+}
+</style>
+```
+
+- [ ] **Step 2: 提交**
+
+```bash
+git add cfc-frontend/pages/profile/service-role-apply.vue
+git commit -m "feat(frontend): add service role apply page with multi-select, upload, address"
+```
+
+---
+
+### Task 7: ProfileMenu 入口
+
+**Files:**
+- Modify: `cfc-frontend/pages/profile/components/ProfileMenu.vue`
+
+**Interfaces:**
+- Consumes: Page created in Task 6
+- Produces: Menu item visible in "个人" group
+
+- [ ] **Step 1: 添加菜单项**
+
+在 `ProfileMenu.vue` 的"个人"分组中,在"报告管理"之后追加:
+
+```vue
+<view class="menu-item" v-if="role === 'parent'" @click="goToServiceRoleApply">
+  <text>📋 服务角色申请</text>
+  <text class="arrow">›</text>
+</view>
+```
+
+- [ ] **Step 2: 添加 methods 方法**
+
+在 `methods` 中添加:
+
+```js
+goToServiceRoleApply() {
+  uni.navigateTo({ url: '/pages/profile/service-role-apply' })
+}
+```
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add cfc-frontend/pages/profile/components/ProfileMenu.vue
+git commit -m "feat(frontend): add service role apply entry in ProfileMenu"
+```
+
+---
+
+### Task 8: Web 管理端审核页面
+
+**Files:**
+- Create: `cfc-web/src/views/service/ServiceRoleApplications.vue`
+- Modify: `cfc-web/src/router/index.js`
+
+**Interfaces:**
+- Consumes: `POST /api/service-role/apply` (approve/reject through admin endpoints)
+- Produces: Web admin review page for pending applications
+
+- [ ] **Step 1: 创建审核页面**
+
+`cfc-web/src/views/service/ServiceRoleApplications.vue`:
+
+```vue
+<template>
+  <div class="page-container">
+    <el-card>
+      <div slot="header">
+        <span>服务角色申请审核</span>
+      </div>
+      <el-table :data="applications" v-loading="loading" style="width: 100%">
+        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="userId" label="用户ID" width="80" />
+        <el-table-column label="申请角色" width="200">
+          <template slot-scope="{ row }">
+            <el-tag v-for="role in parseRoles(row.roles)" :key="role" style="margin-right: 4px">
+              {{ roleMap[role] || role }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="身份证" width="200">
+          <template slot-scope="{ row }">
+            <div>{{ row.idCard }}</div>
+            <div v-if="row.idCardImages">
+              <el-image v-for="(img, idx) in parseImages(row.idCardImages)" :key="idx"
+                :src="img" style="width: 60px; height: 40px; margin-right: 4px; cursor: pointer;"
+                :preview-src-list="parseImages(row.idCardImages)" />
+            </div>
+          </template>
+        </el-table-column>
+        <el-table-column label="资质证书" width="160">
+          <template slot-scope="{ row }">
+            <el-image v-if="row.certificateImages"
+              v-for="(img, idx) in parseImages(row.certificateImages)" :key="idx"
+              :src="img" style="width: 60px; height: 40px; margin-right: 4px; cursor: pointer;"
+              :preview-src-list="parseImages(row.certificateImages)" />
+          </template>
+        </el-table-column>
+        <el-table-column label="地址" width="200">
+          <template slot-scope="{ row }">
+            {{ row.province }}{{ row.city }}{{ row.district }}{{ row.street }} {{ row.addressDetail }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="createdAt" label="申请时间" width="160" />
+        <el-table-column label="状态" width="80">
+          <template slot-scope="{ row }">
+            <el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="160">
+          <template slot-scope="{ row }">
+            <el-button v-if="row.status === 0" type="success" size="mini" @click="handleApprove(row)">批准</el-button>
+            <el-button v-if="row.status === 0" type="danger" size="mini" @click="handleReject(row)">驳回</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <el-dialog title="驳回申请" :visible.sync="rejectDialogVisible" width="400px">
+      <el-form>
+        <el-form-item label="驳回原因">
+          <el-input type="textarea" v-model="rejectReason" rows="3" placeholder="请输入驳回原因" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="rejectDialogVisible = false">取消</el-button>
+        <el-button type="danger" @click="confirmReject">确认驳回</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import request from '@/utils/request'
+
+export default {
+  data() {
+    return {
+      applications: [],
+      loading: false,
+      rejectDialogVisible: false,
+      rejectReason: '',
+      currentRejectId: null,
+      roleMap: {
+        planner: '规划师',
+        nutritionist: '营养师',
+        butler: '管家',
+        vendor: '服务商',
+        article_admin: '文章管理员',
+        activity_provider: '活动提供商'
+      }
+    }
+  },
+  mounted() {
+    this.fetchApplications()
+  },
+  methods: {
+    async fetchApplications() {
+      this.loading = true
+      try {
+        const res = await request({ url: '/api/service-role/admin/list', method: 'post' })
+        this.applications = res.data || []
+      } catch (e) {
+        this.$message.error('获取列表失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    parseRoles(roles) {
+      try { return JSON.parse(roles) } catch { return [] }
+    },
+    parseImages(images) {
+      try { return JSON.parse(images) } catch { return [] }
+    },
+    statusType(status) {
+      return { 0: 'warning', 1: 'success', 2: 'danger' }[status] || 'info'
+    },
+    statusText(status) {
+      return { 0: '待审核', 1: '已通过', 2: '已驳回' }[status] || '未知'
+    },
+    async handleApprove(row) {
+      try {
+        await request({ url: '/api/service-role/admin/approve', method: 'post', data: { id: row.id } })
+        this.$message.success('已批准')
+        this.fetchApplications()
+      } catch (e) {
+        this.$message.error('操作失败')
+      }
+    },
+    handleReject(row) {
+      this.currentRejectId = row.id
+      this.rejectReason = ''
+      this.rejectDialogVisible = true
+    },
+    async confirmReject() {
+      try {
+        await request({ url: '/api/service-role/admin/reject', method: 'post', data: { id: this.currentRejectId, reason: this.rejectReason } })
+        this.$message.success('已驳回')
+        this.rejectDialogVisible = false
+        this.fetchApplications()
+      } catch (e) {
+        this.$message.error('操作失败')
+      }
+    }
+  }
+}
+</script>
+```
+
+- [ ] **Step 2: 添加后端管理端接口**
+
+在 `ServiceRoleApplicationController` 中添加管理端接口(追加到同一个 Controller 中):
+
+```java
+@PostMapping("/admin/list")
+@Operation(summary = "管理端: 获取申请列表")
+public Result list(@RequestParam(required = false, defaultValue = "0") Integer status) {
+    List<ServiceRoleApplication> list = serviceRoleApplicationService.list(
+            new LambdaQueryWrapper<ServiceRoleApplication>()
+                    .eq(status != null, ServiceRoleApplication::getStatus, status)
+                    .orderByDesc(ServiceRoleApplication::getCreatedAt));
+    return Result.success(list);
+}
+
+@PostMapping("/admin/approve")
+@Operation(summary = "管理端: 批准申请")
+public Result adminApprove(@RequestBody Map<String, Object> params,
+                           @RequestAttribute("userId") Long adminId) {
+    Long id = Long.valueOf(params.get("id").toString());
+    return serviceRoleApplicationService.approve(id, adminId);
+}
+
+@PostMapping("/admin/reject")
+@Operation(summary = "管理端: 驳回申请")
+public Result adminReject(@RequestBody Map<String, Object> params,
+                          @RequestAttribute("userId") Long adminId) {
+    Long id = Long.valueOf(params.get("id").toString());
+    String reason = params.get("reason") != null ? params.get("reason").toString() : "";
+    return serviceRoleApplicationService.reject(id, reason, adminId);
+}
+```
+
+- [ ] **Step 3: 添加管理端路由**
+
+在 `cfc-web/src/router/index.js` 中添加路由:
+
+```javascript
+{
+  path: '/service/service-role-applications',
+  name: 'ServiceRoleApplications',
+  component: () => import('@/views/service/ServiceRoleApplications.vue'),
+  meta: { title: '服务角色申请审核', roles: ['admin'] }
+}
+```
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add cfc-web/src/views/service/ServiceRoleApplications.vue \
+       cfc-web/src/router/index.js \
+       cfc-backend/src/main/java/com/etotem/cfc/controller/ServiceRoleApplicationController.java
+git commit -m "feat(admin): add service role application review page and admin endpoints"
+```
+
+---
+
+### Task 9: 替换旧申请页(可选)
+
+**Files:**
+- Modify: `cfc-frontend/pages/butler/apply.vue`
+- Modify: `cfc-frontend/pages/nutritionist/apply.vue`
+- Modify: `cfc-frontend/pages/vendor/apply.vue`
+- Modify: `cfc-frontend/pages/guide/register/index.vue`
+
+- [ ] **Step 1: 将旧申请页重定向到统一入口**
+
+在每个旧页面的 `onLoad` 或页面顶部添加重定向逻辑:
+
+```javascript
+// 例:在 butler/apply.vue 的 onLoad 中添加
+onLoad() {
+  uni.redirectTo({ url: '/pages/profile/service-role-apply' })
+}
+```
+
+- [ ] **Step 2: 提交**
+
+```bash
+git add cfc-frontend/pages/butler/apply.vue \
+       cfc-frontend/pages/nutritionist/apply.vue \
+       cfc-frontend/pages/vendor/apply.vue \
+       cfc-frontend/pages/guide/register/index.vue
+git commit -m "refactor: redirect old apply pages to unified service role apply page"
+```

+ 212 - 1
docs/superpowers/specs/2026-07-30-service-role-apply-design.md

@@ -1 +1,212 @@
-# 服务角色统一申请设计
+# 服务角色统一申请设计
+
+日期: 2026-07-30
+状态: 待实现
+
+## 概述
+
+"我的"页面增加统一的服务角色申请入口,用户可多选申请成为规划师、营养师、管家、服务商、文章管理员、活动提供商,上传身份证和资质证书,填写联系地址,提交后台审核。
+
+现有分散的申请页面(butler/apply.vue、nutritionist/apply.vue、vendor/apply.vue、guide/register)将被此统一入口替换。
+
+## 角色枚举
+
+| 角色 Key | 显示名称 | 现有后端 | 说明 |
+|----------|----------|----------|------|
+| `planner` | 规划师 | GuideApplication/Guide | 成长规划师 |
+| `nutritionist` | 营养师 | User.nutritionistStatus | — |
+| `butler` | 管家 | User.butlerStatus + ButlerProfile | — |
+| `vendor` | 服务商 | User.vendorStatus + vendorInfo | 供应商/服务商 |
+| `article_admin` | 文章管理员 | 新增 User.article_admin_status | 知识管理模块 |
+| `activity_provider` | 活动提供商 | 新增 User.activity_provider_status | — |
+
+## 数据库设计
+
+### 新建表 `service_role_applications`
+
+```sql
+CREATE TABLE `service_role_applications` (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `user_id` int(11) NOT NULL COMMENT '申请人ID',
+  `roles` varchar(255) NOT NULL COMMENT '申请角色,JSON数组 e.g. ["planner","nutritionist"]',
+  `id_card` varchar(18) DEFAULT '' COMMENT '身份证号',
+  `id_card_images` text COMMENT '身份证照片,JSON数组[正面URL,反面URL]',
+  `certificate_images` text COMMENT '资质证书照片,JSON数组',
+  `province` varchar(50) DEFAULT '' COMMENT '省',
+  `city` varchar(50) DEFAULT '' COMMENT '市',
+  `district` varchar(50) DEFAULT '' COMMENT '区',
+  `street` varchar(50) DEFAULT '' COMMENT '街道',
+  `address_detail` varchar(200) DEFAULT '' COMMENT '详细地址',
+  `status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '0-pending, 1-approved, 2-rejected',
+  `reject_reason` varchar(500) DEFAULT '' COMMENT '驳回原因',
+  `reviewed_by` int(11) DEFAULT NULL COMMENT '审核人ID',
+  `reviewed_at` datetime DEFAULT NULL COMMENT '审核时间',
+  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_user_id` (`user_id`),
+  KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务角色申请表';
+```
+
+### User 表新增字段
+
+```sql
+`article_admin_status` tinyint(4) DEFAULT NULL COMMENT '0无 1待审 2通过 3驳回',
+`activity_provider_status` tinyint(4) DEFAULT NULL COMMENT '0无 1待审 2通过 3驳回',
+```
+
+## 后端设计
+
+### 实体
+
+```java
+@Data
+@TableName("service_role_applications")
+public class ServiceRoleApplication {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long userId;
+    private String roles;             // JSON Array
+    private String idCard;
+    private String idCardImages;      // JSON Array
+    private String certificateImages; // JSON Array
+    private String province;
+    private String city;
+    private String district;
+    private String street;
+    private String addressDetail;
+    private Integer status;           // 0=pending, 1=approved, 2=rejected
+    private String rejectReason;
+    private Long reviewedBy;
+    private LocalDateTime reviewedAt;
+    private LocalDateTime createdAt;
+    private LocalDateTime updatedAt;
+}
+```
+
+### DTO
+
+```java
+@Data
+public class ServiceRoleApplyDTO {
+    @NotEmpty(message = "至少选择一个角色")
+    private List<String> roles;
+    private String idCard;
+    private List<String> idCardImages;
+    private List<String> certificateImages;
+    private String province;
+    private String city;
+    private String district;
+    private String street;
+    private String addressDetail;
+}
+```
+
+### Controller: `ServiceRoleApplicationController`
+
+```
+POST /api/service-role/apply          → 提交申请(含图片URL列表)
+POST /api/service-role/my-apply       → 查询我的申请记录
+POST /api/service-role/upload-image   → 上传单张图片(MultipartFile, 返回图片URL)
+```
+
+统一 `@PostMapping`,返回 `Result<T>`。
+
+### Service: `ServiceRoleApplicationService`
+
+```java
+public interface ServiceRoleApplicationService {
+    Result submit(Long userId, ServiceRoleApplyDTO dto);
+    Result getMyApplication(Long userId);
+    Result approve(Long id, Long reviewerId);
+    Result reject(Long id, String reason, Long reviewerId);
+}
+```
+
+### 审批通过同步逻辑
+
+```
+planner           → 创建 Guide 记录
+nutritionist      → User.nutritionistStatus = 2 (approved)
+butler            → User.butlerStatus = 2 + 创建 ButlerProfile
+vendor            → User.vendorStatus = 2
+article_admin     → User.article_admin_status = 2
+activity_provider → User.activity_provider_status = 2
+```
+
+## 前端设计
+
+### ProfileMenu 新增入口
+
+在 `pages/profile/components/ProfileMenu.vue` 的「个人」分组新增菜单项(因 profile.vue 传 `show-services=false` 隐藏了服务分组):
+
+```vue
+<view class="menu-item" @click="goToServiceRoleApply">
+  <text>📋 服务角色申请</text>
+  <text class="arrow">›</text>
+</view>
+```
+
+```js
+goToServiceRoleApply() {
+  uni.navigateTo({ url: '/pages/profile/service-role-apply' })
+}
+```
+
+### 新增页面 `pages/profile/service-role-apply.vue`
+
+页面结构:
+
+1. **角色选择区** — 卡片式多选,6个角色网格布局,选中显示✓标记。参考 `assessment/apply.vue` 的 `:class="{selected:...}"` 模式
+2. **身份证信息** — 身份证号输入框 + 正反面照片上传(两个独立上传位)
+3. **资质证书** — 可添加多张证书照片,每张独立上传,显示缩略图,可删除
+4. **联系地址** — 复用 `<AddressPicker>` 四级联动 + 详细地址输入框
+5. **提交按钮** — 渐变样式,表单验证通过后可点击
+
+### 图片上传
+
+使用 `uni.chooseImage()` + `uni.uploadFile()`,先上传到服务器获取 URL,再提交表单时传 URL 列表。
+
+### API
+
+```js
+export const submitServiceRoleApply = (data) => post('/api/service-role/apply', data)
+export const getMyServiceRoleApply = (data) => post('/api/service-role/my-apply', data)
+export const uploadServiceRoleImage = (filePath) => ... // uni.uploadFile
+```
+
+### 页面注册
+
+在 `pages.json` 中 profile 子包注册:
+```json
+{
+  "path": "pages/profile/service-role-apply",
+  "style": { "navigationBarTitleText": "服务角色申请" }
+}
+```
+
+## Web 管理端审核
+
+`cfc-web` 新增 `ServiceRoleApplications.vue`:
+- 列表展示所有 `pending` 申请
+- 详情查看:申请角色、身份证照片、资质证书、地址
+- 操作:批准/驳回(驳回时填写原因)
+
+## 替换现有申请页
+
+统一入口上线后:
+- `pages/butler/apply.vue` — 标记废弃,可考虑跳转到统一入口
+- `pages/nutritionist/apply.vue` — 同上
+- `pages/vendor/apply.vue` — 同上
+- `pages/guide/register/index.vue` — 同上
+
+## 实施顺序
+
+1. 数据库迁移(建表 + User 加字段)
+2. 后端 Entity + DTO + Mapper
+3. 后端 Service + Controller + 图片上传
+4. Web 管理端审核页面
+5. 前端统一申请页
+6. ProfileMenu 入口
+7. 替换/废弃旧申请页