# Changelog: 修复角色信息 metadata 字段响应问题 **日期**: 2026-02-09 **类型**: Bug 修复 **影响范围**: 后端 Schema 层 - 角色信息响应 --- ## 问题描述 角色信息编辑保存后,性别(`gender`)、视觉年龄(`visual_age`)、性格描述(`personality`)、外貌描述(`appearance`)等字段未能正确从数据库 `meta_data` 字段返回给前端,导致前端显示为空。 **实际测试发现**: 保存提示成功,但数据库中 `meta_data` 字段未更新,仍为 `{}`。 ## 根因分析 ### 问题1: 响应序列化问题(已修复) 1. **存储层面正常**: - `ProjectElementService.update_character()` 方法正确将这些字段存入 `meta_data` JSONB 字段 - 数据库中 `project_characters.meta_data` 包含正确数据 2. **响应序列化问题**: - `ProjectCharacterResponse` Schema 定义了 `gender`、`visual_age`、`personality`、`appearance` 字段 - 但使用 `ConfigDict(from_attributes=True)` 时,Pydantic 直接从 SQLModel 对象属性读取 - 这些字段在 `ProjectCharacter` 模型中**未定义为列**,而是存储在 `meta_data` 中 - 导致 Pydantic 找不到对应属性,返回 `None` ### 问题2: SQLAlchemy JSONB 变更检测失败(核心问题) **SQL 日志分析**: ```sql -- 实际执行的 SQL UPDATE project_characters SET role_type=$1::INTEGER WHERE ... -- meta_data 字段未出现在 UPDATE 语句中! ``` **日志显示**: ``` 更新后 meta_data: {'visual_age': '35岁'} -- 内存中已更新 角色更新成功 | meta_data={'visual_age': '35岁'} -- flush 前正常 ``` **但数据库查询**: ```sql SELECT meta_data FROM project_characters WHERE ... -- 结果: {} (未持久化) ``` **根本原因**: SQLAlchemy 的 JSONB 字段变更检测机制 ```python # 错误写法 current_meta = character.meta_data or {} # 引用原对象 current_meta.update(meta_updates) # 修改原对象内容 character.meta_data = current_meta # 赋值同一个对象引用 # SQLAlchemy 检测: 对象引用未变 → 无变更 → 不生成 UPDATE ``` ## 解决方案 ### 修复1: Schema 响应序列化 在 `ProjectCharacterResponse` Schema 中添加 `model_post_init` 钩子,从 `meta_data` 中提取字段值到响应顶层。 **`server/app/schemas/project_element.py`** ```python class ProjectCharacterResponse(BaseModel): # ... 字段定义 ... def model_post_init(self, __context: Any) -> None: """从 meta_data 中提取字段到顶层""" if self.meta_data: # 如果顶层字段为空,从 meta_data 中提取 if not self.gender and 'gender' in self.meta_data: self.gender = self.meta_data.get('gender') if not self.visual_age and 'visual_age' in self.meta_data: self.visual_age = self.meta_data.get('visual_age') if not self.personality and 'personality' in self.meta_data: self.personality = self.meta_data.get('personality') if not self.appearance and 'appearance' in self.meta_data: self.appearance = self.meta_data.get('appearance') ``` ### 修复2: SQLAlchemy JSONB 变更检测(核心修复) 在 `ProjectElementService.update_character()` 中,使用以下两种方法确保 SQLAlchemy 检测到 JSONB 变更: 1. **创建新字典对象** - 改变对象引用 2. **显式标记字段已修改** - 使用 `flag_modified()` **`server/app/services/project_element_service.py`** ```python # 更新 meta_data 字段 if meta_updates: # 方法1: 创建新字典(推荐) current_meta = dict(character.meta_data or {}) # 创建新字典 current_meta.update(meta_updates) character.meta_data = current_meta # 重新赋值触发变更检测 logger.info("更新后 meta_data: %s", character.meta_data) # 方法2: 显式标记字段已修改(双重保险) from sqlalchemy.orm import attributes attributes.flag_modified(character, 'meta_data') await self.session.flush() await self.session.refresh(character) ``` ## 技术细节 ### SQLAlchemy JSONB 变更检测机制 SQLAlchemy 使用**对象引用比较**来检测 JSONB 字段是否变更: ```python # ❌ 错误写法 - SQLAlchemy 检测不到变更 current_meta = character.meta_data or {} # 引用原对象 current_meta.update(meta_updates) # 修改原对象内容 character.meta_data = current_meta # 赋值同一个对象引用 # → 对象引用未变 → 无变更 → 不生成 UPDATE SQL # ✅ 正确写法1 - 创建新对象 current_meta = dict(character.meta_data or {}) # 创建新字典 current_meta.update(meta_updates) character.meta_data = current_meta # 不同对象引用 # → 对象引用改变 → 检测到变更 → 生成 UPDATE SQL # ✅ 正确写法2 - 显式标记 from sqlalchemy.orm import attributes attributes.flag_modified(character, 'meta_data') # → 强制标记字段已修改 → 生成 UPDATE SQL # ✅ 最佳实践 - 两者结合 current_meta = dict(character.meta_data or {}) current_meta.update(meta_updates) character.meta_data = current_meta attributes.flag_modified(character, 'meta_data') # 双重保险 ``` ### 修复前后 SQL 对比 **修复前**: ```sql -- flush 时生成的 SQL UPDATE project_characters SET role_type = 2 WHERE character_id = '019c41f7-ae08-7f81-88d8-79db92a4eb5d'; -- meta_data 字段未出现! ``` **修复后**: ```sql -- flush 时生成的 SQL UPDATE project_characters SET role_type = 2, meta_data = '{"gender":"男","visual_age":"25岁","personality":"冷静","appearance":"高大"}'::jsonb WHERE character_id = '019c41f7-ae08-7f81-88d8-79db92a4eb5d'; -- meta_data 字段正确更新! ``` ### Pydantic v2 序列化流程 1. **`ConfigDict(from_attributes=True)`**: 允许从对象属性创建模型 2. **`model_post_init()`**: 模型初始化后的钩子,在字段赋值完成后执行 3. **字段提取逻辑**: 仅当顶层字段为 `None` 且 `meta_data` 中存在该字段时才提取 ### 数据流 ``` 前端编辑保存 ↓ usePreviewActions.handleUpdateCharacter() ↓ 发送 { gender, visual_age, personality, appearance } API: PUT /projects/{id}/characters/{id} ↓ ProjectCharacterUpdate Schema 接收 ProjectElementService.update_character() ↓ 识别 meta_fields,存入 character.meta_data 数据库: project_characters.meta_data = { "gender": "男", ... } ↓ commit API 响应: ProjectCharacterResponse ↓ model_post_init() 从 meta_data 提取到顶层 前端收到完整数据: { gender: "男", visual_age: "25岁", ... } ``` ## 测试验证 ### 手动测试步骤 1. 打开角色详情面板 2. 编辑性别、视觉年龄、性格描述、外貌描述 3. 点击"保存" 4. 刷新页面,验证字段正确显示 ### 预期结果 - 保存后字段立即显示最新值 - 刷新页面后字段持久化显示 - 后端日志显示 `meta_data` 正确保存 - API 响应包含顶层字段值 ## 影响范围 - ✅ **修复**: 角色信息编辑保存流程 - ✅ **无破坏性变更**: 仅修改响应序列化逻辑,不影响数据库结构 - ✅ **兼容性**: 前端无需修改,自动获得正确数据 ## 相关文件 - `server/app/schemas/project_element.py` - Schema 定义 - `server/app/services/project_element_service.py` - 服务层逻辑 - `server/app/models/project_character.py` - 数据模型 - `client/src/components/features/preview/hooks/usePreviewActions.ts` - 前端保存逻辑 - `client/src/components/features/preview/PreviewInfoPanel.tsx` - 前端编辑表单 ## 后续优化建议 ### 1. 统一字段提升模式 考虑为其他实体(场景、道具)也添加类似的 `model_post_init` 钩子,统一处理 `meta_data` 字段提升。 ### 2. 性能优化 如果 `meta_data` 字段较大,可以考虑: - 仅提升常用字段 - 使用 `computed_field` 装饰器(Pydantic v2) - 添加缓存机制 ### 3. 类型安全 在 `meta_data` 中为这些字段添加 TypedDict 类型提示: ```python from typing import TypedDict class CharacterMetaData(TypedDict, total=False): gender: str visual_age: str personality: str appearance: str ``` ## 参考资料 - [SQLAlchemy: Mutation Tracking](https://docs.sqlalchemy.org/en/20/orm/session_api.html#sqlalchemy.orm.attributes.flag_modified) - [SQLAlchemy: Working with JSON](https://docs.sqlalchemy.org/en/20/core/type_basics.html#sqlalchemy.types.JSON) - [Pydantic v2 Model Post Init](https://docs.pydantic.dev/latest/concepts/models/#model-post-init) - [SQLModel Meta Data Pattern](https://sqlmodel.tiangolo.com/) - [ADR 006: 枚举类型使用 SMALLINT](../../architecture/adrs/006-enum-smallint.md) ## 相关问题与最佳实践 ### 其他使用 JSONB 的场景 项目中其他使用 JSONB 字段的地方也需要注意这个问题: 1. `ProjectLocation.meta_data` - 场景元数据 2. `ProjectProp.meta_data` - 道具元数据 3. `ProjectElementTag.meta_data` - 标签元数据 4. `ProjectResource.meta_data` - 资源元数据 **建议**: 统一使用 `dict()` 创建新字典 + `flag_modified()` 的模式。 ### SQLModel + JSONB 最佳实践 ```python # ✅ 推荐模式 def update_jsonb_field(obj, field_name: str, updates: dict): """更新 JSONB 字段的通用方法""" current = getattr(obj, field_name) or {} new_value = dict(current) # 创建新字典 new_value.update(updates) setattr(obj, field_name, new_value) # 显式标记 from sqlalchemy.orm import attributes attributes.flag_modified(obj, field_name) # 使用 update_jsonb_field(character, 'meta_data', {'gender': '男'}) ```