-
Notifications
You must be signed in to change notification settings - Fork 1
[Feat] 청년 공고 목록/찜 화면 구현 및 서버 연동 #94
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| // | ||
| // YouthPolicyDTO.swift | ||
| // DataSource | ||
| // | ||
|
|
||
| import Domain | ||
| import Foundation | ||
|
|
||
| struct YouthPolicyDTO: Decodable { | ||
| let plcyNo: String | ||
| let title: String | ||
| let category: String? | ||
| let thumbnailUrl: String? | ||
| let status: String | ||
| let startDate: String? | ||
| let endDate: String? | ||
| let dday: Int? | ||
| let applyUrl: String? | ||
| let bookmarked: Bool | ||
| } | ||
|
|
||
| extension YouthPolicyDTO { | ||
| private static let dateFormatter: DateFormatter = { | ||
| let formatter = DateFormatter() | ||
| formatter.dateFormat = "yyyy-MM-dd" | ||
| formatter.locale = Locale(identifier: "en_US_POSIX") | ||
| formatter.timeZone = TimeZone(identifier: "Asia/Seoul") | ||
| return formatter | ||
| }() | ||
|
|
||
| func toYouthPolicyEntity() -> YouthPolicyEntity? { | ||
| guard let status = YouthPolicyStatus(rawValue: status) else { return nil } | ||
|
|
||
| return YouthPolicyEntity( | ||
| policyNumber: plcyNo, | ||
| title: title, | ||
| category: category, | ||
| thumbnailURL: thumbnailUrl, | ||
| status: status, | ||
| startDate: startDate.flatMap { Self.dateFormatter.date(from: $0) }, | ||
| endDate: endDate.flatMap { Self.dateFormatter.date(from: $0) }, | ||
| dday: dday, | ||
| applyURL: applyUrl, | ||
| isBookmarked: bookmarked) | ||
| } | ||
| } | ||
|
|
||
| struct YouthPolicyPageDTO: Decodable { | ||
| let totalCount: Int | ||
| let hasNext: Bool | ||
| let nextCursor: String? | ||
| let items: [YouthPolicyDTO] | ||
|
|
||
| func toYouthPolicyPageEntity() -> YouthPolicyPageEntity { | ||
| return YouthPolicyPageEntity( | ||
| policies: items.compactMap { $0.toYouthPolicyEntity() }, | ||
| totalCount: totalCount, | ||
| hasNext: hasNext, | ||
| nextCursor: nextCursor) | ||
| } | ||
| } | ||
|
Comment on lines
+31
to
+61
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 알 수 없는 상태값이 있는 공고를 조용히 제거하면 개수 표시가 어긋납니다.
서버가 새 상태값을 추가하거나 매핑되지 않은 값을 반환하면, 화면에 표시되는 "전체 공고 개수"( 알 수 없는 상태를 만나면 최소한 로그를 남기거나, 드롭된 항목 수만큼 🐛 드롭된 항목 수만큼 totalCount를 보정하는 예시 func toYouthPolicyPageEntity() -> YouthPolicyPageEntity {
+ let policies = items.compactMap { $0.toYouthPolicyEntity() }
return YouthPolicyPageEntity(
- policies: items.compactMap { $0.toYouthPolicyEntity() },
- totalCount: totalCount,
+ policies: policies,
+ totalCount: totalCount - (items.count - policies.count),
hasNext: hasNext,
nextCursor: nextCursor)
}🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| // | ||
| // YouthPolicyEndpoint.swift | ||
| // DataSource | ||
| // | ||
|
|
||
| enum YouthPolicyEndpoint { | ||
| case fetchPolicies(latitude: Double, longitude: Double, cursor: String?, size: Int?) | ||
| case fetchBookmarkedPolicies(cursor: String?, size: Int?) | ||
| case addBookmark(policyNumber: String) | ||
| case removeBookmark(policyNumber: String) | ||
| } | ||
|
|
||
| extension YouthPolicyEndpoint: Endpoint { | ||
| var baseURL: String { | ||
| return AppProperties.baseURL + "/api/v1/youth-policies" | ||
| } | ||
|
|
||
| var path: String { | ||
| switch self { | ||
| case .fetchPolicies: | ||
| return baseURL | ||
| case .fetchBookmarkedPolicies: | ||
| return "\(baseURL)/bookmarks" | ||
| case .addBookmark(let policyNumber), .removeBookmark(let policyNumber): | ||
| return "\(baseURL)/\(policyNumber)/bookmark" | ||
| } | ||
| } | ||
|
Comment on lines
+24
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# NetworkService 구현 파일을 찾아 URL 구성 로직을 확인합니다.
fd -i 'NetworkService.swift' --exec cat -n {}Repository: YAPP-Github/Bitnagil-iOS Length of output: 4116 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== Locate Endpoint files =="
fd -i 'Endpoint' --exec sh -c 'echo "--- {}"; wc -l "{}"; ast-grep outline "{}" || true' \;
echo "== YouthPolicyEndpoint.swift =="
fd -i 'YouthPolicyEndpoint.swift' --exec cat -n {}
echo "== Endpoint protocol/extension files =="
for f in $(fd -i 'Endpoint.*\.swift'); do
echo "--- $f"
rg -n "protocol Endpoint|makeURLRequest|path|URLRequest|baseURL|queryItems|method|body" "$f" -A20 -B5 || true
done
echo "== Search for path encoding helpers =="
rg -n "addingPercentEncoding|percent-encoded|URLComponents|URLQueryItem|queryItems|baseURL|path" -S .Repository: YAPP-Github/Bitnagil-iOS Length of output: 35032 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== URLRequest.swift =="
fd -i 'URLRequest\.swift' --exec cat -n {}
echo "== QueryParameter encoding helpers =="
rg -n "URLQueryItem|encodedQuery|percent|URLComponents|queryParameters|makeQuery" Projects/DataSource -S
echo "== Deterministic URLComponents/path behavior probe =="
python3 - <<'PY'
from urllib.parse import quote
cases = [" 1 ", "a?b", "a#b", "a&b", "a b"]
for value in cases:
path = f"https://example.com/api/v1/youth-policies/{value}/bookmark"
# Mirrors URLComponents behavior: query needs percent encoding; path has different allowed chars.
percent = quote(value, safe="/?:@!$&'()*+,-.;=\x3C>[")
print(value, "=> path:", path, "=> percent-encoding-like safe:", percent)
PY
echo "== Deterministic URLComponents encoding evidence from Python urlparse =="
python3 - <<'PY'
from urllib.parse import quote, urlsplit
value = "a?b"
url = f"https://example.com/api/v1/youth-policies/{value}/bookmark"
parts = urlsplit(url)
print("original path:", parts.path)
print("quoted value:", quote(value, safe="/?:@!$&'()*+,-.;=' <>[]"))
PYRepository: YAPP-Github/Bitnagil-iOS Length of output: 3090 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== URLRequest+.swift =="
fd -i 'URLRequest\+\.swift' --exec cat -n {}
echo "== Header/body extension files =="
for f in $(fd -i '*Header*.swift' -p Projects/DataSource/Sources/NetworkService -o -i '*Body*.swift' -p Projects/DataSource/Sources/NetworkService); do
echo "--- $f"
wc -l "$f"
cat -n "$f"
done
echo "== URLRequest extension search =="
fd -i 'URLRequest.*\.swift' --exec sh -c 'echo "--- $1"; cat -n "$1"' sh {}Repository: YAPP-Github/Bitnagil-iOS Length of output: 2926
🤖 Prompt for AI Agents |
||
|
|
||
| var method: HTTPMethod { | ||
| switch self { | ||
| case .fetchPolicies, .fetchBookmarkedPolicies: | ||
| return .get | ||
| case .addBookmark: | ||
| return .post | ||
| case .removeBookmark: | ||
| return .delete | ||
| } | ||
| } | ||
|
|
||
| var headers: [String: String] { | ||
| let headers: [String: String] = [ | ||
| "Content-Type": "application/json", | ||
| "accept": "*/*" | ||
| ] | ||
| return headers | ||
| } | ||
|
|
||
| var queryParameters: [String: String] { | ||
| switch self { | ||
| case .fetchPolicies(let latitude, let longitude, let cursor, let size): | ||
| var parameters = [ | ||
| "latitude": "\(latitude)", | ||
| "longitude": "\(longitude)" | ||
| ] | ||
| if let cursor { parameters["cursor"] = cursor } | ||
| if let size { parameters["size"] = "\(size)" } | ||
| return parameters | ||
| case .fetchBookmarkedPolicies(let cursor, let size): | ||
| var parameters: [String: String] = [:] | ||
| if let cursor { parameters["cursor"] = cursor } | ||
| if let size { parameters["size"] = "\(size)" } | ||
| return parameters | ||
| case .addBookmark, .removeBookmark: | ||
| return [:] | ||
| } | ||
| } | ||
|
|
||
| var bodyParameters: [String: Any] { | ||
| return [:] | ||
| } | ||
|
|
||
| var isAuthorized: Bool { | ||
| return true | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| // | ||
| // YouthPolicyRepository.swift | ||
| // DataSource | ||
| // | ||
|
|
||
| import Domain | ||
| import Foundation | ||
|
|
||
| final class YouthPolicyRepository: YouthPolicyRepositoryProtocol { | ||
| private let networkService = NetworkService.shared | ||
|
|
||
| func fetchPolicies( | ||
| latitude: Double, | ||
| longitude: Double, | ||
| cursor: String?, | ||
| size: Int? | ||
| ) async throws -> YouthPolicyPageEntity { | ||
| let endpoint = YouthPolicyEndpoint.fetchPolicies( | ||
| latitude: latitude, | ||
| longitude: longitude, | ||
| cursor: cursor, | ||
| size: size) | ||
|
|
||
| return try await fetchPage(endpoint: endpoint) | ||
| } | ||
|
|
||
| func fetchBookmarkedPolicies( | ||
| cursor: String?, | ||
| size: Int? | ||
| ) async throws -> YouthPolicyPageEntity { | ||
| let endpoint = YouthPolicyEndpoint.fetchBookmarkedPolicies(cursor: cursor, size: size) | ||
|
|
||
| return try await fetchPage(endpoint: endpoint) | ||
| } | ||
|
|
||
| func addBookmark(policyNumber: String) async throws { | ||
| let endpoint = YouthPolicyEndpoint.addBookmark(policyNumber: policyNumber) | ||
|
|
||
| try await updateBookmark(endpoint: endpoint) | ||
| } | ||
|
|
||
| func removeBookmark(policyNumber: String) async throws { | ||
| let endpoint = YouthPolicyEndpoint.removeBookmark(policyNumber: policyNumber) | ||
|
|
||
| try await updateBookmark(endpoint: endpoint) | ||
| } | ||
|
|
||
| private func fetchPage(endpoint: YouthPolicyEndpoint) async throws -> YouthPolicyPageEntity { | ||
| do { | ||
| guard let response = try await networkService.request(endpoint: endpoint, type: YouthPolicyPageDTO.self) | ||
| else { return YouthPolicyPageEntity(policies: [], totalCount: 0, hasNext: false, nextCursor: nil) } | ||
|
|
||
| return response.toYouthPolicyPageEntity() | ||
| } catch let error as NetworkError { | ||
| throw error.toDomainError() | ||
| } catch { | ||
| throw DomainError.unknown | ||
| } | ||
| } | ||
|
|
||
| private func updateBookmark(endpoint: YouthPolicyEndpoint) async throws { | ||
| do { | ||
| _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self) | ||
| } catch let error as NetworkError { | ||
| throw error.toDomainError() | ||
| } catch { | ||
| throw DomainError.unknown | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private extension NetworkError { | ||
| func toDomainError() -> DomainError { | ||
| switch self { | ||
| case .needRetry, .invalidURL, .emptyData: | ||
| return DomainError.requireRetry | ||
| default: | ||
| return DomainError.business(description) | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| // | ||
| // YouthPolicyStatus.swift | ||
| // Domain | ||
| // | ||
|
|
||
| public enum YouthPolicyStatus: String { | ||
| /// 신청 기간 내 (마감 전) | ||
| case open = "OPEN" | ||
| /// 상시 모집 | ||
| case always = "ALWAYS" | ||
| /// 마감됨. 찜 목록에서만 내려옵니다. | ||
| case closed = "CLOSED" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| // | ||
| // YouthPolicyEntity.swift | ||
| // Domain | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| public struct YouthPolicyEntity { | ||
| /// 공고 고유 번호. 찜 등록/해제 시 이 값을 사용합니다. | ||
| public let policyNumber: String | ||
| public let title: String | ||
| /// 대분류(일자리·주거·교육 등). 일부 공고에서 nil. | ||
| public let category: String? | ||
| /// 원본 API에 이미지가 없어 현재는 항상 nil입니다. 클라이언트 기본 이미지를 사용하세요. | ||
| public let thumbnailURL: String? | ||
| public let status: YouthPolicyStatus | ||
| /// 신청 시작일. 상시/마감 공고에서 nil 가능. | ||
| public let startDate: Date? | ||
| /// 신청 마감일. 상시 공고는 nil. | ||
| public let endDate: Date? | ||
| /// 마감까지 남은 일수(오늘 = 0). status가 open이 아니면 nil. | ||
| public let dday: Int? | ||
| /// 외부 신청 페이지 URL. 없을 수 있습니다. | ||
| public let applyURL: String? | ||
|
Comment on lines
+23
to
+24
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 헙 .. 그러쿤요 !! 어쩐지 눌러도 외부 페이지도 이동하지 않는 공고들이 있어서 궁금했었는디 .. !!!!! |
||
| public let isBookmarked: Bool | ||
|
|
||
| public init( | ||
| policyNumber: String, | ||
| title: String, | ||
| category: String?, | ||
| thumbnailURL: String?, | ||
| status: YouthPolicyStatus, | ||
| startDate: Date?, | ||
| endDate: Date?, | ||
| dday: Int?, | ||
| applyURL: String?, | ||
| isBookmarked: Bool | ||
| ) { | ||
| self.policyNumber = policyNumber | ||
| self.title = title | ||
| self.category = category | ||
| self.thumbnailURL = thumbnailURL | ||
| self.status = status | ||
| self.startDate = startDate | ||
| self.endDate = endDate | ||
| self.dday = dday | ||
| self.applyURL = applyURL | ||
| self.isBookmarked = isBookmarked | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| // | ||
| // YouthPolicyPageEntity.swift | ||
| // Domain | ||
| // | ||
|
|
||
| public struct YouthPolicyPageEntity { | ||
| public let policies: [YouthPolicyEntity] | ||
| /// 필터링된 전체 건수. 탭의 "전체 N" 표기용이며 페이지마다 동일하게 내려옵니다. | ||
| public let totalCount: Int | ||
| public let hasNext: Bool | ||
| /// 다음 페이지 요청에 그대로 넘길 불투명 토큰. hasNext가 false면 nil. | ||
| public let nextCursor: String? | ||
|
|
||
| public init( | ||
| policies: [YouthPolicyEntity], | ||
| totalCount: Int, | ||
| hasNext: Bool, | ||
| nextCursor: String? | ||
| ) { | ||
| self.policies = policies | ||
| self.totalCount = totalCount | ||
| self.hasNext = hasNext | ||
| self.nextCursor = nextCursor | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| // | ||
| // YouthPolicyRepositoryProtocol.swift | ||
| // Domain | ||
| // | ||
|
|
||
| public protocol YouthPolicyRepositoryProtocol { | ||
|
|
||
| /// 현위치 기준 청년 공고 목록을 조회합니다. 마감된 공고는 제외됩니다. | ||
| /// - Parameters: | ||
| /// - latitude: 현재 위도 | ||
| /// - longitude: 현재 경도 | ||
| /// - cursor: 이전 응답의 nextCursor. 첫 페이지는 nil | ||
| /// - size: 페이지 크기. nil이면 서버 기본값(10) 사용 | ||
| /// - Returns: 공고 페이지 | ||
| func fetchPolicies( | ||
| latitude: Double, | ||
| longitude: Double, | ||
| cursor: String?, | ||
| size: Int? | ||
| ) async throws -> YouthPolicyPageEntity | ||
|
|
||
| /// 찜한 공고 목록을 조회합니다. 지역과 무관하며 마감된 공고도 포함됩니다. | ||
| /// - Parameters: | ||
| /// - cursor: 이전 응답의 nextCursor. 첫 페이지는 nil | ||
| /// - size: 페이지 크기. nil이면 서버 기본값(10) 사용 | ||
| /// - Returns: 공고 페이지 | ||
| func fetchBookmarkedPolicies( | ||
| cursor: String?, | ||
| size: Int? | ||
| ) async throws -> YouthPolicyPageEntity | ||
|
|
||
| /// 공고를 찜 목록에 추가합니다. 멱등이므로 이미 찜한 공고를 다시 호출해도 성공합니다. | ||
| /// - Parameter policyNumber: 공고 번호 | ||
| func addBookmark(policyNumber: String) async throws | ||
|
|
||
| /// 공고를 찜 목록에서 제거합니다. 멱등이므로 찜하지 않은 공고를 해제해도 성공합니다. | ||
| /// - Parameter policyNumber: 공고 번호 | ||
| func removeBookmark(policyNumber: String) async throws | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
요기
formatter.locale = Locale(identifier: "en_US_POSIX")요거는 혹시 왜 필요한가용 !!궁금 !! 그래서 Shared 모듈 Date+에 정의된 convertToDate를 사용하지 않은건가용 ??