9.3 KiB
Changelog: 修复角色信息 metadata 字段响应问题
日期: 2026-02-09
类型: Bug 修复
影响范围: 后端 Schema 层 - 角色信息响应
问题描述
角色信息编辑保存后,性别(gender)、视觉年龄(visual_age)、性格描述(personality)、外貌描述(appearance)等字段未能正确从数据库 meta_data 字段返回给前端,导致前端显示为空。
实际测试发现: 保存提示成功,但数据库中 meta_data 字段未更新,仍为 {}。
根因分析
问题1: 响应序列化问题(已修复)
-
存储层面正常:
ProjectElementService.update_character()方法正确将这些字段存入meta_dataJSONB 字段- 数据库中
project_characters.meta_data包含正确数据
-
响应序列化问题:
ProjectCharacterResponseSchema 定义了gender、visual_age、personality、appearance字段- 但使用
ConfigDict(from_attributes=True)时,Pydantic 直接从 SQLModel 对象属性读取 - 这些字段在
ProjectCharacter模型中未定义为列,而是存储在meta_data中 - 导致 Pydantic 找不到对应属性,返回
None
问题2: SQLAlchemy JSONB 变更检测失败(核心问题)
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 前正常
但数据库查询:
SELECT meta_data FROM project_characters WHERE ...
-- 结果: {} (未持久化)
根本原因: SQLAlchemy 的 JSONB 字段变更检测机制
# 错误写法
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
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 变更:
- 创建新字典对象 - 改变对象引用
- 显式标记字段已修改 - 使用
flag_modified()
server/app/services/project_element_service.py
# 更新 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 字段是否变更:
# ❌ 错误写法 - 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 对比
修复前:
-- flush 时生成的 SQL
UPDATE project_characters
SET role_type = 2
WHERE character_id = '019c41f7-ae08-7f81-88d8-79db92a4eb5d';
-- meta_data 字段未出现!
修复后:
-- 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 序列化流程
ConfigDict(from_attributes=True): 允许从对象属性创建模型model_post_init(): 模型初始化后的钩子,在字段赋值完成后执行- 字段提取逻辑: 仅当顶层字段为
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岁", ... }
测试验证
手动测试步骤
- 打开角色详情面板
- 编辑性别、视觉年龄、性格描述、外貌描述
- 点击"保存"
- 刷新页面,验证字段正确显示
预期结果
- 保存后字段立即显示最新值
- 刷新页面后字段持久化显示
- 后端日志显示
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 类型提示:
from typing import TypedDict
class CharacterMetaData(TypedDict, total=False):
gender: str
visual_age: str
personality: str
appearance: str
参考资料
- SQLAlchemy: Mutation Tracking
- SQLAlchemy: Working with JSON
- Pydantic v2 Model Post Init
- SQLModel Meta Data Pattern
- ADR 006: 枚举类型使用 SMALLINT
相关问题与最佳实践
其他使用 JSONB 的场景
项目中其他使用 JSONB 字段的地方也需要注意这个问题:
ProjectLocation.meta_data- 场景元数据ProjectProp.meta_data- 道具元数据ProjectElementTag.meta_data- 标签元数据ProjectResource.meta_data- 资源元数据
建议: 统一使用 dict() 创建新字典 + flag_modified() 的模式。
SQLModel + JSONB 最佳实践
# ✅ 推荐模式
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': '男'})