CRITICAL: listUsers, userFavorites, userReviews, userMemos 4개 GET이 인증만 요구하고 admin 검사가 없어, 일반 사용자 토큰으로 전체 사용자 목록 및 타인 활동 조회 가능했음. 각 메서드 첫 줄에 AuthUtil.requireAdmin() 호출 추가 → non-admin은 403. 함께 커밋(이전 미커밋 작업): - Logger 등록 (감사 로그용) - AuthUtil/Logger/HttpStatus/ResponseStatusException import 정리 - updateAdmin: 자기 자신 admin 변경 차단 + 감사 로그 (이미 동작 중이던 변경이나 git 미커밋 상태였음) 문서: - 설계서 §3 인수조건에 권한 강제 항목 추가, 상태 Draft → Approved - CHANGELOG.md 2026-06-15 핫픽스 항목 추가 검증: - Anonymous GET /api/admin/users → 403 ✓ - Bad-token GET /api/admin/users → 403 ✓ - 백엔드 빌드 성공, tasteby-api 재시작 완료 Refs: #267
206 lines
13 KiB
Markdown
206 lines
13 KiB
Markdown
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
|
|
|
|
# 설계서: 백엔드 - 사용자 관리 (#267)
|
|
|
|
> **상태**: Approved <!-- Draft | Approved | Superseded -->
|
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
|
> **추적성** — Redmine: #267 · 관련 ADR: 없음
|
|
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/UserService.java`, `backend-java/src/main/java/com/tasteby/controller/AdminUserController.java`, `backend-java/src/main/java/com/tasteby/mapper/UserMapper.java`, `backend-java/src/main/resources/mybatis/mapper/UserMapper.xml` · 테스트: TBD (현재 없음)
|
|
|
|
## 1. 목적 (Why)
|
|
관리자(`is_admin=1`)가 가입 사용자 목록과 각 사용자의 활동(즐겨찾기·리뷰·메모)을 조회하고, 관리자 권한을 부여/회수할 수 있도록 한다. 또한 인증(#266) 흐름에서 호출되는 사용자 upsert/조회의 단일 책임 지점을 제공한다.
|
|
|
|
## 2. 범위 (Scope)
|
|
- **포함**
|
|
- 사용자 upsert/조회 도메인 서비스 (`UserService.findOrCreate`, `findById`).
|
|
- 관리자 전용 사용자 목록 조회(페이징, 활동 카운트 포함) — `GET /api/admin/users`.
|
|
- 사용자별 즐겨찾기/리뷰/메모 조회 — `GET /api/admin/users/{userId}/{favorites|reviews|memos}`.
|
|
- 관리자 권한 토글 — `PATCH /api/admin/users/{userId}/admin`.
|
|
- **제외 (out of scope)**
|
|
- 회원 탈퇴/익명화, 개인정보 수정 API.
|
|
- 일반 사용자가 자기 프로필을 수정하는 API.
|
|
- 사용자 자체 검색(이름/이메일).
|
|
- 즐겨찾기/리뷰/메모의 CRUD (각 도메인 서비스 책임).
|
|
- Google 토큰 검증 (#266 책임).
|
|
|
|
## 3. 인수조건 (Acceptance Criteria)
|
|
- [x] 관리자 토큰으로 `GET /api/admin/users?limit=&offset=` 호출 시 `{ users:[…], total:n }` 구조와 각 사용자의 `favoriteCount/reviewCount/memoCount`가 포함된다.
|
|
- [x] `/api/admin/users/**` 모든 엔드포인트(GET 4종 + PATCH)는 진입 시 `AuthUtil.requireAdmin()`을 호출하여 비관리자 토큰에 대해 403을 반환한다. (보안 핫픽스 2026-06-15)
|
|
- [ ] `findOrCreate`는 `(provider, providerId)`로 기존 사용자가 있으면 `last_login_at`만 갱신하고, 없으면 신규 PK로 INSERT한다.
|
|
- [ ] `PATCH /api/admin/users/{userId}/admin {admin: true|false}` 호출 시 자기 자신의 권한을 변경하면 400을 반환한다.
|
|
- [ ] 존재하지 않는 사용자 ID로 `updateAdmin` 호출 시 404를 반환한다.
|
|
- [ ] 관리자 권한 변경 후 응답에 `{ success:true, user_id, is_admin }`이 포함되고, 서버 로그에 변경자/대상/값이 기록된다.
|
|
|
|
## 4. 컨텍스트 & 제약
|
|
- **의존성**
|
|
- Oracle 23ai 테이블: `tasteby_users`, `user_favorites`, `user_reviews`, `user_memos`.
|
|
- MyBatis (`UserMapper.xml`) — resultMap으로 UPPERCASE 컬럼 → 도메인 매핑.
|
|
- `ReviewService`, `MemoService` — 사용자별 활동 조회 위임.
|
|
- `AuthUtil.requireAdmin()` — JWT의 admin 클레임 검사.
|
|
- **제약**
|
|
- 모든 `/api/admin/**` 엔드포인트는 관리자만 호출 가능.
|
|
- 페이징 기본 `limit=50, offset=0`. 상한 강제 없음(향후 캡 고려).
|
|
- `is_admin`은 Oracle NUMBER(0/1) → Java boolean (`@JsonProperty("is_admin")`).
|
|
- 트랜잭션: upsert 및 권한 변경은 `@Transactional`.
|
|
- **가정**
|
|
- `UserInfo.id`는 `IdGenerator.newId()` 32-char hex로 사전 발급.
|
|
- `findAllWithCounts`는 LEFT JOIN으로 활동 미존재 시 0 반환.
|
|
|
|
## 5. 아키텍처 개요
|
|
- **모듈/파일**
|
|
- `controller/AdminUserController.java` — 관리자 전용 엔드포인트.
|
|
- `service/UserService.java` — upsert·조회·권한 변경.
|
|
- `mapper/UserMapper.java` + `resources/mybatis/mapper/UserMapper.xml` — SQL 매핑.
|
|
- `domain/UserInfo.java` — Lombok DTO.
|
|
- **데이터 흐름**
|
|
|
|
```
|
|
[Admin Client]
|
|
│ GET /api/admin/users?limit&offset (Bearer JWT, is_admin=true)
|
|
▼
|
|
AdminUserController.listUsers
|
|
├─ UserService.findAllWithCounts(limit, offset)
|
|
│ └─ UserMapper.xml: SELECT u.* + LEFT JOIN (fav/rev/memo COUNT)
|
|
└─ UserService.countAll
|
|
▼
|
|
{ users:[UserInfo], total }
|
|
|
|
[Admin Client]
|
|
│ PATCH /api/admin/users/{id}/admin {admin}
|
|
▼
|
|
AdminUserController.updateAdmin
|
|
├─ AuthUtil.requireAdmin() (자기 자신 변경 거부)
|
|
└─ UserService.updateAdmin → UserMapper.updateAdmin
|
|
▼
|
|
{ success, user_id, is_admin }
|
|
|
|
[AuthService (#266)]
|
|
▼ UserService.findOrCreate
|
|
├─ findByProviderAndProviderId → updateLastLogin
|
|
└─ insert + findById
|
|
```
|
|
|
|
- **I/O ↔ 순수 로직 경계**
|
|
- I/O: MyBatis Mapper(DB).
|
|
- 순수: 권한 변경 시 자기 자신 검사, boolean→int 변환.
|
|
|
|
## 6. 데이터 모델
|
|
- **`UserInfo`** (`domain/UserInfo.java`)
|
|
- `id: String`, `email: String`, `nickname: String`, `avatarUrl: String`, `admin: boolean(@JsonProperty("is_admin"))`, `provider: String`, `providerId: String`, `createdAt: String`, `favoriteCount/reviewCount/memoCount: int`.
|
|
- **저장(`tasteby_users`)**
|
|
- `id PK(32)`, `provider VARCHAR`, `provider_id VARCHAR`, `email`, `nickname`, `avatar_url`, `is_admin NUMBER(1)`, `created_at TIMESTAMP`, `last_login_at TIMESTAMP`.
|
|
- 유니크(가정): `(provider, provider_id)`.
|
|
- **입력**
|
|
- `GET /api/admin/users`: query `limit:int(=50)`, `offset:int(=0)`.
|
|
- `PATCH /api/admin/users/{userId}/admin`: body `{ admin: boolean }`.
|
|
- **출력**
|
|
- 목록: `{ users: UserInfo[], total: int }`.
|
|
- 사용자 즐겨찾기/리뷰/메모: `Restaurant[]`, `Review[]`, `Memo[]`.
|
|
- 권한 변경: `{ success:true, user_id, is_admin }`.
|
|
- **경계 검증**
|
|
- `limit/offset` 정수, 음수 가드 없음 — 호출자 신뢰. Oracle FETCH NEXT가 0/음수 시 결과 0건.
|
|
- body.admin이 null → `Boolean.TRUE.equals(null)` = false 처리.
|
|
|
|
## 7. 함수 명세 (Function Specs)
|
|
|
|
### UserService (public)
|
|
|
|
| 함수 | 책임(1줄) | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? |
|
|
|------|-----------|----------|------|------|-----------|-------|
|
|
| `UserService(UserMapper)` | DI | 생성자 | DI 빈 | 인스턴스 | 없음 | 단순 |
|
|
| `findOrCreate` | provider+providerId로 사용자 upsert + 마지막 로그인 갱신 | `UserInfo findOrCreate(String provider, String providerId, String email, String nickname, String avatarUrl)` | 5개 문자열 | `UserInfo` | DB 예외 → 500 | **복잡** (분기 + 트랜잭션) |
|
|
| `findById` | PK로 단건 조회 | `UserInfo findById(String userId)` | userId | `UserInfo` or null | DB 예외 → 500 | 단순 |
|
|
| `findAllWithCounts` | 활동 카운트 포함 페이징 목록 | `List<UserInfo> findAllWithCounts(int limit, int offset)` | 페이징 | 사용자 리스트 | DB 예외 → 500 | 단순 |
|
|
| `countAll` | 전체 사용자 수 | `int countAll()` | 없음 | int | DB 예외 → 500 | 단순 |
|
|
| `updateAdmin` | 관리자 플래그 변경 | `void updateAdmin(String userId, boolean admin)` | userId, boolean | void | 미존재 → 404 | 단순 |
|
|
|
|
### UserMapper (public, MyBatis)
|
|
|
|
| 함수 | 책임 | 시그니처 | 출력 |
|
|
|------|------|----------|------|
|
|
| `findByProviderAndProviderId` | provider+providerId 조회 | `UserInfo findByProviderAndProviderId(String provider, String providerId)` | `UserInfo`/null |
|
|
| `updateLastLogin` | last_login_at = SYSTIMESTAMP | `void updateLastLogin(String id)` | void |
|
|
| `insert` | 신규 사용자 INSERT | `void insert(UserInfo user)` | void |
|
|
| `findById` | PK 조회 | `UserInfo findById(String id)` | `UserInfo`/null |
|
|
| `findAllWithCounts` | 활동 COUNT 조인 페이징 | `List<UserInfo> findAllWithCounts(int limit, int offset)` | 목록 |
|
|
| `countAll` | 전체 카운트 | `int countAll()` | int |
|
|
| `updateAdmin` | `is_admin` UPDATE | `int updateAdmin(String id, int admin)` | 영향 행 수 |
|
|
|
|
### AdminUserController (public)
|
|
|
|
| 함수 | 책임 | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? |
|
|
|------|------|----------|------|------|-----------|-------|
|
|
| `AdminUserController(...)` | DI | 생성자 | DI 빈 | 인스턴스 | 없음 | 단순 |
|
|
| `listUsers` | `GET /api/admin/users` | `Map<String,Object> listUsers(int limit=50, int offset=0)` | 페이징 | `{users,total}` | 인증 실패 → 401/403 | 단순 |
|
|
| `userFavorites` | `GET …/{userId}/favorites` | `List<Restaurant> userFavorites(String userId)` | userId | 즐겨찾기 식당 | 인증/위임 | 단순 |
|
|
| `userReviews` | `GET …/{userId}/reviews` | `List<Review> userReviews(String userId)` | userId | 리뷰 100건 | 인증/위임 | 단순 |
|
|
| `userMemos` | `GET …/{userId}/memos` | `List<Memo> userMemos(String userId)` | userId | 메모 | 인증/위임 | 단순 |
|
|
| `updateAdmin` | `PATCH …/{userId}/admin` | `Map<String,Object> updateAdmin(String userId, Map<String,Boolean> body)` | userId, `{admin}` | `{success,user_id,is_admin}` | 자기 자신 → 400, 미존재 → 404 | **복잡** (정책 분기) |
|
|
|
|
## 8. 흐름 / 알고리즘
|
|
**A. Upsert (`findOrCreate`)**
|
|
1. `findByProviderAndProviderId(provider, providerId)` 호출.
|
|
2. 존재 → `updateLastLogin(id)` → `findById(id)` 반환.
|
|
3. 미존재 → `IdGenerator.newId()`로 PK 발급 → `UserInfo` 빌드 → `insert` → `findById(newId)` 반환.
|
|
4. 전체 트랜잭션(`@Transactional`)으로 묶여 부분 실패 시 롤백.
|
|
|
|
**B. 관리자 목록 (`listUsers`)**
|
|
1. 컨트롤러에서 limit/offset 기본값 적용.
|
|
2. `findAllWithCounts`로 `LEFT JOIN ( user_favorites|user_reviews|user_memos GROUP BY user_id )` + `ORDER BY created_at DESC OFFSET ? FETCH NEXT ?`.
|
|
3. `countAll`로 전체 수 합산하여 `{ users, total }` 반환.
|
|
|
|
**C. 권한 변경 (`updateAdmin`)**
|
|
1. `AuthUtil.requireAdmin()` — JWT의 admin 클레임 미보유 시 403.
|
|
2. `userId == currentUser.subject` → 400 ("자기 자신의 관리자 권한은 변경할 수 없습니다").
|
|
3. body.admin → boolean (null=false).
|
|
4. `UserService.updateAdmin` → Mapper에서 `UPDATE … WHERE id = ?`.
|
|
5. 영향 행 0 → 404, 1 → 감사 로그 `[ADMIN] User {} set admin={} for user {}` 출력 후 성공 응답.
|
|
|
|
## 9. 엣지케이스 & 에러 처리
|
|
- **자기 자신 권한 변경**: 명시적으로 400으로 차단(마지막 관리자 사고 방지).
|
|
- **존재하지 않는 사용자 권한 변경**: Mapper 영향 행 0 → 404.
|
|
- **음수 limit/offset**: Oracle은 OFFSET 0 ROWS FETCH NEXT 음수 시 결과 0건. 클라이언트 신뢰.
|
|
- **활동 카운트 0**: `NVL(…, 0)`으로 0으로 노출.
|
|
- **`findById` race(인증 직후 삭제)**: null 반환 → 호출자(AuthService)에서 404.
|
|
- **email/nickname null** (Google에서 일부 제공 안 함): 컬럼 NULL 허용 가정. UI에서 빈 값 처리.
|
|
- **권한 변경 동시성**: 트랜잭션 + 단일 UPDATE이므로 마지막 쓰기 승리(last-write-wins). 감사 로그로 추적.
|
|
- **안전한 기본값**: 권한 변경 실패 시 변경 없음.
|
|
|
|
## 10. 테스트 계획
|
|
- **현 상태**: 자동화 테스트 없음 (TBD).
|
|
- **단위 테스트** (Mockito)
|
|
- `UserService.findOrCreate`
|
|
- 기존 사용자 → `updateLastLogin` + `findById` 호출 검증.
|
|
- 신규 사용자 → `insert` + `findById(newId)` 호출 검증.
|
|
- `UserService.updateAdmin`
|
|
- 영향 행 1 → 정상.
|
|
- 영향 행 0 → 404 예외.
|
|
- **컨트롤러 통합 테스트** (MockMvc + `@MockBean`)
|
|
- `GET /api/admin/users` 정상/페이징 파라미터.
|
|
- `PATCH /admin` 자기 자신 → 400, 미존재 → 404, 정상 → 200 + 응답 구조.
|
|
- 비-관리자 토큰 → 403.
|
|
- **Mapper 통합** (`@MybatisTest` + Testcontainers Oracle)
|
|
- `findByProviderAndProviderId` 일치/미일치.
|
|
- `findAllWithCounts` 활동 카운트 정확성.
|
|
- **모킹 전략**: `AuthUtil` 정적 메서드는 `Mockito.mockStatic`으로 stub.
|
|
|
|
## 11. 리스크 & 대안 검토
|
|
- **선택**: 관리자 전용 엔드포인트 분리 + 일반 사용자용 프로필 API 없음.
|
|
- 장점: 권한 경계 단순.
|
|
- 단점: 일반 사용자가 자기 프로필 수정 시 별도 API 신설 필요.
|
|
- **대안 1**: `is_admin` 외 RBAC(role 테이블).
|
|
- 트레이드오프: 복잡도 ↑. 현 규모에서는 과설계.
|
|
- **대안 2**: 활동 카운트를 캐시/뷰로 분리.
|
|
- 트레이드오프: 사용자 수 증가 시 JOIN 비용 ↑ — 그때 도입.
|
|
- **되돌리기 어려운 결정**: `is_admin` boolean → 다중 역할로 확장 시 마이그레이션 필요.
|
|
- **운영 리스크**
|
|
- 마지막 관리자 권한 회수: 자기 자신 차단으로 부분 보호. 다른 관리자가 회수하면 무관리자 상태 가능 → 향후 "최소 1명 admin 유지" 가드 고려.
|
|
- 사용자 페이징에 상한 없음 → 큰 limit으로 메모리 압박 가능.
|
|
|
|
## 12. 미해결 질문 (Open Questions)
|
|
- 일반 사용자가 자기 프로필(닉네임/아바타)을 수정하는 API를 어디에 둘 것인지(`UserController` 신설 vs `/api/auth/me PATCH`).
|
|
- 사용자 검색(이메일/닉네임 부분 일치)을 관리자 화면에 추가할 것인지.
|
|
- "최소 1명 admin 유지" 가드를 도입할지, 운영 정책으로만 둘지.
|
|
- 활동 카운트 페이지 캐싱 TTL을 도입할지(현재 Redis 캐시 미적용).
|
|
- 회원 탈퇴/익명화 정책과 개인정보 보관기간.
|