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
10 changes: 0 additions & 10 deletions UMCApp/Features/BusinessCard/Data/Sources/BusinessCardData.swift

This file was deleted.

120 changes: 120 additions & 0 deletions UMCApp/Features/BusinessCard/Data/Sources/DTO/ActivityCountDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//
// ActivityCountDTO.swift
// BusinessCardData
//
// Created by One on 8/16/26.
//

import Foundation
import UMCFoundation

// MARK: - Query

/// 스터디 카운트 쿼리. 커서 응답에 총개수가 없어 페이지를 크게 받아 항목 수를 센다.
/// size(50) 초과분은 표기가 50에서 멈춘다 — 개인 참여 스터디 수 특성상 실질 영향 없음.
public struct StudyCountQueryDTO {
public let size: Int

public init(size: Int = 50) {
self.size = size
}

public var toParameters: [String: Any] {
["size": size]
}
}

/// 스크랩 카운트 쿼리 — totalElements만 필요하므로 최소 페이지(size 1)로 요청한다.
public struct ScrappedCountQueryDTO {
public let page: Int
public let size: Int

public init(page: Int = 0, size: Int = 1) {
self.page = page
self.size = size
}

public var toParameters: [String: Any] {
["page": page, "size": size]
}
}

// MARK: - Response

/// 스크랩 페이지 응답에서 totalElements만 취하는 얇은 DTO (절대규칙 #3 custom Codable).
struct ScrappedCountPageDTO: Codable {
let totalElements: String

private enum CodingKeys: String, CodingKey {
case totalElements
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
totalElements = try container.decodeFlexibleString(forKey: .totalElements)
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(totalElements, forKey: .totalElements)
}
}

/// 스터디 커서 페이지에서 항목 수만 세는 얇은 DTO.
/// 서버가 `cursor` 래핑 / `content` / `studyGroups` 어느 키로 응답해도 흡수한다
/// (Activity `MyStudyGroupsPageDTO`와 같은 유연 디코딩 — Bool도 문자열 흡수).
struct StudyCountPageDTO: Codable {
let itemCount: Int
let hasNext: Bool

private struct AnyItemStub: Codable {} // 항목 내용은 버리고 개수만 센다

private enum CodingKeys: String, CodingKey {
case cursor, content, studyGroups, hasNext
}

/// 중첩 DTO도 synthesized Codable 금지 (절대 규칙 #3) — hasNext는 서버가 "true"
/// 문자열로 직렬화해도 흡수해야 하므로 `decodeBoolFlexibleIfPresent`를 쓴다
/// (선례: Activity `MyStudyGroupsPageDTO`, 헬퍼: KeyedDecodingContainer+FlexibleNumber).
private struct CursorEnvelope: Codable {
let content: [AnyItemStub]?
let studyGroups: [AnyItemStub]?
let hasNext: Bool?

private enum CodingKeys: String, CodingKey {
case content, studyGroups, hasNext
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
content = try container.decodeIfPresent([AnyItemStub].self, forKey: .content)
studyGroups = try container.decodeIfPresent([AnyItemStub].self, forKey: .studyGroups)
hasNext = try container.decodeBoolFlexibleIfPresent(forKey: .hasNext)
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(hasNext, forKey: .hasNext)
}
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let cursor = try container.decodeIfPresent(CursorEnvelope.self, forKey: .cursor) {
itemCount = (cursor.studyGroups ?? cursor.content ?? []).count
hasNext = try cursor.hasNext
?? container.decodeBoolFlexibleIfPresent(forKey: .hasNext) ?? false
} else {
let items = try container.decodeIfPresent([AnyItemStub].self, forKey: .studyGroups)
?? container.decodeIfPresent([AnyItemStub].self, forKey: .content)
?? []
itemCount = items.count
hasNext = try container.decodeBoolFlexibleIfPresent(forKey: .hasNext) ?? false
}
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(hasNext, forKey: .hasNext)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//
// Profile+MyCard.swift
// BusinessCardData
//
// Created by One on 8/16/26.
//

import Foundation
import UMCFoundation
import CoreDomain
import BusinessCardDomain

// 정본 `CoreDomain.Profile` → 명함 매핑.
// 파생 규칙은 MyPage의 `Profile.toProfileData()`(Profile+ProfileData.swift)와 동일하게 맞춘다
// — 명함과 프로필 카드가 다른 기수/파트를 보여주면 안 되기 때문.
public extension Profile {

func toMyCard() -> MyCard {
let visibleRecords = challengerRecords.filter { UMCPartType(apiValue: $0.part) != .admin }
let latestRecord = visibleRecords.max { $0.gisu.intValue < $1.gisu.intValue }
?? challengerRecords.max { $0.gisu.intValue < $1.gisu.intValue }
let latestRole = roles.max { $0.gisu.intValue < $1.gisu.intValue }

let fallbackPart = latestRole?.responsiblePart
.flatMap { UMCPartType(apiValue: $0) } ?? .admin

return MyCard(
memberId: memberId,
name: latestRecord?.name ?? name,
nickname: latestRecord?.nickname ?? nickname,
part: UMCPartType(apiValue: latestRecord?.part ?? "") ?? fallbackPart,
generation: latestRecord?.gisu ?? latestRole?.gisu ?? "0",
university: latestRecord?.schoolName ?? schoolName,
email: (latestRecord?.email).flatMap(\.nonEmpty) ?? email.nonEmpty,
github: externalLinks?.github?.nonEmpty,
blog: externalLinks?.blog?.nonEmpty,
avatarURL: latestRecord?.profileImageLink?.nonEmpty ?? profileImageLink?.nonEmpty,
memberNo: memberId
)
}
}

// MARK: - Private String Helpers

// `intValue`/`nonEmpty`는 UMCFoundation에 없다(전 코드베이스 확인 2026-08-15).
// 유일한 정의가 MyPageDomain `Profile+ProfileData.swift`의 파일-로컬 private 확장이라
// BusinessCardData에서는 보이지 않는다. 파생 규칙을 정본과 동일하게 맞추기 위해
// 같은 구현을 여기 복제한다(크로스 피처 import 금지 — 승격은 소비자가 더 늘 때).
private extension String {
var intValue: Int { Int(self) ?? 0 }

var nonEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// BusinessCardNetworkRequesting.swift
// BusinessCardData
//
// Created by One on 8/16/26.
//

import Foundation
import CoreNetwork
import Moya

/// Repository 단위 테스트용 네트워크 요청 seam (MyPageNetworkRequesting 선례).
///
/// 운영 채택 타입은 `MoyaNetworkAdapter` 하나. 인증 요청만 필요해 `request`만 요구한다.
/// 테스트 목적 추상화로 런타임 동작에는 영향이 없다 (baseURL fatalError 회피).
protocol BusinessCardNetworkRequesting {
func request<T: TargetType>(_ target: T) async throws -> Response
}

extension MoyaNetworkAdapter: BusinessCardNetworkRequesting {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//
// ReceivedCardRecord.swift
// BusinessCardData
//
// Created by One on 8/16/26.
//

import Foundation
import SwiftData

/// 받은 명함 로컬 저장 모델 (SwiftData + CloudKit Sync — 명함첩이 기기 간 동기화된다).
///
/// - Note: CloudKit 호환 제약 — 전 필드 기본값 필수·`@Attribute(.unique)` 금지 (Home
/// `GenerationMappingRecord` 선례). 중복은 Repository가 memberId 기준으로 정리한다.
/// - Note: 서버 응답이 아닌 로컬 영속 모델이지만 `generation`·`memberNo`는 도메인 그대로
/// String 보존 (경계 변환 없음). `exchangedAt`/`isConnected`는 로컬 생성 값이라 본래 타입.
@Model
public final class ReceivedCardRecord {

// MARK: - Property

/// 교환 페이로드 cardID (upsert 부차 키)
public var cardID: String = ""
/// 상대 memberId (upsert 1차 키 — 같은 사람 재교환 시 갱신)
public var memberId: String = ""
public var name: String = ""
public var nickname: String = ""
/// `UMCPartType.apiValue` 문자열
public var partRaw: String = ""
public var generation: String = ""
public var university: String = ""
public var email: String?
public var github: String?
public var blog: String?
public var avatarURL: String?
public var memberNo: String?
public var exchangedAt: Date = Date()
public var exchangeContext: String?
public var isConnected: Bool = false
public var updatedAt: Date = Date()

// MARK: - Init

public init(
cardID: String,
memberId: String,
name: String,
nickname: String,
partRaw: String,
generation: String,
university: String,
email: String?,
github: String?,
blog: String?,
avatarURL: String?,
memberNo: String?,
exchangedAt: Date,
exchangeContext: String?,
isConnected: Bool,
updatedAt: Date = Date()
) {
self.cardID = cardID
self.memberId = memberId
self.name = name
self.nickname = nickname
self.partRaw = partRaw
self.generation = generation
self.university = university
self.email = email
self.github = github
self.blog = blog
self.avatarURL = avatarURL
self.memberNo = memberNo
self.exchangedAt = exchangedAt
self.exchangeContext = exchangeContext
self.isConnected = isConnected
self.updatedAt = updatedAt
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//
// CoreImageQRCodeGenerator.swift
// BusinessCardData
//
// Created by One on 8/16/26.
//

import CoreImage.CIFilterBuiltins
import Foundation
import BusinessCardDomain

/// CIQRCodeGenerator 기반 QR 생성기 (MP-F02 뒷면·MP-F04 공용).
///
/// 스파이크(2026-08-15 실기기) 검증 파라미터: 보정 레벨 M, 정수 배율 12 업스케일.
/// "QR에 Glass/블러 금지"의 렌더링 처리(interpolation none 등)는 View 몫.
public struct CoreImageQRCodeGenerator: QRCodeGenerating {

// MARK: - Constants

private enum Constants {
static let correctionLevel = "M"
static let upscaleFactor: CGFloat = 12
}

// MARK: - Error

public enum GenerationError: Error {
case emptyPayload
case renderingFailed
}

// MARK: - Init

public init() {}

// MARK: - Function

public func generate(from payload: String) throws -> CGImage {
guard !payload.isEmpty else { throw GenerationError.emptyPayload }

let filter = CIFilter.qrCodeGenerator()
filter.message = Data(payload.utf8)
filter.correctionLevel = Constants.correctionLevel

guard let output = filter.outputImage else { throw GenerationError.renderingFailed }
let scaled = output.transformed(
by: CGAffineTransform(scaleX: Constants.upscaleFactor, y: Constants.upscaleFactor)
)
guard let image = CIContext().createCGImage(scaled, from: scaled.extent) else {
throw GenerationError.renderingFailed
}
return image
}
}
Loading
Loading