Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,9 @@ public struct DataSourceDependencyAssembler: DependencyAssemblerProtocol {
DIContainer.shared.register(type: ActivityHistoryRepositoryProtocol.self) { _ in
return ActivityHistoryRepository()
}

DIContainer.shared.register(type: YouthPolicyRepositoryProtocol.self) { _ in
return YouthPolicyRepository()
}
}
}
61 changes: 61 additions & 0 deletions Projects/DataSource/Sources/DTO/YouthPolicyDTO.swift
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")
Comment on lines +25 to +26

Copy link
Copy Markdown
Contributor

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를 사용하지 않은건가용 ??

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

알 수 없는 상태값이 있는 공고를 조용히 제거하면 개수 표시가 어긋납니다.

toYouthPolicyEntity()statusYouthPolicyStatus에 없는 값이면 nil을 반환합니다. toYouthPolicyPageEntity()compactMap으로 이 nil 항목을 목록에서 제거하지만, totalCount는 서버 응답값을 그대로 사용합니다.

서버가 새 상태값을 추가하거나 매핑되지 않은 값을 반환하면, 화면에 표시되는 "전체 공고 개수"(totalCount)가 실제로 렌더링되는 항목 수보다 커집니다. 페이지네이션의 hasNext/nextCursor도 드롭된 항목을 포함한 전체 기준이므로, 사용자에게는 페이지마다 항목이 누락되는 것처럼 보일 수 있습니다.

알 수 없는 상태를 만나면 최소한 로그를 남기거나, 드롭된 항목 수만큼 totalCount를 보정하는 방안을 검토하십시오.

🐛 드롭된 항목 수만큼 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Projects/DataSource/Sources/DTO/YouthPolicyDTO.swift` around lines 31 - 61,
Update toYouthPolicyPageEntity so policies removed by toYouthPolicyEntity for
unknown statuses are not silently ignored: either log each dropped DTO or adjust
totalCount by the number of discarded items, ensuring the displayed count and
pagination metadata remain consistent with rendered policies.

75 changes: 75 additions & 0 deletions Projects/DataSource/Sources/Endpoint/YouthPolicyEndpoint.swift
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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="/?:@!$&'()*+,-.;=' <>[]"))
PY

Repository: 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


policyNumber를 URL 경로에 삽입하기 전에 인코딩하십시오.

urlStringURLComponents로 변환하지 않고 queryItems만 자동 설정합니다. ?, #, &, 공백이 포함된 policyNumber가 경로를 분할하거나 손상시킬 수 있으므로, addBookmarkremoveBookmark에서 URLQueryAllowedCharacters 같은 허용 문자 집합으로 인코딩한 뒤 경로에 삽입하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Projects/DataSource/Sources/Endpoint/YouthPolicyEndpoint.swift` around lines
24 - 27, Update the addBookmark/removeBookmark URL construction in the
endpoint’s request-path switch to percent-encode policyNumber with an
appropriate URL path-safe character set before interpolating it into the path,
preserving the existing bookmark endpoint structure.


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
}
}
81 changes: 81 additions & 0 deletions Projects/DataSource/Sources/Repository/YouthPolicyRepository.swift
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)
}
}
}
11 changes: 11 additions & 0 deletions Projects/Domain/Sources/DomainDependencyAssembler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,16 @@ public struct DomainDependencyAssembler: DependencyAssemblerProtocol {
reportRepository: reportRepository,
fileRepository: fileRepository)
}

DIContainer.shared.register(type: YouthPolicyUseCaseProtocol.self) { container in
guard
let youthPolicyRepository = container.resolve(type: YouthPolicyRepositoryProtocol.self),
let locationRepository = container.resolve(type: LocationRepositoryProtocol.self)
else { fatalError("youthPolicyUseCase에 필요한 의존성이 등록되지 않았습니다.") }

return YouthPolicyUseCase(
youthPolicyRepository: youthPolicyRepository,
locationRepository: locationRepository)
}
}
}
13 changes: 13 additions & 0 deletions Projects/Domain/Sources/Entity/Enum/YouthPolicyStatus.swift
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"
}
50 changes: 50 additions & 0 deletions Projects/Domain/Sources/Entity/YouthPolicyEntity.swift
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
}
}
25 changes: 25 additions & 0 deletions Projects/Domain/Sources/Entity/YouthPolicyPageEntity.swift
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
}
Loading
Loading