You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

9.3 KiB

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 定义了 gendervisual_agepersonalityappearance 字段
    • 但使用 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 变更:

  1. 创建新字典对象 - 改变对象引用
  2. 显式标记字段已修改 - 使用 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 序列化流程

  1. ConfigDict(from_attributes=True): 允许从对象属性创建模型
  2. model_post_init(): 模型初始化后的钩子,在字段赋值完成后执行
  3. 字段提取逻辑: 仅当顶层字段为 Nonemeta_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 类型提示:

from typing import TypedDict

class CharacterMetaData(TypedDict, total=False):
    gender: str
    visual_age: str
    personality: str
    appearance: str

参考资料

相关问题与最佳实践

其他使用 JSONB 的场景

项目中其他使用 JSONB 字段的地方也需要注意这个问题:

  1. ProjectLocation.meta_data - 场景元数据
  2. ProjectProp.meta_data - 道具元数据
  3. ProjectElementTag.meta_data - 标签元数据
  4. 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': '男'})