feat(avatar): honor square interaction authorization
This commit is contained in:
@@ -1,16 +1,24 @@
|
||||
"""数字分身管理服务层 — 同步连接数字分身应用的 SQLite 数据库"""
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy import create_engine, select, text
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logger import logger
|
||||
from app.models import UserPersonality, VirtualUser
|
||||
|
||||
|
||||
_engine = None
|
||||
_SessionLocal: Optional[sessionmaker] = None
|
||||
|
||||
AVATAR_ACCOUNT_PREFIX = "__avatar__:"
|
||||
SQUARE_INTERACTION_PERMISSION = "interact"
|
||||
SQUARE_INTERACTION_ACTIONS = frozenset({"like", "collect", "comment", "reply"})
|
||||
|
||||
|
||||
def _get_engine_and_session():
|
||||
global _engine, _SessionLocal
|
||||
@@ -66,6 +74,185 @@ def _get_global_token_balance(db: Session) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _decode_config(value) -> dict:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
decoded = json.loads(value)
|
||||
return decoded if isinstance(decoded, dict) else {}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def is_delegated_avatar_user(user: VirtualUser | None) -> bool:
|
||||
return bool(user and (user.account or "").startswith(AVATAR_ACCOUNT_PREFIX))
|
||||
|
||||
|
||||
def delegated_avatar_id(user: VirtualUser | None) -> str:
|
||||
if not is_delegated_avatar_user(user):
|
||||
return ""
|
||||
return (user.account or "")[len(AVATAR_ACCOUNT_PREFIX):]
|
||||
|
||||
|
||||
def _list_square_interaction_authorizations(db: Session) -> list[dict]:
|
||||
"""读取已明确授权分身参与广场互动的身份与会会令牌。"""
|
||||
rows = db.execute(text("""
|
||||
SELECT
|
||||
a.id AS avatar_id,
|
||||
a.name AS avatar_name,
|
||||
a.display_name AS avatar_display_name,
|
||||
a.description AS avatar_description,
|
||||
a.photo_url AS avatar_photo_url,
|
||||
a.config AS avatar_config,
|
||||
u.huihui_user_id,
|
||||
u.nickname AS owner_nickname,
|
||||
u.avatar_url AS owner_avatar_url,
|
||||
u.huihui_token
|
||||
FROM avatars a
|
||||
JOIN users u ON u.huihui_user_id = a.owner_id
|
||||
WHERE a.status = 'active'
|
||||
""")).fetchall()
|
||||
|
||||
authorized = []
|
||||
for row in rows:
|
||||
config = _decode_config(row.avatar_config)
|
||||
permissions = config.get("authorizationPermissions", [])
|
||||
if not isinstance(permissions, list) or SQUARE_INTERACTION_PERMISSION not in permissions:
|
||||
continue
|
||||
platform_uid = str(row.huihui_user_id or "").strip()
|
||||
token = str(row.huihui_token or "").strip()
|
||||
if not platform_uid or not token:
|
||||
continue
|
||||
authorized.append({
|
||||
"avatar_id": str(row.avatar_id),
|
||||
"avatar_name": row.avatar_display_name or row.avatar_name or row.owner_nickname or "数字分身",
|
||||
"avatar_description": row.avatar_description or "",
|
||||
"avatar_url": _resolve_photo_url(row.avatar_photo_url or row.owner_avatar_url or ""),
|
||||
"config": config,
|
||||
"platform_uid": platform_uid,
|
||||
"token": token,
|
||||
})
|
||||
return authorized
|
||||
|
||||
|
||||
def get_square_interaction_permissions(avatar_id: str) -> frozenset[str]:
|
||||
"""实时复核授权;数据库不可用、令牌失效或撤权时一律拒绝执行。"""
|
||||
avatar_db = get_session()
|
||||
if avatar_db is None:
|
||||
return frozenset()
|
||||
try:
|
||||
authorized_ids = {
|
||||
item["avatar_id"] for item in _list_square_interaction_authorizations(avatar_db)
|
||||
}
|
||||
return SQUARE_INTERACTION_ACTIONS if avatar_id in authorized_ids else frozenset()
|
||||
except Exception as exc:
|
||||
logger.error(f"读取数字分身广场互动授权失败: {exc}")
|
||||
return frozenset()
|
||||
finally:
|
||||
avatar_db.close()
|
||||
|
||||
|
||||
def _word_count_range(config: dict) -> tuple[int, int]:
|
||||
ranges = {
|
||||
"short": (10, 35),
|
||||
"medium": (20, 60),
|
||||
"long": (30, 80),
|
||||
}
|
||||
return ranges.get(str(config.get("responseLength") or "medium"), (20, 60))
|
||||
|
||||
|
||||
async def sync_square_interaction_users(db) -> set[str]:
|
||||
"""把已授权分身同步为调度器身份,并刷新其会会会话。"""
|
||||
avatar_db = get_session()
|
||||
if avatar_db is None:
|
||||
logger.warning("数字分身数据库不可用,跳过广场互动授权同步")
|
||||
return set()
|
||||
try:
|
||||
authorized = _list_square_interaction_authorizations(avatar_db)
|
||||
except Exception as exc:
|
||||
logger.error(f"同步数字分身广场互动授权失败: {exc}")
|
||||
return set()
|
||||
finally:
|
||||
avatar_db.close()
|
||||
|
||||
from app.core.redis_client import delete_session, set_session
|
||||
|
||||
result = await db.execute(
|
||||
select(VirtualUser).where(VirtualUser.account.like(f"{AVATAR_ACCOUNT_PREFIX}%"))
|
||||
)
|
||||
existing_users = {delegated_avatar_id(user): user for user in result.scalars().all()}
|
||||
authorized_ids = {item["avatar_id"] for item in authorized}
|
||||
|
||||
for avatar_id, user in existing_users.items():
|
||||
if avatar_id not in authorized_ids:
|
||||
user.is_enabled = 0
|
||||
user.status = 0
|
||||
user.session_token = None
|
||||
user.session_expires_at = None
|
||||
await delete_session(user.id)
|
||||
|
||||
for item in authorized:
|
||||
avatar_id = item["avatar_id"]
|
||||
user = existing_users.get(avatar_id)
|
||||
if user is None:
|
||||
user = VirtualUser(
|
||||
nickname=item["avatar_name"],
|
||||
account=f"{AVATAR_ACCOUNT_PREFIX}{avatar_id}",
|
||||
password_enc="",
|
||||
status=2,
|
||||
is_enabled=1,
|
||||
platform_uid=item["platform_uid"],
|
||||
remark="用户授权的数字分身广场互动身份",
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
expires_at = datetime.now() + timedelta(days=1)
|
||||
user.nickname = item["avatar_name"]
|
||||
user.real_name = item["avatar_name"]
|
||||
user.avatar_url = item["avatar_url"]
|
||||
user.platform_uid = item["platform_uid"]
|
||||
user.session_token = item["token"]
|
||||
user.session_expires_at = expires_at
|
||||
user.last_login_at = datetime.now()
|
||||
user.status = 2
|
||||
user.is_enabled = 1
|
||||
|
||||
config = item["config"]
|
||||
personality_result = await db.execute(
|
||||
select(UserPersonality).where(UserPersonality.user_id == user.id)
|
||||
)
|
||||
personality = personality_result.scalar_one_or_none()
|
||||
word_min, word_max = _word_count_range(config)
|
||||
prompt_parts = [item["avatar_description"], str(config.get("systemPrompt") or "")]
|
||||
style_prompt = "\n".join(part.strip() for part in prompt_parts if part and part.strip())
|
||||
if personality is None:
|
||||
personality = UserPersonality(user_id=user.id)
|
||||
db.add(personality)
|
||||
personality.language_style = str(config.get("replyStyle") or "professional")
|
||||
personality.personality_desc = item["avatar_description"]
|
||||
personality.comment_style_prompt = style_prompt
|
||||
personality.word_count_min = word_min
|
||||
personality.word_count_max = word_max
|
||||
|
||||
await set_session(user.id, {
|
||||
"token": item["token"],
|
||||
"session_id": f"avatar:{avatar_id}",
|
||||
"platform_uid": item["platform_uid"],
|
||||
"org_id": "",
|
||||
"login_time": datetime.now().isoformat(),
|
||||
"nickname": item["avatar_name"],
|
||||
"real_name": item["avatar_name"],
|
||||
"avatar": item["avatar_url"],
|
||||
"delegated_avatar_id": avatar_id,
|
||||
}, expire=86400)
|
||||
|
||||
await db.commit()
|
||||
return authorized_ids
|
||||
|
||||
|
||||
class AvatarService:
|
||||
|
||||
@staticmethod
|
||||
|
||||
Reference in New Issue
Block a user