--- Dev UserEmail
+++ Dev UserEmail
@@ -9,18 +9,18 @@
== 정책
* 초기 UI에서는 로그인 이메일 삭제를 제공하지 않는다. 최소 하나의 로그인 이메일은 migration, login, 계정 연결 flow에서 유지한다.
* Google OAuth profile email만 로그인 이메일로 연결한다. Google People API의 여러 verified email을 자동 등록하지 않는다.
* 대표 이메일은 `UserEmail.isPrimary = true`로 표현한다.
* 병합 후 duplicate `User` row는 주요 FK와 `UserEmail`을 canonical `User.seq`로 이동한 뒤 삭제한다.
== 구현 결과
* `User` 모델은 이메일을 직접 갖지 않고, `UserEmail` 모델이 이메일 조회, 추가, primary 설정, 중복 확인을 담당한다. 로그인은 `UserEmail.email`로 user를 찾고, 없으면 새 `User`와 첫 `UserEmail`을 만든다.
* 세션은 `seq`, `nickname`, `loginEmail` 중심으로 정리했다. identity 판단은 `seq`로 하고, `loginEmail`은 표시나 감사 목적의 보조 정보다. 다만 `UserEmail` 행이 하나도 없는 사용자에게는 `WikiPermission` 이 `loginEmail` 을 권한 actor 로 대신 쓴다.
* 계정 설정 화면에 로그인 이메일 목록과 Google 계정 연결 버튼을 추가했다. 연결된 이메일이 다른 user 소유이면 병합 확인 화면으로 이동한다.
- * 병합 로직은 DB 메타데이터에서 `User(seq)` 참조 FK를 읽어 duplicate user의 참조를 canonical user로 옮긴다. `UserEmail`도 canonical user로 이동하고, 이동이 끝나면 duplicate `User` row를 삭제한다.
+ * 병합 로직은 DB 메타데이터에서 `User(seq)` 참조 FK를 읽어 duplicate user의 참조를 canonical user로 옮긴다. `UserEmail`도 canonical user로 이동하고, 이동이 끝나면 duplicate `User` row를 삭제한다. 다만 (user, site) 유니크 키를 가진 테이블(`UserSite`·`SiteAdmin`)은 그대로 옮기면 키가 충돌하므로, canonical 이 이미 가진 site 는 duplicate 행을 지우고 나머지만 옮긴다.
* `Permission`은 현재 user의 모든 로그인 이메일을 actor 후보로 사용하도록 변경했다. `Exact`, `Domain`, `Login` 권한이 여러 로그인 이메일 기준으로 동작한다.
- * `UserMergeSpec`가 FK 이동(`Page`, `AccessLog`), `UserEmail` 이동, primary 하나 유지, duplicate `User` 삭제를 검증한다. `UserSite` 충돌 처리도 검증했었지만 evolution 55 가 그 테이블을 지워 2026-08-10 에 스펙에서 뺐다 — `UserMerge.mergeUserSiteIfPresent` 는 테이블이 있을 때만 도는 가드 뒤에 남아 있다.
+ * `UserMergeSpec`가 FK 이동(`Page`, `AccessLog`), `UserEmail` 이동, primary 하나 유지, duplicate `User` 삭제, 그리고 두 사용자가 같은 site 의 관리자일 때 `SiteAdmin` 행이 충돌 없이 하나로 합쳐지는 것을 검증한다. (user, site) 충돌 처리는 `UserMerge.mergeUniqueUserSiteRows` 하나로 `UserSite`·`SiteAdmin` 에 함께 쓴다 — `UserSite` 는 evolution 55 가 지웠지만 테이블이 있을 때만 도는 가드 뒤에 남아 있고, `SiteAdmin` 은 2026-09-15 부터 같은 처리를 받는다. 전에는 `SiteAdmin` 겹침(둘 다 같은 site 관리자)이 primary key 를 위반해 병합 전체를 실패시켰다.
== 운영 확인
배포 전에는 DB를 백업하고 staging에서 migration rehearsal을 수행한다. `UserEmail.email` unique 제약, 기존 user 수와 migration된 primary email 수, 현재 DB의 `User(seq)` 참조 FK 목록, Google OAuth Console의 `/google/oauth/callback` redirect URI 등록 여부를 확인한다.
배포 후에는 대표 계정의 여러 이메일로 각각 로그인하고, 관리자 권한, 이메일 기반 page permission, 계정 설정의 로그인 이메일 목록, 병합 flow를 확인한다.