feat(req-002): implement portfolio tagging and tag filters
This commit is contained in:
@@ -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
@@ -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가 누락 없이 검토되어야 한다.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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`)으로 강제할지 결정한다.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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`로 구현했다.
|
||||
Reference in New Issue
Block a user