fix(avatar): preserve ownership across Huihui environments
This commit is contained in:
@@ -27,7 +27,7 @@ from sqlalchemy.orm import Session
|
||||
_CN_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
from database import get_db
|
||||
from models import User
|
||||
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["会会账号"])
|
||||
@@ -281,12 +281,61 @@ def pwd_login(body: dict = Body(...), db: Session = Depends(get_db)):
|
||||
})
|
||||
|
||||
|
||||
def _transfer_avatar_ownership(db: Session, old_owner_id: str, new_owner_id: str) -> int:
|
||||
"""Move one user's avatar-owned data to a replacement Huihui identity."""
|
||||
if not old_owner_id or old_owner_id == new_owner_id:
|
||||
return 0
|
||||
|
||||
avatar_ids = [
|
||||
avatar_id
|
||||
for (avatar_id,) in db.query(Avatar.id).filter(Avatar.owner_id == old_owner_id).all()
|
||||
]
|
||||
if not avatar_ids:
|
||||
return 0
|
||||
|
||||
db.query(Avatar).filter(Avatar.id.in_(avatar_ids)).update(
|
||||
{Avatar.owner_id: new_owner_id}, synchronize_session="fetch"
|
||||
)
|
||||
for model in (TakeoverCursor, TakeoverMessage, TakeoverReplyTask):
|
||||
db.query(model).filter(model.avatar_id.in_(avatar_ids)).update(
|
||||
{model.owner_id: new_owner_id}, synchronize_session="fetch"
|
||||
)
|
||||
return len(avatar_ids)
|
||||
|
||||
|
||||
def _find_or_link_user(db: Session, phone: str, huihui_user_id: str) -> User:
|
||||
"""Resolve an account and safely retain avatars across Huihui environments."""
|
||||
user = db.query(User).filter(User.huihui_user_id == huihui_user_id).first()
|
||||
if not phone:
|
||||
return user or User(huihui_user_id=huihui_user_id)
|
||||
|
||||
same_phone_users = db.query(User).filter(User.phone == phone).all()
|
||||
|
||||
if user is None:
|
||||
# A unique verified-phone match is the same person whose upstream ID changed.
|
||||
if len(same_phone_users) == 1:
|
||||
user = same_phone_users[0]
|
||||
old_owner_id = user.huihui_user_id
|
||||
_transfer_avatar_ownership(db, old_owner_id, huihui_user_id)
|
||||
user.huihui_user_id = huihui_user_id
|
||||
return user
|
||||
return User(huihui_user_id=huihui_user_id)
|
||||
|
||||
legacy_users = [candidate for candidate in same_phone_users if candidate.id != user.id]
|
||||
current_avatar_count = db.query(Avatar).filter(Avatar.owner_id == huihui_user_id).count()
|
||||
if len(legacy_users) == 1 and current_avatar_count == 0:
|
||||
legacy_user = legacy_users[0]
|
||||
_transfer_avatar_ownership(db, legacy_user.huihui_user_id, huihui_user_id)
|
||||
legacy_user.app_token = ""
|
||||
legacy_user.huihui_token = ""
|
||||
db.add(legacy_user)
|
||||
return user
|
||||
|
||||
|
||||
def _issue_session(db: Session, phone: str, info: dict):
|
||||
"""建/链本地用户并签发本系统会话 token"""
|
||||
huihui_user_id = info.get("userId", "")
|
||||
user = db.query(User).filter(User.huihui_user_id == huihui_user_id).first()
|
||||
if not user:
|
||||
user = User(huihui_user_id=huihui_user_id)
|
||||
user = _find_or_link_user(db, phone, huihui_user_id)
|
||||
if phone:
|
||||
user.phone = phone
|
||||
if info.get("nickname"):
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Tests for preserving local avatar ownership when Huihui IDs change."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from database import Base
|
||||
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
|
||||
from routers.huihui_auth import _issue_session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
session = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def _add_avatar_data(db, owner_id: str, suffix: str = "1") -> Avatar:
|
||||
avatar = Avatar(id=f"avatar-{suffix}", owner_id=owner_id, name="冯医生")
|
||||
db.add_all(
|
||||
[
|
||||
avatar,
|
||||
TakeoverCursor(id=f"cursor-{suffix}", avatar_id=avatar.id, owner_id=owner_id),
|
||||
TakeoverMessage(
|
||||
id=f"message-{suffix}",
|
||||
avatar_id=avatar.id,
|
||||
owner_id=owner_id,
|
||||
boxim_message_id=f"box-{suffix}",
|
||||
peer_id="peer",
|
||||
direction="incoming",
|
||||
send_time=datetime(2026, 8, 20, 12, 0, 0),
|
||||
),
|
||||
TakeoverReplyTask(
|
||||
id=f"task-{suffix}",
|
||||
avatar_id=avatar.id,
|
||||
owner_id=owner_id,
|
||||
peer_id="peer",
|
||||
trigger_message_id=f"trigger-{suffix}",
|
||||
scheduled_at=datetime(2026, 8, 20, 12, 0, 3),
|
||||
boxim_local_id=f"local-{suffix}",
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
return avatar
|
||||
|
||||
|
||||
def _assert_avatar_data_owner(db, avatar_id: str, owner_id: str):
|
||||
assert db.query(Avatar).filter_by(id=avatar_id).one().owner_id == owner_id
|
||||
assert db.query(TakeoverCursor).filter_by(avatar_id=avatar_id).one().owner_id == owner_id
|
||||
assert db.query(TakeoverMessage).filter_by(avatar_id=avatar_id).one().owner_id == owner_id
|
||||
assert db.query(TakeoverReplyTask).filter_by(avatar_id=avatar_id).one().owner_id == owner_id
|
||||
|
||||
|
||||
def test_unique_phone_user_is_reused_when_huihui_id_changes(db):
|
||||
legacy = User(
|
||||
id="legacy-local",
|
||||
huihui_user_id="fat-user-id",
|
||||
phone="18500000000",
|
||||
app_token="old-session",
|
||||
)
|
||||
db.add(legacy)
|
||||
db.commit()
|
||||
avatar = _add_avatar_data(db, legacy.huihui_user_id)
|
||||
|
||||
response = _issue_session(
|
||||
db,
|
||||
"18500000000",
|
||||
{"userId": "prod-user-id", "nickname": "用户", "token": "prod-token"},
|
||||
)
|
||||
|
||||
users = db.query(User).all()
|
||||
assert len(users) == 1
|
||||
assert users[0].id == "legacy-local"
|
||||
assert users[0].huihui_user_id == "prod-user-id"
|
||||
assert response["data"]["token"] == users[0].app_token
|
||||
_assert_avatar_data_owner(db, avatar.id, "prod-user-id")
|
||||
|
||||
|
||||
def test_existing_production_user_claims_one_legacy_phone_account(db):
|
||||
current = User(
|
||||
id="prod-local",
|
||||
huihui_user_id="prod-user-id",
|
||||
phone="18500000000",
|
||||
)
|
||||
legacy = User(
|
||||
id="legacy-local",
|
||||
huihui_user_id="fat-user-id",
|
||||
phone="18500000000",
|
||||
app_token="old-session",
|
||||
huihui_token="fat-token",
|
||||
)
|
||||
db.add_all([current, legacy])
|
||||
db.commit()
|
||||
avatar = _add_avatar_data(db, legacy.huihui_user_id)
|
||||
|
||||
_issue_session(
|
||||
db,
|
||||
"18500000000",
|
||||
{"userId": "prod-user-id", "nickname": "用户", "token": "prod-token"},
|
||||
)
|
||||
|
||||
db.refresh(legacy)
|
||||
assert legacy.app_token == ""
|
||||
assert legacy.huihui_token == ""
|
||||
_assert_avatar_data_owner(db, avatar.id, "prod-user-id")
|
||||
|
||||
|
||||
def test_ambiguous_phone_matches_do_not_move_existing_avatars(db):
|
||||
first = User(id="first", huihui_user_id="fat-1", phone="18500000000")
|
||||
second = User(id="second", huihui_user_id="fat-2", phone="18500000000")
|
||||
db.add_all([first, second])
|
||||
db.commit()
|
||||
first_avatar = _add_avatar_data(db, first.huihui_user_id, "1")
|
||||
second_avatar = _add_avatar_data(db, second.huihui_user_id, "2")
|
||||
|
||||
_issue_session(
|
||||
db,
|
||||
"18500000000",
|
||||
{"userId": "prod-user-id", "nickname": "用户", "token": "prod-token"},
|
||||
)
|
||||
|
||||
assert db.query(User).count() == 3
|
||||
_assert_avatar_data_owner(db, first_avatar.id, "fat-1")
|
||||
_assert_avatar_data_owner(db, second_avatar.id, "fat-2")
|
||||
Reference in New Issue
Block a user