"""职业管理API""" from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, and_ import json from typing import AsyncGenerator from app.database import get_db from app.utils.sse_response import SSEResponse, create_sse_response, WizardProgressTracker from app.models.career import Career, CharacterCareer from app.models.character import Character from app.models.project import Project from app.schemas.career import ( CareerCreate, CareerUpdate, CareerResponse, CareerListResponse, CareerGenerateRequest, CharacterCareerResponse, CharacterCareerDetail, SetMainCareerRequest, AddSubCareerRequest, UpdateCareerStageRequest, CareerStage ) from app.services.ai_service import AIService from app.logger import get_logger from app.api.settings import get_user_ai_service from app.api.common import verify_project_access router = APIRouter(prefix="/careers", tags=["职业管理"]) logger = get_logger(__name__) @router.get("", response_model=CareerListResponse, summary="获取职业列表") async def get_careers( project_id: str, request: Request, db: AsyncSession = Depends(get_db) ): """获取指定项目的所有职业""" user_id = getattr(request.state, 'user_id', None) await verify_project_access(project_id, user_id, db) # 获取总数 count_result = await db.execute( select(func.count(Career.id)).where(Career.project_id == project_id) ) total = count_result.scalar_one() # 获取职业列表 result = await db.execute( select(Career) .where(Career.project_id == project_id) .order_by(Career.type, Career.created_at.desc()) ) careers = result.scalars().all() # 分类返回 main_careers = [] sub_careers = [] for career in careers: # 解析JSON字段 stages = json.loads(career.stages) if career.stages else [] attribute_bonuses = json.loads(career.attribute_bonuses) if career.attribute_bonuses else None career_dict = { "id": career.id, "project_id": career.project_id, "name": career.name, "type": career.type, "description": career.description, "category": career.category, "stages": stages, "max_stage": career.max_stage, "requirements": career.requirements, "special_abilities": career.special_abilities, "worldview_rules": career.worldview_rules, "attribute_bonuses": attribute_bonuses, "source": career.source, "created_at": career.created_at, "updated_at": career.updated_at } if career.type == "main": main_careers.append(career_dict) else: sub_careers.append(career_dict) return CareerListResponse( total=total, main_careers=main_careers, sub_careers=sub_careers ) @router.post("", response_model=CareerResponse, summary="创建职业") async def create_career( career_data: CareerCreate, request: Request, db: AsyncSession = Depends(get_db) ): """手动创建职业""" user_id = getattr(request.state, 'user_id', None) await verify_project_access(career_data.project_id, user_id, db) try: # 转换stages为JSON字符串 stages_json = json.dumps([stage.model_dump() for stage in career_data.stages], ensure_ascii=False) attribute_bonuses_json = json.dumps(career_data.attribute_bonuses, ensure_ascii=False) if career_data.attribute_bonuses else None # 创建职业 career = Career( project_id=career_data.project_id, name=career_data.name, type=career_data.type, description=career_data.description, category=career_data.category, stages=stages_json, max_stage=career_data.max_stage, requirements=career_data.requirements, special_abilities=career_data.special_abilities, worldview_rules=career_data.worldview_rules, attribute_bonuses=attribute_bonuses_json, source=career_data.source ) db.add(career) await db.commit() await db.refresh(career) logger.info(f"✅ 创建职业成功:{career.name} (ID: {career.id}, 类型: {career.type})") return CareerResponse( id=career.id, project_id=career.project_id, name=career.name, type=career.type, description=career.description, category=career.category, stages=career_data.stages, max_stage=career.max_stage, requirements=career.requirements, special_abilities=career.special_abilities, worldview_rules=career.worldview_rules, attribute_bonuses=career_data.attribute_bonuses, source=career.source, created_at=career.created_at, updated_at=career.updated_at ) except Exception as e: logger.error(f"创建职业失败: {str(e)}") raise HTTPException(status_code=500, detail=f"创建职业失败: {str(e)}") @router.get("/generate-system", summary="AI生成新职业(增量式,流式)") async def generate_career_system( project_id: str, main_career_count: int = 3, sub_career_count: int = 6, user_requirements: str = "", enable_mcp: bool = False, http_request: Request = None, db: AsyncSession = Depends(get_db), user_ai_service: AIService = Depends(get_user_ai_service) ): """ 使用AI生成新职业(增量式,基于已有职业补充,支持SSE流式进度显示) 通过Server-Sent Events返回实时进度信息 """ async def generate() -> AsyncGenerator[str, None]: tracker = WizardProgressTracker("职业体系") try: # 验证用户权限和项目是否存在 user_id = getattr(http_request.state, 'user_id', None) project = await verify_project_access(project_id, user_id, db) yield await tracker.start() # 获取已有职业列表 yield await tracker.loading("分析已有职业...", 0.3) existing_careers_result = await db.execute( select(Career).where(Career.project_id == project_id) ) existing_careers = existing_careers_result.scalars().all() # 构建已有职业摘要 existing_main_careers = [] existing_sub_careers = [] for career in existing_careers: career_summary = f"- {career.name}({career.category or '未分类'},{career.max_stage}阶)" if career.description: career_summary += f": {career.description[:50]}" if career.type == "main": existing_main_careers.append(career_summary) else: existing_sub_careers.append(career_summary) existing_careers_text = "" if existing_main_careers: existing_careers_text += f"\n已有主职业({len(existing_main_careers)}个):\n" + "\n".join(existing_main_careers) if existing_sub_careers: existing_careers_text += f"\n\n已有副职业({len(existing_sub_careers)}个):\n" + "\n".join(existing_sub_careers) if not existing_careers_text: existing_careers_text = "\n当前还没有任何职业,这是第一次创建职业体系。" # 构建项目上下文 yield await tracker.loading("分析项目世界观...", 0.6) project_context = f""" 项目信息: - 书名:{project.title} - 类型:{project.genre or '未设定'} - 主题:{project.theme or '未设定'} - 时间背景:{project.world_time_period or '未设定'} - 地理位置:{project.world_location or '未设定'} - 氛围基调:{project.world_atmosphere or '未设定'} - 世界规则:{project.world_rules or '未设定'} """ sanitized_user_requirements = user_requirements.strip() extra_requirement_text = "" if sanitized_user_requirements: extra_requirement_text = f""" 用户额外要求: {sanitized_user_requirements} 执行要求: - 请优先满足用户提出的职业方向、能力风格、限制条件和避雷项 - 如果用户要求与已有职业高度相似,请保留需求核心,但生成定位差异明确的新职业 - 如果用户要求与项目世界观冲突,请在不违背世界观的前提下进行合理改写和本土化适配 """ generation_requirements = f""" 已有职业情况:{existing_careers_text} 生成要求(增量式): - 本次新增主职业:{main_career_count}个 - 本次新增副职业:{sub_career_count}个 - ⚠️ 重要:请生成与已有职业**不重复**的新职业,形成互补体系 - 新职业应填补已有职业体系的空缺,丰富职业多样性 - 主职业必须严格符合世界观规则,体现核心能力体系 - 副职业可以更加自由灵活,包含生产、辅助、特殊类型 {extra_requirement_text}""" yield await tracker.preparing("构建AI提示词...") # 构建提示词 prompt = f"""{project_context} {generation_requirements} 请为这个小说项目生成新的补充职业(增量式)。要求: 1. **仔细分析已有职业**,避免生成重复或相似的职业 2. **填补职业体系的空缺**,让职业体系更加完善和多样化 3. 如果已有职业较少,可以生成核心基础职业 4. 如果已有职业较多,可以生成特色化、专精化的职业 返回JSON格式,结构如下: {{ "main_careers": [ {{ "name": "职业名称", "description": "职业描述", "category": "职业分类(如:战斗系、法术系等)", "stages": [ {{"level": 1, "name": "阶段名称", "description": "阶段描述"}}, {{"level": 2, "name": "阶段名称", "description": "阶段描述"}}, ... ], "max_stage": 10, "requirements": "职业要求", "special_abilities": "特殊能力", "worldview_rules": "世界观规则关联", "attribute_bonuses": {{"strength": "+10%", "intelligence": "+5%"}} }} ], "sub_careers": [ {{ "name": "副职业名称", "description": "职业描述", "category": "生产系/辅助系/特殊系", "stages": [...], "max_stage": 5, "requirements": "职业要求", "special_abilities": "特殊能力" }} ] }} 注意事项: 1. **避免重复**:生成的职业名称和定位不能与已有职业重复 2. **互补性**:新职业应与已有职业形成互补,丰富职业体系 3. 主职业的阶段设定要详细,体现明确的成长路径 4. 阶段名称要符合世界观特色 5. 副职业可以相对简化,但要有独特性 6. 所有职业都要符合项目的整体世界观设定 7. 如果提供了用户额外要求,请优先满足;若与世界观冲突,必须以世界观为准进行合理改写 8. 只返回纯JSON,不要添加任何解释文字 """ yield await tracker.generating(0, max(3000, len(prompt) * 8), "调用AI生成新职业...") logger.info(f"🎯 开始为项目 {project_id} 生成新职业(增量式,已有{len(existing_careers)}个职业)") try: # 使用流式生成替代非流式 ai_response = "" chunk_count = 0 estimated_total = max(3000, len(prompt) * 8) async for chunk in user_ai_service.generate_text_stream(prompt=prompt): chunk_count += 1 ai_response += chunk # 发送内容块 yield await SSEResponse.send_chunk(chunk) # 平滑更新进度(避免过于频繁) if chunk_count % 10 == 0: yield await tracker.generating(len(ai_response), estimated_total) # 心跳 if chunk_count % 20 == 0: yield await tracker.heartbeat() except Exception as ai_error: logger.error(f"❌ AI服务调用异常:{str(ai_error)}") yield await tracker.error(f"AI服务调用失败:{str(ai_error)}") return if not ai_response or not ai_response.strip(): yield await tracker.error("AI服务返回空响应") return yield await tracker.parsing("解析AI响应...", 0.5) # 清洗并解析JSON try: cleaned_response = user_ai_service._clean_json_response(ai_response) career_data = json.loads(cleaned_response) logger.info(f"✅ 职业体系JSON解析成功") except json.JSONDecodeError as e: logger.error(f"❌ 职业体系JSON解析失败: {e}") logger.error(f" 原始响应预览: {ai_response[:200]}") yield await tracker.error(f"AI返回的内容无法解析为JSON:{str(e)}") return yield await tracker.saving("保存主职业到数据库...", 0.3) # 保存主职业 main_careers_created = [] for idx, career_info in enumerate(career_data.get("main_careers", [])): try: stages_json = json.dumps(career_info.get("stages", []), ensure_ascii=False) attribute_bonuses = career_info.get("attribute_bonuses") attribute_bonuses_json = json.dumps(attribute_bonuses, ensure_ascii=False) if attribute_bonuses else None career = Career( project_id=project_id, name=career_info.get("name", f"未命名主职业{idx+1}"), type="main", description=career_info.get("description"), category=career_info.get("category"), stages=stages_json, max_stage=career_info.get("max_stage", 10), requirements=career_info.get("requirements"), special_abilities=career_info.get("special_abilities"), worldview_rules=career_info.get("worldview_rules"), attribute_bonuses=attribute_bonuses_json, source="ai" ) db.add(career) await db.flush() main_careers_created.append(career.name) logger.info(f" ✅ 创建主职业:{career.name}") except Exception as e: logger.error(f" ❌ 创建主职业失败:{str(e)}") continue yield await tracker.saving("保存副职业到数据库...", 0.6) # 保存副职业 sub_careers_created = [] for idx, career_info in enumerate(career_data.get("sub_careers", [])): try: stages_json = json.dumps(career_info.get("stages", []), ensure_ascii=False) attribute_bonuses = career_info.get("attribute_bonuses") attribute_bonuses_json = json.dumps(attribute_bonuses, ensure_ascii=False) if attribute_bonuses else None career = Career( project_id=project_id, name=career_info.get("name", f"未命名副职业{idx+1}"), type="sub", description=career_info.get("description"), category=career_info.get("category"), stages=stages_json, max_stage=career_info.get("max_stage", 5), requirements=career_info.get("requirements"), special_abilities=career_info.get("special_abilities"), worldview_rules=career_info.get("worldview_rules"), attribute_bonuses=attribute_bonuses_json, source="ai" ) db.add(career) await db.flush() sub_careers_created.append(career.name) logger.info(f" ✅ 创建副职业:{career.name}") except Exception as e: logger.error(f" ❌ 创建副职业失败:{str(e)}") continue await db.commit() total_main = len(existing_main_careers) + len(main_careers_created) total_sub = len(existing_sub_careers) + len(sub_careers_created) logger.info(f"🎉 新职业生成完成:新增主职业{len(main_careers_created)}个,新增副职业{len(sub_careers_created)}个") logger.info(f" 职业体系总数:主职业{total_main}个,副职业{total_sub}个") yield await tracker.complete(f"新职业生成完成!(主职业{total_main}个,副职业{total_sub}个)") # 发送结果数据 yield await tracker.result({ "main_careers_count": len(main_careers_created), "sub_careers_count": len(sub_careers_created), "main_careers": main_careers_created, "sub_careers": sub_careers_created }) yield await tracker.done() except HTTPException as he: logger.error(f"HTTP异常: {he.detail}") yield await tracker.error(he.detail, he.status_code) except Exception as e: logger.error(f"生成职业体系失败: {str(e)}") yield await tracker.error(f"生成新职业失败: {str(e)}") return create_sse_response(generate()) @router.put("/{career_id}", response_model=CareerResponse, summary="更新职业") async def update_career( career_id: str, career_update: CareerUpdate, request: Request, db: AsyncSession = Depends(get_db) ): """更新职业信息""" result = await db.execute( select(Career).where(Career.id == career_id) ) career = result.scalar_one_or_none() if not career: raise HTTPException(status_code=404, detail="职业不存在") # 验证用户权限 user_id = getattr(request.state, 'user_id', None) await verify_project_access(career.project_id, user_id, db) # 更新字段 update_data = career_update.model_dump(exclude_unset=True) for field, value in update_data.items(): if field == "stages" and value is not None: # 转换为JSON字符串 # model_dump() 已经将嵌套模型转换为字典,所以 value 中的元素已经是 dict stages_list = [ stage if isinstance(stage, dict) else stage.model_dump() for stage in value ] setattr(career, field, json.dumps(stages_list, ensure_ascii=False)) elif field == "attribute_bonuses" and value is not None: # 转换为JSON字符串 setattr(career, field, json.dumps(value, ensure_ascii=False)) else: setattr(career, field, value) await db.commit() await db.refresh(career) logger.info(f"✅ 更新职业成功:{career.name} (ID: {career_id})") # 解析JSON返回 stages = json.loads(career.stages) if career.stages else [] attribute_bonuses = json.loads(career.attribute_bonuses) if career.attribute_bonuses else None return CareerResponse( id=career.id, project_id=career.project_id, name=career.name, type=career.type, description=career.description, category=career.category, stages=stages, max_stage=career.max_stage, requirements=career.requirements, special_abilities=career.special_abilities, worldview_rules=career.worldview_rules, attribute_bonuses=attribute_bonuses, source=career.source, created_at=career.created_at, updated_at=career.updated_at ) @router.delete("/{career_id}", summary="删除职业") async def delete_career( career_id: str, request: Request, db: AsyncSession = Depends(get_db) ): """删除职业""" result = await db.execute( select(Career).where(Career.id == career_id) ) career = result.scalar_one_or_none() if not career: raise HTTPException(status_code=404, detail="职业不存在") # 验证用户权限 user_id = getattr(request.state, 'user_id', None) await verify_project_access(career.project_id, user_id, db) # 检查是否有角色使用该职业 char_career_result = await db.execute( select(func.count(CharacterCareer.id)).where(CharacterCareer.career_id == career_id) ) usage_count = char_career_result.scalar_one() if usage_count > 0: raise HTTPException( status_code=400, detail=f"该职业被{usage_count}个角色使用,无法删除。请先移除角色的职业关联。" ) await db.delete(career) await db.commit() logger.info(f"✅ 删除职业成功:{career.name} (ID: {career_id})") return {"message": "职业删除成功"} @router.get("/{career_id}", response_model=CareerResponse, summary="获取职业详情") async def get_career( career_id: str, request: Request, db: AsyncSession = Depends(get_db) ): """根据ID获取职业详情""" result = await db.execute( select(Career).where(Career.id == career_id) ) career = result.scalar_one_or_none() if not career: raise HTTPException(status_code=404, detail="职业不存在") # 验证用户权限 user_id = getattr(request.state, 'user_id', None) await verify_project_access(career.project_id, user_id, db) # 解析JSON字段 stages = json.loads(career.stages) if career.stages else [] attribute_bonuses = json.loads(career.attribute_bonuses) if career.attribute_bonuses else None return CareerResponse( id=career.id, project_id=career.project_id, name=career.name, type=career.type, description=career.description, category=career.category, stages=stages, max_stage=career.max_stage, requirements=career.requirements, special_abilities=career.special_abilities, worldview_rules=career.worldview_rules, attribute_bonuses=attribute_bonuses, source=career.source, created_at=career.created_at, updated_at=career.updated_at ) # ===== 角色职业关联API ===== @router.get("/character/{character_id}/careers", response_model=CharacterCareerResponse, summary="获取角色的职业信息") async def get_character_careers( character_id: str, request: Request, db: AsyncSession = Depends(get_db) ): """获取角色的所有职业信息(主职业和副职业)""" # 验证角色存在 char_result = await db.execute( select(Character).where(Character.id == character_id) ) character = char_result.scalar_one_or_none() if not character: raise HTTPException(status_code=404, detail="角色不存在") # 验证用户权限 user_id = getattr(request.state, 'user_id', None) await verify_project_access(character.project_id, user_id, db) # 获取角色的所有职业关联 result = await db.execute( select(CharacterCareer, Career) .join(Career, CharacterCareer.career_id == Career.id) .where(CharacterCareer.character_id == character_id) .order_by(CharacterCareer.career_type.desc()) # main排在前 ) career_relations = result.all() main_career = None sub_careers = [] for char_career, career in career_relations: # 解析职业的阶段信息 stages = json.loads(career.stages) if career.stages else [] # 找到当前阶段信息 stage_name = "未知阶段" stage_description = None for stage in stages: if stage.get("level") == char_career.current_stage: stage_name = stage.get("name", f"第{char_career.current_stage}阶段") stage_description = stage.get("description") break career_detail = CharacterCareerDetail( id=char_career.id, character_id=char_career.character_id, career_id=char_career.career_id, career_name=career.name, career_type=char_career.career_type, current_stage=char_career.current_stage, stage_name=stage_name, stage_description=stage_description, stage_progress=char_career.stage_progress, max_stage=career.max_stage, started_at=char_career.started_at, reached_current_stage_at=char_career.reached_current_stage_at, notes=char_career.notes, created_at=char_career.created_at, updated_at=char_career.updated_at ) if char_career.career_type == "main": main_career = career_detail else: sub_careers.append(career_detail) return CharacterCareerResponse( main_career=main_career, sub_careers=sub_careers ) @router.post("/character/{character_id}/careers/main", summary="设置角色主职业") async def set_main_career( character_id: str, career_request: SetMainCareerRequest, request: Request, db: AsyncSession = Depends(get_db) ): """设置或更换角色的主职业""" # 验证角色存在 char_result = await db.execute( select(Character).where(Character.id == character_id) ) character = char_result.scalar_one_or_none() if not character: raise HTTPException(status_code=404, detail="角色不存在") # 验证用户权限 user_id = getattr(request.state, 'user_id', None) await verify_project_access(character.project_id, user_id, db) # 验证职业存在且为主职业类型 career_result = await db.execute( select(Career).where( Career.id == career_request.career_id, Career.project_id == character.project_id ) ) career = career_result.scalar_one_or_none() if not career: raise HTTPException(status_code=404, detail="职业不存在") if career.type != "main": raise HTTPException(status_code=400, detail="该职业不是主职业类型,无法设置为主职业") # 验证阶段有效性 if career_request.current_stage > career.max_stage: raise HTTPException( status_code=400, detail=f"阶段超出范围,该职业最大阶段为{career.max_stage}" ) # 检查是否已有主职业 existing_main = await db.execute( select(CharacterCareer).where( CharacterCareer.character_id == character_id, CharacterCareer.career_type == "main" ) ) current_main = existing_main.scalar_one_or_none() if current_main: # 删除旧的主职业 await db.delete(current_main) logger.info(f" 移除旧主职业关联: {current_main.career_id}") # 创建新的主职业关联 char_career = CharacterCareer( character_id=character_id, career_id=career_request.career_id, career_type="main", current_stage=career_request.current_stage, stage_progress=0, started_at=career_request.started_at, reached_current_stage_at=career_request.started_at ) db.add(char_career) await db.commit() logger.info(f"✅ 设置主职业成功:角色{character.name} -> {career.name}(第{career_request.current_stage}阶段)") return {"message": "主职业设置成功", "career_name": career.name} @router.post("/character/{character_id}/careers/sub", summary="添加角色副职业") async def add_sub_career( character_id: str, career_request: AddSubCareerRequest, request: Request, db: AsyncSession = Depends(get_db) ): """为角色添加副职业""" # 验证角色存在 char_result = await db.execute( select(Character).where(Character.id == character_id) ) character = char_result.scalar_one_or_none() if not character: raise HTTPException(status_code=404, detail="角色不存在") # 验证用户权限 user_id = getattr(request.state, 'user_id', None) await verify_project_access(character.project_id, user_id, db) # 验证职业存在且为副职业类型 career_result = await db.execute( select(Career).where( Career.id == career_request.career_id, Career.project_id == character.project_id ) ) career = career_result.scalar_one_or_none() if not career: raise HTTPException(status_code=404, detail="职业不存在") if career.type != "sub": raise HTTPException(status_code=400, detail="该职业不是副职业类型,无法添加为副职业") # 验证阶段有效性 if career_request.current_stage > career.max_stage: raise HTTPException( status_code=400, detail=f"阶段超出范围,该职业最大阶段为{career.max_stage}" ) # 检查是否已存在 existing_check = await db.execute( select(CharacterCareer).where( CharacterCareer.character_id == character_id, CharacterCareer.career_id == career_request.career_id ) ) if existing_check.scalar_one_or_none(): raise HTTPException(status_code=400, detail="该角色已拥有此副职业") # 检查副职业数量限制(可选,这里设置为最多5个) sub_count_result = await db.execute( select(func.count(CharacterCareer.id)).where( CharacterCareer.character_id == character_id, CharacterCareer.career_type == "sub" ) ) sub_count = sub_count_result.scalar_one() if sub_count >= 5: raise HTTPException(status_code=400, detail="副职业数量已达上限(最多5个)") # 创建副职业关联 char_career = CharacterCareer( character_id=character_id, career_id=career_request.career_id, career_type="sub", current_stage=career_request.current_stage, stage_progress=0, started_at=career_request.started_at, reached_current_stage_at=career_request.started_at ) db.add(char_career) await db.commit() logger.info(f"✅ 添加副职业成功:角色{character.name} -> {career.name}(第{career_request.current_stage}阶段)") return {"message": "副职业添加成功", "career_name": career.name} @router.put("/character/{character_id}/careers/{career_id}/stage", summary="更新职业阶段") async def update_career_stage( character_id: str, career_id: str, stage_request: UpdateCareerStageRequest, request: Request, db: AsyncSession = Depends(get_db) ): """更新角色在某个职业的阶段""" # 验证角色职业关联存在 result = await db.execute( select(CharacterCareer, Career, Character) .join(Career, CharacterCareer.career_id == Career.id) .join(Character, CharacterCareer.character_id == Character.id) .where( CharacterCareer.character_id == character_id, CharacterCareer.career_id == career_id ) ) relation_data = result.one_or_none() if not relation_data: raise HTTPException(status_code=404, detail="角色职业关联不存在") char_career, career, character = relation_data # 验证用户权限 user_id = getattr(request.state, 'user_id', None) await verify_project_access(character.project_id, user_id, db) # 验证新阶段有效性 if stage_request.current_stage > career.max_stage: raise HTTPException( status_code=400, detail=f"阶段超出范围,该职业最大阶段为{career.max_stage}" ) # 验证阶段递增规则(不能倒退,除非降级) if stage_request.current_stage < char_career.current_stage: logger.warning(f"⚠️ 角色{character.name}的职业{career.name}阶段降低:{char_career.current_stage} -> {stage_request.current_stage}") # 更新阶段信息 char_career.current_stage = stage_request.current_stage char_career.stage_progress = stage_request.stage_progress if stage_request.reached_current_stage_at: char_career.reached_current_stage_at = stage_request.reached_current_stage_at if stage_request.notes is not None: char_career.notes = stage_request.notes await db.commit() logger.info(f"✅ 更新职业阶段成功:{character.name}的{career.name} -> 第{stage_request.current_stage}阶段") return { "message": "职业阶段更新成功", "career_name": career.name, "new_stage": stage_request.current_stage } @router.delete("/character/{character_id}/careers/{career_id}", summary="删除角色副职业") async def remove_sub_career( character_id: str, career_id: str, request: Request, db: AsyncSession = Depends(get_db) ): """删除角色的副职业""" # 验证角色职业关联存在 result = await db.execute( select(CharacterCareer, Character) .join(Character, CharacterCareer.character_id == Character.id) .where( CharacterCareer.character_id == character_id, CharacterCareer.career_id == career_id ) ) relation_data = result.one_or_none() if not relation_data: raise HTTPException(status_code=404, detail="角色职业关联不存在") char_career, character = relation_data # 验证用户权限 user_id = getattr(request.state, 'user_id', None) await verify_project_access(character.project_id, user_id, db) # 不允许删除主职业 if char_career.career_type == "main": raise HTTPException(status_code=400, detail="无法删除主职业,只能更换") await db.delete(char_career) await db.commit() logger.info(f"✅ 删除副职业成功:角色{character.name}移除职业{career_id}") return {"message": "副职业删除成功"}