"""司命台 · 人物模板管理聚合服务(api-spec §19 扩展)。 系统模板 CRUD,所有写操作记审计并入起居注。 路由直接调用此服务层函数(函数内部自管 ``AsyncSessionLocal``)。 """ from app.core.config import settings from app.core.errors import AppError from app.core.ids import new_character_id from app.core.time import utc_now from app.db.session import AsyncSessionLocal from app.models.character import Character from app.repositories import characters as character_repo from app.repositories.json_utils import dumps, loads_dict from app.schemas.admin_character import ( AdminCreateTemplateRequest, AdminTemplateSummaryResponse, AdminUpdateTemplateRequest, ) from app.services.audit import record_audit_event def _template_to_response(template: Character) -> AdminTemplateSummaryResponse: profile = loads_dict(template.profile_json) model_policy = loads_dict(template.model_policy_json) # 从 profile 中推断 is_default(无独立字段,保留为 False 未映射) return AdminTemplateSummaryResponse( key=template.template_key or "", name=template.name, display_name=template.display_name, language=template.language, privacy_level=template.privacy_level, is_default=False, profile=profile, model_policy=model_policy, created_at=template.created_at.isoformat() if template.created_at else "", updated_at=template.updated_at.isoformat() if template.updated_at else "", ) async def admin_list_templates() -> list[AdminTemplateSummaryResponse]: """列出所有系统模板(管理端完整视图)。""" async with AsyncSessionLocal() as session: templates = await character_repo.list_system_templates(session) return [_template_to_response(t) for t in templates] async def admin_get_template(key: str) -> AdminTemplateSummaryResponse: """获取单个系统模板。""" async with AsyncSessionLocal() as session: template = await character_repo.get_system_template_by_key(session, key) if template is None: raise AppError("not_found", f"模板 '{key}' 不存在。") return _template_to_response(template) async def admin_create_template( payload: AdminCreateTemplateRequest, ) -> AdminTemplateSummaryResponse: """创建系统模板。""" async with AsyncSessionLocal() as session: # 检查是否已存在同 key 模板 existing = await character_repo.get_system_template_by_key(session, payload.key) if existing is not None: raise AppError("conflict", f"模板 key '{payload.key}' 已存在。") profile = dict(payload.profile) model_policy = dict(payload.model_policy) model_policy.setdefault("default_chat_model_hint", settings.default_model_hint) character = Character( id=new_character_id(), name=payload.name, display_name=payload.display_name, status="active", language=payload.language, profile_json=dumps(profile), model_policy_json=dumps(model_policy), privacy_level=payload.privacy_level, owner_scope="system", is_system_template=True, source_template_id=None, template_key=payload.key, ) session.add(character) await session.flush() # birthday 兜底 await _ensure_birthday(character) await record_audit_event( session, action="admin.character_template.created", character_id=character.id, actor="admin", decision="auto", input_summary=f"管理员创建系统模板 '{payload.key}'", resource_type="character_template", resource_id=character.id, ) await session.commit() await session.refresh(character) return _template_to_response(character) async def admin_update_template( key: str, payload: AdminUpdateTemplateRequest, ) -> AdminTemplateSummaryResponse: """更新系统模板(部分更新)。""" async with AsyncSessionLocal() as session: template = await character_repo.get_system_template_by_key(session, key) if template is None: raise AppError("not_found", f"模板 '{key}' 不存在。") changed = False update_fields = payload.model_dump(exclude_unset=True) if "name" in update_fields and template.name != update_fields["name"]: template.name = update_fields["name"] changed = True if "display_name" in update_fields and template.display_name != update_fields["display_name"]: template.display_name = update_fields["display_name"] changed = True if "language" in update_fields and template.language != update_fields["language"]: template.language = update_fields["language"] changed = True if "privacy_level" in update_fields and template.privacy_level != update_fields["privacy_level"]: template.privacy_level = update_fields["privacy_level"] changed = True if "profile" in update_fields: current_profile = loads_dict(template.profile_json) current_profile.update(update_fields["profile"]) template.profile_json = dumps(current_profile) changed = True if "model_policy" in update_fields: current_policy = loads_dict(template.model_policy_json) current_policy.update(update_fields["model_policy"]) template.model_policy_json = dumps(current_policy) changed = True if changed: await record_audit_event( session, action="admin.character_template.updated", character_id=template.id, actor="admin", decision="auto", input_summary=f"管理员更新系统模板 '{key}'", resource_type="character_template", resource_id=template.id, ) template.updated_at = utc_now() await session.commit() await session.refresh(template) return _template_to_response(template) async def admin_delete_template(key: str) -> None: """删除系统模板。""" async with AsyncSessionLocal() as session: template = await character_repo.get_system_template_by_key(session, key) if template is None: raise AppError("not_found", f"模板 '{key}' 不存在。") # 检查是否有关联的子人物 await _check_template_in_use(session, template) await record_audit_event( session, action="admin.character_template.deleted", character_id=template.id, actor="admin", decision="auto", input_summary=f"管理员删除系统模板 '{key}'", resource_type="character_template", resource_id=template.id, ) await session.delete(template) await session.commit() async def _check_template_in_use(session, template: Character) -> None: """检查模板是否被用户人物引用,是则阻止删除。""" from sqlalchemy import func, select count = await session.scalar( select(func.count()) .select_from(Character) .where( Character.source_template_id == template.id, Character.is_system_template.is_(False), ) ) if count and count > 0: raise AppError( "conflict", f"模板 '{template.template_key}' 已被 {count} 个人物引用,无法删除。" "请先确保无用户人物引用此模板后再试。", ) async def _ensure_birthday(character: Character) -> None: """为模板创建兜底 birthday(profile 中无 birthday 时用 created_at)。""" profile = loads_dict(character.profile_json) birthday = profile.get("birthday") if isinstance(birthday, str) and birthday.strip(): return birthday_iso = character.created_at.strftime("%Y-%m-%d") profile["birthday"] = birthday_iso character.profile_json = dumps(profile)