feat(req-002): implement portfolio tagging and tag filters

This commit is contained in:
2026-04-14 01:06:10 +09:00
parent 2dab17cbb1
commit a776ea3b1d
20 changed files with 1033 additions and 32 deletions
@@ -1,6 +1,6 @@
# REQ-002: 포트폴리오 태깅 및 태그 기반 검색 지원
- Status: Proposed
- Status: Approved
- Type: Interface
- Priority: Medium
- Owner: Justin
@@ -12,27 +12,30 @@
## Requirement
- 시스템은 각 포트폴리오에 대해 하나 이상의 태그를 추가하고 제거할 수 있어야 한다.
- 시스템은 HTTP API와 CLI를 통해 동일한 포트폴리오 태깅 기능을 제공해야 한다.
- 시스템은 태그 추가 시 입력 문자열의 앞뒤 공백을 제거한 값을 저장 및 비교 기준으로 사용해야 하며, 정규화 결과가 빈 문자열이면 validation 오류를 반환해야 한다.
- 시스템은 태그 문자열의 대소문자, 내부 공백, 네임스페이스 구분자(`:`)를 정규화 과정에서 변경하지 않아야 하며, 앞뒤 공백 제거 외의 추가 정규화는 수행하지 않아야 한다.
- 시스템은 둘 이상의 태그를 조건으로 포트폴리오를 검색할 수 있어야 하며, 검색 요청에서 `AND` 또는 `OR` 매칭 방식을 선택할 수 있어야 한다.
- 시스템은 다운스트림 소비자가 태그 기반 목록 조회 시 사용할 수 있도록 포함 태그(`includeTags`)와 제외 태그(`excludeTags`)를 동시에 지원해야 한다.
- 시스템은 HTTP API의 포트폴리오 목록 조회 인터페이스와 CLI의 동일 의미 인터페이스에서 포함 태그(`includeTags`), 제외 태그(`excludeTags`), 포함 태그 매칭 방식(`matchMode`: `AND` 또는 `OR`)을 동시에 지원해야 한다.
- 시스템은 `matchMode`를 `includeTags` 집합에만 적용해야 하며, `excludeTags`는 요청된 태그 중 하나라도 일치하면 해당 포트폴리오를 결과에서 제외해야 한다.
- 시스템은 태그 기반 조회에서 `grade:*`, `blacklist:*`와 같은 태그 네임스페이스를 그대로 보존하고 필터 조건으로 사용할 수 있어야 한다.
- 시스템은 공백만으로 이루어지지 않은 태그 문자열을 추가 대상으로 받아들여야 한다.
- 시스템은 동일 포트폴리오에 이미 연결된 동일한 태그 문자열을 다시 추가하려는 요청에 대해 `409 Conflict` 또는 이에 대응하는 CLI 오류를 반환해야 하며, 기존 태그 상태를 변경하지 않아야 한다.
- 시스템은 동일 포트폴리오에 이미 연결된 태그와 앞뒤 공백 제거 후 동일한 태그 문자열을 다시 추가하려는 요청에 대해 `409 Conflict` 또는 이에 대응하는 CLI 오류를 반환해야 하며, 기존 태그 상태를 변경하지 않아야 한다.
- 시스템은 태그 추가·삭제·검색 요청에 대해 기존 포트폴리오 접근 권한 정책을 적용해야 하며, 인증 실패 또는 권한 부족 시 기존 인증 오류 계약에 따라 요청을 거부해야 한다.
- 시스템은 존재하지 않는 `portfolio_id`를 대상으로 한 태그 추가·삭제 요청에 대해 Not Found 오류를 반환해야 한다.
- 시스템은 태그 검색 결과에서 각 포트폴리오를 중복 없이 한 번만 반환해야 한다.
- 시스템은 태그 문자열 길이 또는 포트폴리오당 태그 개수에 대해 애플리케이션 정의 최대 제한만을 이유로 요청을 거부하지 않아야 한다.
## Acceptance Criteria
1. Given 기존 포트폴리오가 존재하고 권한이 있는 사용자가 API 또는 CLI로 새 태그를 추가할 때, When 공백만으로 이루어지지 않은 태그 문자열을 제출하면, Then 시스템은 해당 포트폴리오에 태그를 연결하고 후속 조회에서 그 태그가 포함된 상태를 반환해야 한다.
1. Given 기존 포트폴리오가 존재하고 권한이 있는 사용자가 API 또는 CLI로 새 태그를 추가할 때, When 공백만으로 이루어지지 않은 태그 문자열을 제출하면, Then 시스템은 입력값의 앞뒤 공백을 제거한 태그를 해당 포트폴리오에 연결하고 후속 조회에서 그 정규화된 태그가 포함된 상태를 반환해야 한다.
2. Given 특정 포트폴리오에 이미 연결된 태그가 있을 때, When 사용자가 API 또는 CLI로 해당 태그를 제거하면, Then 시스템은 성공 응답을 반환하고 후속 조회 및 태그 검색 결과에서 그 연결을 노출하지 않아야 한다.
3. Given 여러 포트폴리오에 서로 다른 태그 조합이 연결되어 있을 때, When 사용자가 API 또는 CLI로 둘 이상의 태그와 `AND` 모드를 지정해 검색하면, Then 시스템은 요청한 모든 태그를 포함한 포트폴리오만 반환해야 한다.
4. Given 여러 포트폴리오에 서로 다른 태그 조합이 연결되어 있을 때, When 사용자가 API 또는 CLI로 둘 이상의 태그와 `OR` 모드를 지정해 검색하면, Then 시스템은 요청한 태그 중 하나 이상을 포함한 포트폴리오를 반환해야 한다.
5. Given 동일 포트폴리오에 이미 동일한 태그 문자열이 존재할 때, When 사용자가 API 또는 CLI로 같은 태그를 다시 추가하면, Then 시스템은 `409 Conflict` 응답 또는 이에 대응하는 CLI 오류를 반환하고 중복 태그 연결을 생성하지 않아야 한다.
3. Given 여러 포트폴리오에 서로 다른 태그 조합이 연결되어 있을 때, When 사용자가 API 또는 CLI로 `includeTags`에 둘 이상의 태그와 `matchMode=AND`를 지정해 검색하면, Then 시스템은 요청한 모든 포함 태그를 가진 포트폴리오만 반환해야 한다.
4. Given 여러 포트폴리오에 서로 다른 태그 조합이 연결되어 있을 때, When 사용자가 API 또는 CLI로 `includeTags`에 둘 이상의 태그와 `matchMode=OR`를 지정해 검색하면, Then 시스템은 요청한 포함 태그 중 하나 이상을 가진 포트폴리오를 반환해야 한다.
5. Given 동일 포트폴리오에 이미 앞뒤 공백 제거 후 동일한 태그 문자열이 존재할 때, When 사용자가 API 또는 CLI로 같은 태그를 다시 추가하면, Then 시스템은 `409 Conflict` 응답 또는 이에 대응하는 CLI 오류를 반환하고 중복 태그 연결을 생성하지 않아야 한다.
6. Given 인증되지 않았거나 해당 포트폴리오에 접근 권한이 없는 사용자가 API 또는 CLI로 태그 추가·삭제·검색을 시도할 때, When 요청을 전송하면, Then 시스템은 기존 인증/인가 계약에 따라 `401` 또는 `403` 응답을 반환하고 태그 상태를 변경하지 않아야 한다.
7. Given 존재하지 않는 `portfolio_id`가 요청에 포함될 때, When 사용자가 API 또는 CLI로 태그 추가 또는 삭제를 시도하면, Then 시스템은 `404` 응답 또는 이에 대응하는 CLI 오류를 반환하고 어떤 태그 연결도 생성·변경하지 않아야 한다.
8. Given 포트폴리오에 이미 여러 태그가 연결되어 있고 사용자가 긴 태그 문자열을 추가할 때, When API 또는 CLI로 태그 추가를 요청하면, Then 시스템은 애플리케이션 정의 최대 길이 제한 또는 최대 개수 제한만을 이유로 요청을 거부하지 않아야 한다.
8. Given 포트폴리오에 이미 여러 태그가 연결되어 있고 사용자가 긴 태그 문자열을 추가할 때, When API 또는 CLI로 태그 추가를 요청하면, Then 시스템은 앞뒤 공백 제거 후 빈 문자열이 아닌 한 애플리케이션 정의 최대 길이 제한 또는 최대 개수 제한만을 이유로 요청을 거부하지 않아야 한다.
9. Given `OR` 모드의 다중 태그 검색에서 하나의 포트폴리오가 두 개 이상의 요청 태그와 동시에 일치할 때, When 사용자가 검색을 수행하면, Then 시스템은 해당 포트폴리오를 결과에 한 번만 반환해야 한다.
10. Given 소비 서비스가 `includeTags=["grade:A", "grade:B"]`, `excludeTags=["blacklist:trade"]` 조건으로 목록 조회를 요청한 상태, When 태그 필터 검색을 수행하면, Then 시스템은 포함 조건을 만족하고 제외 조건을 만족하지 않는 항목만 반환해야 한다.
10. Given 소비 서비스가 `includeTags=["grade:A", "grade:B"]`, `excludeTags=["blacklist:trade"]`, `matchMode=OR` 조건으로 목록 조회를 요청한 상태, When 태그 필터 검색을 수행하면, Then 시스템은 `grade:A` 또는 `grade:B` 중 하나 이상을 포함하고 `blacklist:trade`를 하나라도 포함하지 않는 항목만 반환해야 한다.
## Impacted Area
- Docs: `docs/technical/api-contracts.md`, `docs/technical/persistence-and-migrations.md`, `docs/SRS/index.md`, `docs/SRS/traceability-matrix.md`
@@ -43,10 +46,74 @@
- 본 requirement의 검색 범위는 다중 태그 검색을 포함하며, 호출자는 `AND` 또는 `OR` 모드를 선택할 수 있다.
- 태그 최대 길이와 포트폴리오당 최대 태그 개수에 대한 애플리케이션 정의 상한은 두지 않는다.
- 동일 포트폴리오에 같은 태그 문자열을 다시 추가하는 요청은 `409 Conflict` 또는 이에 대응하는 CLI 오류로 처리한다.
- 태그 정규화는 앞뒤 공백 제거만 수행하며, 대소문자와 내부 문자열은 보존한다.
- HTTP API와 CLI는 `includeTags`, `excludeTags`, `matchMode`에 대해 동일 의미의 검색 계약을 제공한다.
- 특정 소비 서비스의 입력 고정/스냅샷 책임은 소비 서비스 측 requirement에서 관리하며, portfolio-hub는 태그 등록/검색 계약만 제공한다.
## Change Log
### CHG-20260414-02
- Date: 2026-04-14
- Author: GitHub Copilot
#### Change Summary
- REQ-002를 Proposed에서 Approved로 전환했다.
#### Intent
- 태그 정규화 규칙과 태그 검색 입력 계약이 승인 기준을 충족했음을 반영하기 위함이다.
#### Acceptance Criteria Delta
- Added:
- 없음
- Updated:
- Status를 Approved로 전환
- Removed:
- 없음
#### Impacted Area Delta
- Docs:
- `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md`
- `docs/SRS/index.md`
- `docs/SRS/traceability-matrix.md`
- Modules:
- 없음
- Tests:
- 없음
#### Validation Plan
- 승인 후 technical spec 동기화 시 REQ-002의 정규화 및 검색 계약이 관련 technical 문서에 동일하게 반영되는지 확인한다.
### CHG-20260414-01
- Date: 2026-04-14
- Author: GitHub Copilot
#### Change Summary
- REQ-002의 태그 정규화 규칙과 태그 검색 입력 계약을 구체화해 승인 blocker를 해소할 수 있도록 보강했다.
#### Intent
- 태그 중복 판단과 include/exclude 검색 동작이 구현마다 다르게 해석되지 않도록 인터페이스 계약을 고정하기 위함이다.
#### Acceptance Criteria Delta
- Added:
- 앞뒤 공백 제거 기반 태그 정규화 규칙
- `includeTags`, `excludeTags`, `matchMode`의 결합 규칙
- Updated:
- AC1, AC3, AC4, AC5, AC8, AC10의 입력/비교 기준 명확화
- Removed:
- 없음
#### Impacted Area Delta
- Docs:
- `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md`
- `docs/SRS/index.md`
- Modules:
- tag input normalization 및 검색 query parser 구현 경로
- Tests:
- 태그 정규화, 중복 추가 충돌, include/exclude 조합 검색 검증 시나리오
#### Validation Plan
- 문서 리뷰 시 태그 정규화 규칙과 `includeTags`/`excludeTags`/`matchMode` 조합 규칙이 각각 독립 테스트로 매핑 가능한지 확인한다.
### CHG-20260413-05
- Date: 2026-04-13
- Author: GitHub Copilot
+3 -3
View File
@@ -22,16 +22,16 @@
| REQ ID | Title | Type | Status | Priority | Owner | File |
|---|---|---|---|---|---|---|
| REQ-001 | 포트폴리오 댓글형 메모 이력 지원 | Interface | Approved | Medium | Justin | `docs/SRS/Interface/REQ-001_portfolio-comment-history.md` |
| REQ-002 | 포트폴리오 태깅 및 태그 기반 검색 지원 | Interface | Proposed | Medium | Justin | `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md` |
| REQ-002 | 포트폴리오 태깅 및 태그 기반 검색 지원 | Interface | Approved | Medium | Justin | `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md` |
| REQ-003 | 포트폴리오 필수 필드 및 거래소/시장 데이터 정의 | Data | Proposed | Medium | Justin | `docs/SRS/Data/REQ-003_portfolio-required-fields.md` |
| REQ-004 | 포트폴리오 소유주 기반 접근 제어 | Interface | Proposed | High | Justin | `docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md` |
## Recent Change Log Summary
- 2026-04-14 — `REQ-002`를 Approved로 전환했다.
- 2026-04-14 — `REQ-002`에 태그 정규화 규칙과 `includeTags`/`excludeTags`/`matchMode` 검색 계약을 추가해 승인 blocker를 줄였다.
- 2026-04-14 — `REQ-004`에 사용자 귀속 봇 principal(`bot_principals.owner_user_id`) 및 봇 토큰 실효 사용자 매핑 규칙(AC6, AC7)을 추가했다.
- 2026-04-13 — `REQ-004` 포트폴리오 소유주(`owner_user_id`) 및 소유권 기반 접근 제어 requirement를 추가했다.
- 2026-04-13 — `REQ-003`에서 태그 필터 기반 조회의 결정성 유지 책임을 소비 서비스(stock-picker)로 이관했다.
- 2026-04-13 — `REQ-003`에서 `sourceVersion`/`sourceTimestamp` 요구를 제거하고 태그 필터 기반 조회 결정성 계약으로 정리했다.
- 2026-04-13 — `REQ-003`의 소비자 표현을 특정 서비스 전용에서 태그 기반 조회 소비 서비스 일반 계약으로 정리했다.
## Status Transition Evidence
- `Proposed` → `Approved`: Owner가 지정되고 requirement 문서 리뷰가 완료되며 acceptance criteria가 누락 없이 검토되어야 한다.
+1 -1
View File
@@ -12,7 +12,7 @@
| REQ ID | AC Mapping | Requirement File | Implementation Issue | PR | Test Evidence | Requirement Status | Delivery Status | Last Updated |
|---|---|---|---|---|---|---|---|---|
| REQ-001 | AC1, AC2, AC3, AC4, AC5, AC6, AC7 | `docs/SRS/Interface/REQ-001_portfolio-comment-history.md` | #2 | TBD | TBD | Approved | In Progress | 2026-04-10 |
| REQ-002 | AC1, AC2, AC3, AC4, AC5, AC6, AC7, AC8, AC9 | `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md` | TBD | TBD | TBD | Proposed | Planned | 2026-04-10 |
| REQ-002 | AC1, AC2, AC3, AC4, AC5, AC6, AC7, AC8, AC9, AC10 | `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md` | TBD | TBD | `tests/test_portfolio_tags.py`, `tests/test_cli.py`, local `pytest` pass (65 passed, 2026-04-14) | Approved | Verified | 2026-04-14 |
| REQ-003 | AC1, AC2, AC3, AC4, AC5 | `docs/SRS/Data/REQ-003_portfolio-required-fields.md` | TBD | TBD | TBD | Proposed | Planned | 2026-04-10 |
| REQ-004 | AC1, AC2, AC3, AC4, AC5, AC6, AC7 | `docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md` | TBD | TBD | `tests/test_portfolio_ownership.py`, `tests/test_users.py`, `tests/test_auth.py`, `tests/test_portfolio_comments.py`, local `pytest` pass (2026-04-14) | Proposed | In Progress | 2026-04-14 |
+41 -2
View File
@@ -54,6 +54,15 @@
- `POST /portfolios/{portfolio_id}/transfer`
- `POST /portfolios/{portfolio_id}/reset`
- `DELETE /portfolios/{portfolio_id}`
- `POST /portfolios/{portfolio_id}/tags`
- `GET /portfolios/{portfolio_id}/tags`
- `DELETE /portfolios/{portfolio_id}/tags`
REQ-002 정렬 메모:
- 포트폴리오 목록 조회는 반복 query parameter `includeTags`, `excludeTags`, `matchMode`를 지원한다.
- 태그 mutation은 기존 포트폴리오 리소스 하위 경로 `/portfolios/{portfolio_id}/tags`로 구현한다.
- CLI는 `portfolio tag add|list|remove` 및 `portfolio list --include-tag/--exclude-tag/--match-mode` 계약을 제공한다.
### 포트폴리오 댓글 (`REQ-001`)
- `POST /portfolios/{portfolio_id}/comments`
@@ -106,12 +115,17 @@
- Query:
- `broker: string | null`
- `status_filter: string | null`
- `includeTags: string[] | null`
- `excludeTags: string[] | null`
- `matchMode: "AND" | "OR" | null` (`includeTags`에만 적용)
- 응답 `200`: `PortfolioListItem[]`
- 오류:
- `400`: `matchMode`가 `AND` 또는 `OR`가 아님
예시:
```bash
curl -X GET "http://localhost:8000/portfolios?broker=upbit&status_filter=enabled" \
curl -X GET "http://localhost:8000/portfolios?broker=upbit&status_filter=enabled&includeTags=grade:A&excludeTags=blacklist:trade&matchMode=OR" \
-H "Authorization: Bearer <READ_TOKEN>"
```
@@ -123,6 +137,7 @@ curl -X GET "http://localhost:8000/portfolios?broker=upbit&status_filter=enabled
"status": "enabled",
"name": "Bitcoin",
"symbol": "KRW-BTC",
"tags": ["grade:A"],
"cash_balance": 1000000.0,
"asset_quantity": 0.1,
"price": 100000000.0,
@@ -157,6 +172,7 @@ curl -X GET "http://localhost:8000/portfolios/krw-btc?recent_trades=5" \
"status": "enabled",
"name": "Bitcoin",
"symbol": "KRW-BTC",
"tags": ["grade:A"],
"owner_user_id": "user-1",
"cash_balance": 1000000.0,
"asset_quantity": 0.1,
@@ -246,6 +262,16 @@ curl -X PATCH "http://localhost:8000/portfolios/krw-btc" \
- `POST /portfolios/{portfolio_id}/transfer`: `TransferRequest` -> `PortfolioDetailResponse`
- `POST /portfolios/{portfolio_id}/reset`: `ResetPortfolioRequest` -> `PortfolioDetailResponse`
- `DELETE /portfolios/{portfolio_id}`: admin only, query `keep_ledger: bool = false`, 응답 `204`
- `POST /portfolios/{portfolio_id}/tags`: `PortfolioTagMutationRequest` -> `PortfolioTagResponse`
- `GET /portfolios/{portfolio_id}/tags`: `PortfolioTagResponse[]`
- `DELETE /portfolios/{portfolio_id}/tags`: body `PortfolioTagMutationRequest`, 응답 `204`
태그 엔드포인트 추가 규칙:
- `POST`/`DELETE`는 `trade-write`, `GET`은 `read`
- 비관리자 요청은 기존 ownership 검사를 동일하게 적용
- 입력 `tag`는 앞뒤 공백을 제거한 값을 저장/비교 기준으로 사용
- 정규화 후 빈 문자열은 `422`, 중복 태그 추가는 `409`, 포트폴리오 또는 태그 미존재는 `404`
대표 요청:
@@ -277,6 +303,19 @@ curl -X POST "http://localhost:8000/portfolios/krw-btc/reset" \
curl -X DELETE "http://localhost:8000/portfolios/krw-btc?keep_ledger=false" \
-H "Authorization: Bearer <ADMIN_TOKEN>"
curl -X POST "http://localhost:8000/portfolios/krw-btc/tags" \
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"tag": " grade:A "}'
curl -X GET "http://localhost:8000/portfolios/krw-btc/tags" \
-H "Authorization: Bearer <READ_TOKEN>"
curl -X DELETE "http://localhost:8000/portfolios/krw-btc/tags" \
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"tag": "grade:A"}'
```
#### 포트폴리오 댓글 (`REQ-001`)
@@ -703,7 +742,7 @@ curl -X POST "http://localhost:8000/users" \
## 문서 동기화 규칙
- `app/api/` 또는 `app/schemas/` 변경 시 본 문서를 같은 변경 단위에서 갱신한다.
- `REQ-002` 태그 검색 API가 구현되면 요청/응답 파라미터와 필터 의미를 본 문서에 추가한다.
- `REQ-002` 태그 검색 구현은 승인된 정규화/필터 의미와 실제 route/CLI 계약을 모두 본 문서에 유지한다.
## Open Implementation Points
- 에러 응답 body를 공통 포맷(`error_code`, `message`, `details`)으로 강제할지 결정한다.
+2 -1
View File
@@ -19,6 +19,7 @@
| `settings-and-env-loading.md` | `.env`/환경변수 로딩, 필수 키, CORS/운영 설정 규칙 | N/A |
| `architecture-overview.md` | 시스템 개요, 모듈 책임, 런타임 흐름, 테스트/호환성 기준 | `REQ-003` |
| `api-contracts.md` | HTTP API 상세 계약, request/response 모델, 대표 payload 예시 | `REQ-001`, `REQ-002`, `REQ-003` |
| `portfolio-tagging-and-search-design.md` | 태그 정규화, API/CLI 검색 파라미터, 모듈 책임, 구현 단계 설계 | `REQ-002` |
| `persistence-and-migrations.md` | PostgreSQL/Timescale 연동, 리포지토리 스위치, Alembic 운영, 스키마 baseline | `REQ-001`, `REQ-003` |
| `auth-and-token-lifecycle.md` | 로그인/토큰/봇 주체 라이프사이클 및 권한 모델 | N/A |
| `portfolio-ownership-and-auth-design.md` | 포트폴리오 소유주(`owner_user_id`) 도입, 소유권 접근 제어, 검증 시나리오 설계 | `REQ-004` |
@@ -30,7 +31,7 @@
| REQ ID | Technical Docs |
| --- | --- |
| `REQ-001` | `api-contracts.md`, `persistence-and-migrations.md` |
| `REQ-002` | `api-contracts.md` |
| `REQ-002` | `api-contracts.md`, `portfolio-tagging-and-search-design.md`, `persistence-and-migrations.md` |
| `REQ-003` | `architecture-overview.md`, `api-contracts.md`, `persistence-and-migrations.md` |
| `REQ-004` | `auth-and-token-lifecycle.md`, `api-contracts.md`, `persistence-and-migrations.md`, `portfolio-ownership-and-auth-design.md` |
+46 -2
View File
@@ -1,6 +1,6 @@
# Persistence and Migrations
- Related REQ: `REQ-001`, `REQ-003`, `REQ-004`
- Related REQ: `REQ-001`, `REQ-002`, `REQ-003`, `REQ-004`
## 목표
DB 기반 영속성 경로를 안정적으로 운영하고, 스키마 변경 이력과 문서 동기화를 일관되게 유지한다.
@@ -257,6 +257,48 @@ Conditional migration behavior:
- application layer cascade:
- 포트폴리오 삭제 시 연관 `portfolio_comments`를 hard delete 한다.
## REQ-002 Tagging Storage Design
목표:
- 포트폴리오별 태그 연결을 중복 없이 저장한다.
- 앞뒤 공백 제거 기반 정규화와 검색용 include/exclude 조건을 DB 계층에서 일관되게 지원한다.
구현 저장 모델:
- 신규 테이블 `portfolio_tags`
- 복합 PK(`portfolio_id`, `tag_value`) 사용
- `portfolio_id`: 대상 포트폴리오
- `tag_value`: 앞뒤 공백 제거 후 저장되는 태그 값
- `created_at`: 연결 생성 시각
- 동일 포트폴리오 내 중복 태그는 PK 자체로 방지한다.
정규화 책임:
- API/CLI 입력 계층에서 앞뒤 공백 제거를 수행한다.
- repository는 이미 정규화된 `tag_value`를 기준으로 insert/delete/query를 수행한다.
- DB는 `UNIQUE (portfolio_id, tag_value)`로 중복 insert를 방지하고 application은 이를 `409 Conflict`로 매핑한다.
검색 전략:
- `includeTags`와 `excludeTags`는 모두 정규화된 태그 값 기준으로 비교한다.
- `matchMode=AND`는 `GROUP BY portfolio_id` + `HAVING COUNT(DISTINCT tag_value) = <includeTags 개수>` 전략을 기본안으로 둔다.
- `matchMode=OR`는 `WHERE tag_value IN (...)` + distinct portfolio projection을 기본안으로 둔다.
- `excludeTags`는 anti-join 또는 `NOT EXISTS` 전략을 사용한다.
권장 인덱스:
- `UNIQUE (portfolio_id, tag_value)`
- `INDEX (tag_value, portfolio_id)`
- owner-scoped 목록 필터와 결합될 수 있으므로 `portfolios.owner_user_id` 인덱스와 함께 실행 계획을 검토한다.
적용 마이그레이션 단계:
1. `20260414_0012_portfolio_tags.py`에서 `portfolio_tags` 테이블 생성
2. `fk_portfolio_tags_portfolio` FK와 `idx_portfolio_tags_tag_portfolio` 인덱스 생성
3. API/CLI/repository가 동일 revision 기준으로 태그 mutation 및 검색을 사용
4. 필요 시 exact-match 중심 추가 인덱스를 검토
## Validation Notes
- 마이그레이션은 빈 DB와 기존 DB 모두에서 연속 적용 가능해야 한다.
- 파괴적 변경(drop/rename)은 백필 또는 호환 단계 계획 없이 직접 반영하지 않는다.
@@ -276,7 +318,9 @@ Conditional migration behavior:
| 20260403_0008 | migrations/versions/20260403_0008_portfolio_name_column.py | Add non-null `portfolios.name` and backfill from `symbol` |
| 20260410_0009 | migrations/versions/20260410_0009_portfolio_comments_table.py | Add `portfolio_comments` table for per-portfolio memo history |
| 20260413_0010 | migrations/versions/20260413_0010_portfolio_owner_user_id.py | Add `portfolios.owner_user_id`, backfill owner, create owner index, and add owner FK |
| 20260414_0011 | migrations/versions/20260414_0011_bot_principal_owner_user_id.py | Restore `bot_principals.owner_user_id`, backfill owner, create owner index, and add owner FK |
| 20260414_0012 | migrations/versions/20260414_0012_portfolio_tags.py | Add `portfolio_tags`, portfolio FK, and tag lookup index for REQ-002 |
## Open Implementation Points
- `portfolio_ledger`의 Timescale hypertable 강제 여부와 보존 정책(retention)을 확정한다.
- `REQ-002` 태그 검색 구현 시 인덱스 전략(B-tree/GiN 등)과 마이그레이션 단계를 추가 정의한다.
- owner-scoped 조회와 태그 필터를 함께 사용할 때 결과 집합이 ownership 경계를 넘지 않아야 한다.
@@ -0,0 +1,109 @@
# Portfolio Tagging and Search Design
- Related REQ: `REQ-002`
## 목표
- 승인된 `REQ-002`를 구현 가능한 내부 설계로 정리한다.
- 태그 정규화, 검색 파라미터 의미, 모듈 책임, 저장소/마이그레이션 방향을 한 문서에서 추적한다.
## 구현 범위
- 포트폴리오별 태그 추가/삭제 흐름
- HTTP API와 CLI의 공통 태그 검색 파라미터 의미
- repository query 전략과 DB 인덱스 후보
제외:
- 태그 추천, 자동 분류, 네임스페이스 사전 관리
- 부분 일치/전방 일치/full-text 검색
## 정규화 정책
- 입력 태그는 API/CLI 계층에서 앞뒤 공백을 제거한다.
- 정규화 후 빈 문자열이면 validation 오류로 처리한다.
- 대소문자, 내부 공백, `:` 네임스페이스는 변경하지 않는다.
- 중복 판단은 정규화된 값 기준으로 수행한다.
예시:
- `" grade:A "` -> `"grade:A"`
- `"Grade:A"`와 `"grade:A"`는 서로 다른 태그로 취급한다.
- `"blacklist: trade"`의 내부 공백은 보존한다.
## 모듈 책임
API:
- 대상 파일: `app/api/portfolios.py`
- 책임:
- `POST/GET/DELETE /portfolios/{portfolio_id}/tags` 제공
- `GET /portfolios`의 반복 query 파라미터 `includeTags`, `excludeTags`, `matchMode` 수신
- 기존 ownership/auth 정책 재사용
CLI:
- 대상 파일: `app/cli/commands/portfolio.py`
- 책임:
- `portfolio tag add|list|remove` 명령 노출
- `portfolio list --include-tag --exclude-tag --match-mode` 옵션 제공
Schema:
- 대상 파일: `app/schemas/portfolio.py`
- 책임:
- tag mutation request/response 모델 정의
- list query 파라미터 contract 정리
Service:
- 대상 파일: `app/services/portfolio_service.py`
- 책임:
- 정규화된 태그 입력 검증
- repository 호출 orchestration
- duplicate/not-found/auth 오류를 API/CLI가 소비할 수 있는 에러로 유지
Repository:
- 대상 파일: `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py`
- 책임:
- 태그 연결 추가/삭제
- include/exclude + matchMode query 제공
- duplicate insert를 명시적 오류로 변환
## 검색 파라미터 의미
- `includeTags`
- 포함해야 하는 정규화 태그 목록
- `excludeTags`
- 하나라도 일치하면 제외할 정규화 태그 목록
- `matchMode`
- `includeTags`에만 적용
- `AND`: 모든 include tag 필요
- `OR`: 하나 이상의 include tag 필요
처리 순서:
1. 입력 태그 정규화
2. ownership/auth 필터 적용
3. include 조건 계산
4. exclude 조건 제거
5. 중복 portfolio 제거
6. 기존 recency ordering과 충돌 여부 확인
## Validation Notes
- duplicate tag add는 `UNIQUE (portfolio_id, tag_value)` 또는 동등한 repository 검증으로 `409`에 매핑해야 한다.
- 존재하지 않는 포트폴리오에 대한 add/remove는 `404`로 매핑해야 한다.
- 검색 결과는 `OR` 조건에서 중복 없이 한 번만 반환해야 한다.
- owner-scoped 조회와 태그 필터를 함께 사용할 때 결과 집합이 ownership 경계를 넘지 않아야 한다.
## 파일 위치 및 구현 후보
- API route extension: `app/api/portfolios.py`
- CLI extension: `app/cli/commands/portfolio.py`
- schema extension: `app/schemas/portfolio.py`
- service orchestration: `app/services/portfolio_service.py`
- repository contract: `app/repositories/portfolio_repository.py`
- Postgres query implementation: `app/repositories/postgres.py`
- migration: `migrations/versions/20260414_0012_portfolio_tags.py`
## 구현 결정
- 태그 mutation은 기존 포트폴리오 리소스 하위 경로 `/portfolios/{portfolio_id}/tags`로 고정했다.
- `GET /portfolios` 태그 필터는 반복 query parameter 방식으로 구현했다.
- CLI 다중 태그 입력은 반복 옵션 `--include-tag`, `--exclude-tag`로 구현했다.