diff --git a/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift index 6db2fe8..6308111 100644 --- a/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift +++ b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift @@ -32,6 +32,9 @@ protocol TravelAlbumServing { /// 上传并登记旅拍相册素材。 func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial + /// 拉取相册内已登记素材的客户端照片 ID。 + func materialClientPhotoIds(userEquityTravelId: Int) async throws -> TravelAlbumMaterialClientPhotoIDsResponse + /// 删除旅拍相册。 func deleteAlbum(id: Int) async throws @@ -115,6 +118,19 @@ final class TravelAlbumAPI: TravelAlbumServing { try await client.send(APIRequest(method: .post, path: "\(basePath)/upload-material", body: request)) } + /// 拉取相册内已登记素材的客户端照片 ID。 + func materialClientPhotoIds(userEquityTravelId: Int) async throws -> TravelAlbumMaterialClientPhotoIDsResponse { + try await client.send( + APIRequest( + method: .get, + path: "\(basePath)/material-client-photo-ids", + queryItems: [ + URLQueryItem(name: "user_equity_travel_id", value: String(userEquityTravelId)), + ] + ) + ) + } + /// 删除旅拍相册。 func deleteAlbum(id: Int) async throws { let _: EmptyPayload = try await client.send( diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift index 8225ae8..a045383 100644 --- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift @@ -203,11 +203,22 @@ struct TravelAlbumUploadMaterialRequest: Encodable, Sendable, Equatable { let userEquityTravelId: Int let fileName: String let fileUrl: String + let clientPhotoId: String enum CodingKeys: String, CodingKey { case userEquityTravelId = "user_equity_travel_id" case fileName = "file_name" case fileUrl = "file_url" + case clientPhotoId = "client_photo_id" + } +} + +/// 旅拍相册服务端已登记的客户端照片 ID 集合。 +struct TravelAlbumMaterialClientPhotoIDsResponse: Decodable, Sendable, Equatable { + let clientPhotoIds: [String] + + enum CodingKeys: String, CodingKey { + case clientPhotoIds = "client_photo_ids" } } diff --git a/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift b/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift index b043f54..1d9845b 100644 --- a/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift +++ b/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift @@ -5,6 +5,25 @@ import Foundation +/// 生成并规范化旅拍照片的客户端唯一标识。 +enum TravelAlbumClientPhotoID { + /// 生成小写、带连字符的 UUID v4 字符串。 + static func make() -> String { + UUID().uuidString.lowercased() + } + + /// 规范化服务端或本地保存的客户端照片 ID。 + static func normalize(_ value: String?) -> String? { + guard let normalized = value? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + !normalized.isEmpty else { + return nil + } + return normalized + } +} + /// OTG 传输照片上传状态,对齐 Android `WiredTransferUploadStatus`。 enum TravelAlbumOTGUploadStatus: String, Codable, Equatable, Sendable { case pending = "PENDING" @@ -25,6 +44,7 @@ enum TravelAlbumOTGPhotoFormatOption: String, CaseIterable, Equatable, Sendable struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable { let id: String let sourceId: String + var clientPhotoId: String let fileName: String var localPath: String var thumbnailPath: String @@ -42,6 +62,7 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable { init( id: String, sourceId: String? = nil, + clientPhotoId: String = TravelAlbumClientPhotoID.make(), fileName: String, localPath: String, thumbnailPath: String = "", @@ -57,6 +78,7 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable { ) { self.id = id self.sourceId = sourceId ?? id + self.clientPhotoId = TravelAlbumClientPhotoID.normalize(clientPhotoId) ?? TravelAlbumClientPhotoID.make() self.fileName = fileName self.localPath = localPath self.thumbnailPath = thumbnailPath @@ -71,6 +93,31 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable { self.updatedAt = updatedAt } + private enum CodingKeys: String, CodingKey { + case id, sourceId, clientPhotoId, fileName, localPath, thumbnailPath, capturedAt + case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, updatedAt + } + + /// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + sourceId = try container.decodeIfPresent(String.self, forKey: .sourceId) ?? id + clientPhotoId = try container.decodeIfPresent(String.self, forKey: .clientPhotoId) ?? "" + fileName = try container.decode(String.self, forKey: .fileName) + localPath = try container.decode(String.self, forKey: .localPath) + thumbnailPath = try container.decodeIfPresent(String.self, forKey: .thumbnailPath) ?? "" + capturedAt = try container.decode(String.self, forKey: .capturedAt) + fileSizeBytes = try container.decodeIfPresent(Int64.self, forKey: .fileSizeBytes) ?? 0 + status = try container.decode(TravelAlbumOTGUploadStatus.self, forKey: .status) + progress = try container.decodeIfPresent(Int.self, forKey: .progress) ?? 0 + errorMessage = try container.decodeIfPresent(String.self, forKey: .errorMessage) + albumId = try container.decode(Int.self, forKey: .albumId) + userId = try container.decode(String.self, forKey: .userId) + remoteUrl = try container.decodeIfPresent(String.self, forKey: .remoteUrl) ?? "" + updatedAt = try container.decodeIfPresent(Int64.self, forKey: .updatedAt) ?? 0 + } + /// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。 func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord { guard status == .transferring || status == .uploading else { return self } @@ -188,9 +235,17 @@ final class TravelAlbumOTGPhotoStore { let decoded = try? JSONDecoder().decode([TravelAlbumOTGPhotoRecord].self, from: data) else { return [] } - return decoded - .filter { $0.albumId == albumId && $0.userId == context.userId } - .map { $0.normalizedAfterInterruptedTransfer() } + let scoped = decoded.filter { $0.albumId == albumId && $0.userId == context.userId } + let migrated = scoped.map { record -> TravelAlbumOTGPhotoRecord in + var copy = record + copy.clientPhotoId = TravelAlbumClientPhotoID.normalize(record.clientPhotoId) + ?? TravelAlbumClientPhotoID.make() + return copy.normalizedAfterInterruptedTransfer() + } + if migrated.map(\.clientPhotoId) != scoped.map(\.clientPhotoId) { + save(migrated, albumId: albumId) + } + return migrated .filter { isDisplayable($0, albumId: albumId) } .sorted { $0.capturedAt > $1.capturedAt } } @@ -434,6 +489,40 @@ final class TravelAlbumOTGPhotoStore { } } +/// 按服务端客户端照片 ID 快照回写本地持久状态。 +enum TravelAlbumOTGServerStatusPolicy { + /// 只处理请求发起时已存在且期间未变化的非活动记录。 + static func reconcile( + records: [TravelAlbumOTGPhotoRecord], + baselineRecordsById: [String: TravelAlbumOTGPhotoRecord], + activePhotoIds: Set, + serverClientPhotoIds: [String], + now: Int64 + ) -> [TravelAlbumOTGPhotoRecord] { + let serverIds = Set(serverClientPhotoIds.compactMap(TravelAlbumClientPhotoID.normalize)) + return records.map { record in + guard baselineRecordsById[record.id] == record, + !activePhotoIds.contains(record.id) else { + return record + } + var copy = record + if let clientPhotoId = TravelAlbumClientPhotoID.normalize(record.clientPhotoId), + serverIds.contains(clientPhotoId) { + copy.status = .uploaded + copy.progress = 100 + copy.errorMessage = nil + copy.updatedAt = now + } else if record.status == .uploaded { + copy.status = .pending + copy.progress = 0 + copy.errorMessage = nil + copy.updatedAt = now + } + return copy + } + } +} + /// 兼容从 `otg_swift` 同步来的边拍边传写入调用。 enum AlbumPhotoStorage { /// 写入原片数据到当前账号与相册隔离目录。 diff --git a/suixinkan/Features/TravelAlbum/OTG/Upload/TravelAlbumOTGUploader.swift b/suixinkan/Features/TravelAlbum/OTG/Upload/TravelAlbumOTGUploader.swift index b5be895..ad266e4 100644 --- a/suixinkan/Features/TravelAlbum/OTG/Upload/TravelAlbumOTGUploader.swift +++ b/suixinkan/Features/TravelAlbum/OTG/Upload/TravelAlbumOTGUploader.swift @@ -44,6 +44,9 @@ final class TravelAlbumOTGUploader: TravelAlbumOTGUploading { FileManager.default.fileExists(atPath: record.localPath) else { throw TravelAlbumOTGUploadError.localFileMissing } + guard let clientPhotoId = TravelAlbumClientPhotoID.normalize(record.clientPhotoId) else { + throw TravelAlbumOTGUploadError.clientPhotoIdMissing + } let data = try Data(contentsOf: URL(fileURLWithPath: record.localPath)) let remoteURL = try await uploader.uploadTravelAlbumMaterial( @@ -56,7 +59,8 @@ final class TravelAlbumOTGUploader: TravelAlbumOTGUploading { TravelAlbumUploadMaterialRequest( userEquityTravelId: record.albumId, fileName: record.fileName, - fileUrl: remoteURL + fileUrl: remoteURL, + clientPhotoId: clientPhotoId ) ) } @@ -66,6 +70,7 @@ final class TravelAlbumOTGUploader: TravelAlbumOTGUploading { enum TravelAlbumOTGUploadError: LocalizedError, Equatable { case invalidAlbum case localFileMissing + case clientPhotoIdMissing var errorDescription: String? { switch self { @@ -73,6 +78,8 @@ enum TravelAlbumOTGUploadError: LocalizedError, Equatable { return "请先选择有效的相册" case .localFileMissing: return "本地文件不存在,无法上传" + case .clientPhotoIdMissing: + return "照片标识缺失,请重新导入后重试" } } } diff --git a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift index 88615bd..79f1154 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift @@ -77,6 +77,7 @@ final class WiredCameraTransferViewModel { private let storage: TravelAlbumOTGPhotoStore private let repository: TravelAlbumOTGPhotoRepository private let uploader: any TravelAlbumOTGUploading + private let api: any TravelAlbumServing private let appStore: AppStore private let userDefaults: UserDefaults @@ -85,6 +86,7 @@ final class WiredCameraTransferViewModel { private var isUploading = false private var queuedAutoUploadPhotoIds: Set = [] private var suppressSelectedTimeSlotNotification = false + private var serverStatusSyncTask: Task? @MainActor init( @@ -95,6 +97,7 @@ final class WiredCameraTransferViewModel { connectionManager: (any WiredCameraConnectionManaging)? = nil, storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(), uploader: (any TravelAlbumOTGUploading)? = nil, + api: (any TravelAlbumServing)? = nil, appStore: AppStore = .shared, userDefaults: UserDefaults = .standard ) { @@ -106,6 +109,7 @@ final class WiredCameraTransferViewModel { self.storage = storage self.repository = TravelAlbumOTGPhotoRepository(storage: storage) self.uploader = uploader ?? TravelAlbumOTGUploader() + self.api = api ?? NetworkServices.shared.travelAlbumAPI self.appStore = appStore self.userDefaults = userDefaults self.transferMode = Self.persistedTransferMode(in: userDefaults) @@ -225,11 +229,14 @@ final class WiredCameraTransferViewModel { connectionManager.delegate = self syncFromConnectionManager() loadPersistedPhotos() + syncServerUploadStatuses() connectionManager.start() } /// 页面消失时解绑 UI delegate,并暂停边拍边传轮询,保留底层设备缓存。 func stop() { + serverStatusSyncTask?.cancel() + serverStatusSyncTask = nil connectionManager.unbindDelegate() connectionManager.suspendLiveTransfer() } @@ -483,6 +490,39 @@ final class WiredCameraTransferViewModel { applyMergedPhotos() } + private func syncServerUploadStatuses() { + serverStatusSyncTask?.cancel() + guard albumId > 0 else { return } + let baseline = persistedRecordsById + serverStatusSyncTask = Task { [weak self] in + guard let self else { return } + do { + let response = try await api.materialClientPhotoIds(userEquityTravelId: albumId) + try Task.checkCancellation() + let activeIds = Set(persistedRecordsById.values.compactMap { record in + record.status == .transferring || record.status == .uploading ? record.id : nil + }) + let reconciled = TravelAlbumOTGServerStatusPolicy.reconcile( + records: Array(persistedRecordsById.values), + baselineRecordsById: baseline, + activePhotoIds: activeIds, + serverClientPhotoIds: response.clientPhotoIds, + now: Int64(Date().timeIntervalSince1970 * 1000) + ) + let reconciledById = Dictionary(uniqueKeysWithValues: reconciled.map { ($0.id, $0) }) + guard reconciledById != persistedRecordsById else { return } + persistedRecordsById = reconciledById + storage.save(reconciled, albumId: albumId) + applyMergedPhotos() + selectedPhotoIds = selectedPhotoIds.intersection(selectableVisiblePhotoIds) + } catch is CancellationError { + return + } catch { + OTGLog.error(.connection, "sync server upload statuses failed: \(error.localizedDescription)") + } + } + } + private func applyMergedPhotos() { let persistedItems = persistedRecordsById.values .map { $0.toPhotoItem(storage: storage, albumId: albumId) } diff --git a/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift b/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift index 095ee29..6136835 100644 --- a/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift +++ b/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift @@ -12,19 +12,16 @@ final class CreateTravelAlbumSheetViewController: BaseViewController { private let viewModel: TravelAlbumEntryViewModel private let api: any TravelAlbumServing - private var mode: TravelAlbumEntryViewModel.CreateMode = .preShoot - private var selectedOrder: TravelAlbumAvailableOrder? + private let mode: TravelAlbumEntryViewModel.CreateMode = .preShoot private let scrollView = UIScrollView() private let contentView = UIView() private let titleLabel = UILabel() private let preShootCard = TravelAlbumModeOptionView() - private let preOrderCard = TravelAlbumModeOptionView() private let fieldsStack = UIStackView() private let freeCountField = UITextField() private let singlePriceField = UITextField() private let packagePriceField = UITextField() - private let orderButton = UIButton(type: .system) private let cancelButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system) @@ -58,11 +55,6 @@ final class CreateTravelAlbumSheetViewController: BaseViewController { desc: "拍完后分享给用户,用户在小程序上选择性购买", selected: true ) - preOrderCard.apply( - title: "买了再拍", - desc: "表示用户已经在小程序上下过单了,绑定对应订单上传后用户可以直接选片", - selected: false - ) fieldsStack.axis = .vertical fieldsStack.spacing = 14 @@ -71,23 +63,12 @@ final class CreateTravelAlbumSheetViewController: BaseViewController { configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad) configureTextField(packagePriceField, placeholder: "请输入打包价格", keyboardType: .decimalPad) - orderButton.contentHorizontalAlignment = .left - orderButton.setTitle("请选择订单", for: .normal) - orderButton.setTitleColor(AppColor.textTertiary, for: .normal) - orderButton.titleLabel?.font = .systemFont(ofSize: 14) - orderButton.layer.cornerRadius = 8 - orderButton.layer.borderColor = AppColor.border.cgColor - orderButton.layer.borderWidth = 1 - orderButton.setConfigurationContentInsets( - NSDirectionalEdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12) - ) - configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary) configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white) view.addSubview(scrollView) scrollView.addSubview(contentView) - [titleLabel, preShootCard, preOrderCard, fieldsStack, cancelButton, confirmButton].forEach(contentView.addSubview) + [titleLabel, preShootCard, fieldsStack, cancelButton, confirmButton].forEach(contentView.addSubview) rebuildFields() } @@ -107,12 +88,8 @@ final class CreateTravelAlbumSheetViewController: BaseViewController { make.top.equalTo(titleLabel.snp.bottom).offset(18) make.leading.trailing.equalToSuperview().inset(16) } - preOrderCard.snp.makeConstraints { make in - make.top.equalTo(preShootCard.snp.bottom).offset(10) - make.leading.trailing.equalTo(preShootCard) - } fieldsStack.snp.makeConstraints { make in - make.top.equalTo(preOrderCard.snp.bottom).offset(18) + make.top.equalTo(preShootCard.snp.bottom).offset(18) make.leading.trailing.equalTo(preShootCard) } cancelButton.snp.makeConstraints { make in @@ -130,9 +107,6 @@ final class CreateTravelAlbumSheetViewController: BaseViewController { } override func bindActions() { - preShootCard.addTarget(self, action: #selector(preShootTapped), for: .touchUpInside) - preOrderCard.addTarget(self, action: #selector(preOrderTapped), for: .touchUpInside) - orderButton.addTarget(self, action: #selector(orderTapped), for: .touchUpInside) cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) [freeCountField, singlePriceField, packagePriceField].forEach { @@ -171,13 +145,9 @@ final class CreateTravelAlbumSheetViewController: BaseViewController { private func rebuildFields() { fieldsStack.arrangedSubviews.forEach { $0.removeFromSuperview() } - if mode == .preShoot { - fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField)) - fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField)) - fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField)) - } else { - fieldsStack.addArrangedSubview(makeOrderGroup()) - } + fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField)) + fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField)) + fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField)) } private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView { @@ -196,23 +166,6 @@ final class CreateTravelAlbumSheetViewController: BaseViewController { return container } - private func makeOrderGroup() -> UIView { - let container = UIView() - let label = UILabel() - label.attributedText = fieldTitle("绑定订单", required: false) - container.addSubview(label) - container.addSubview(orderButton) - label.snp.makeConstraints { make in - make.top.leading.trailing.equalToSuperview() - } - orderButton.snp.makeConstraints { make in - make.top.equalTo(label.snp.bottom).offset(8) - make.leading.trailing.bottom.equalToSuperview() - make.height.greaterThanOrEqualTo(46) - } - return container - } - private func fieldTitle(_ text: String, required: Bool) -> NSAttributedString { let result = NSMutableAttributedString( string: text, @@ -224,58 +177,6 @@ final class CreateTravelAlbumSheetViewController: BaseViewController { return result } - private func updateMode(_ newMode: TravelAlbumEntryViewModel.CreateMode) { - mode = newMode - preShootCard.apply( - title: "先拍再买", - desc: "拍完后分享给用户,用户在小程序上选择性购买", - selected: mode == .preShoot - ) - preOrderCard.apply( - title: "买了再拍", - desc: "表示用户已经在小程序上下过单了,绑定对应订单上传后用户可以直接选片", - selected: mode == .preOrder - ) - rebuildFields() - } - - private func updateOrderButton() { - guard let order = selectedOrder else { - orderButton.setTitle("请选择订单", for: .normal) - orderButton.setTitleColor(AppColor.textTertiary, for: .normal) - return - } - let title = "\(order.projectName.isEmpty ? "未命名项目" : order.projectName)\n手机号:\(order.userPhone)\n订单号:\(order.orderNumber)" - orderButton.setTitle(title, for: .normal) - orderButton.setTitleColor(AppColor.textPrimary, for: .normal) - orderButton.titleLabel?.numberOfLines = 3 - } - - @objc private func preShootTapped() { - updateMode(.preShoot) - } - - @objc private func preOrderTapped() { - updateMode(.preOrder) - } - - @objc private func orderTapped() { - let alert = UIAlertController(title: "绑定订单", message: nil, preferredStyle: .actionSheet) - if viewModel.availableOrders.isEmpty { - alert.addAction(UIAlertAction(title: "暂无可绑定订单", style: .default)) - } else { - viewModel.availableOrders.forEach { order in - let title = "\(order.projectName.isEmpty ? "未命名项目" : order.projectName) \(order.userPhone)" - alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in - self?.selectedOrder = order - self?.updateOrderButton() - }) - } - } - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - present(alert, animated: true) - } - @objc private func cancelTapped() { dismiss(animated: true) } @@ -287,7 +188,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController { freeCount: freeCountField.text ?? "", singlePrice: singlePriceField.text ?? "", packagePrice: packagePriceField.text ?? "", - order: selectedOrder, + order: nil, api: api ) await MainActor.run { diff --git a/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift index 64190e2..15c24f0 100644 --- a/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift @@ -421,13 +421,19 @@ final class WildReportRiskMapViewController: BaseViewController { let avatarWrap = UIView() avatarWrap.backgroundColor = AppColor.success.withAlphaComponent(0.12) avatarWrap.layer.cornerRadius = 28 - let avatar = UIImageView(image: UIImage(systemName: clue.avatar ?? "person.crop.circle.fill")) + let placeholder = UIImage(systemName: "person.crop.circle.fill") + let avatar = UIImageView(image: placeholder) avatar.tintColor = AppColor.success - avatar.contentMode = .scaleAspectFit + avatar.contentMode = .scaleAspectFill + avatar.clipsToBounds = true + avatar.layer.cornerRadius = 28 + let avatarURLString = clue.avatar?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if let avatarURL = URL(string: avatarURLString), !avatarURLString.isEmpty { + avatar.kf.setImage(with: avatarURL, placeholder: placeholder) + } avatarWrap.addSubview(avatar) avatar.snp.makeConstraints { make in - make.center.equalToSuperview() - make.size.equalTo(42) + make.edges.equalToSuperview() } avatarWrap.snp.makeConstraints { make in make.size.equalTo(56) diff --git a/suixinkanTests/TravelAlbumAPITests.swift b/suixinkanTests/TravelAlbumAPITests.swift index 7648a87..8398fc3 100644 --- a/suixinkanTests/TravelAlbumAPITests.swift +++ b/suixinkanTests/TravelAlbumAPITests.swift @@ -82,7 +82,8 @@ final class TravelAlbumAPITests: XCTestCase { TravelAlbumUploadMaterialRequest( userEquityTravelId: 3, fileName: "A.JPG", - fileUrl: "https://cdn/a.jpg" + fileUrl: "https://cdn/a.jpg", + clientPhotoId: "2a1d96b1-c0cc-489f-9f42-419ff1439a62" ) ) @@ -94,6 +95,22 @@ final class TravelAlbumAPITests: XCTestCase { XCTAssertEqual(body?["user_equity_travel_id"] as? Int, 3) XCTAssertEqual(body?["file_name"] as? String, "A.JPG") XCTAssertEqual(body?["file_url"] as? String, "https://cdn/a.jpg") + XCTAssertEqual(body?["client_photo_id"] as? String, "2a1d96b1-c0cc-489f-9f42-419ff1439a62") + } + + func testMaterialClientPhotoIdsBuildsPathQueryAndDecodesResponse() async throws { + let data = envelopeJSON(#"{"client_photo_ids":["one","two"]}"#) + let session = MockURLSession(responses: [data]) + let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) + + let response = try await api.materialClientPhotoIds(userEquityTravelId: 3) + + XCTAssertEqual(response.clientPhotoIds, ["one", "two"]) + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/material-client-photo-ids") + let query = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(query?.first { $0.name == "user_equity_travel_id" }?.value, "3") } private func envelopeJSON(_ dataJSON: String) -> Data { diff --git a/suixinkanTests/TravelAlbumOTGStorageTests.swift b/suixinkanTests/TravelAlbumOTGStorageTests.swift index 8ba0567..f911351 100644 --- a/suixinkanTests/TravelAlbumOTGStorageTests.swift +++ b/suixinkanTests/TravelAlbumOTGStorageTests.swift @@ -90,6 +90,78 @@ final class TravelAlbumOTGStorageTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: another.deletingLastPathComponent().deletingLastPathComponent().path)) } + func testLegacyRecordGetsStableClientPhotoIdOnLoad() throws { + let root = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = makeStore(root: root) + let legacyJSON = """ + {"id":"legacy","sourceId":"legacy","fileName":"A.JPG","localPath":"",\ + "thumbnailPath":"","capturedAt":"2026-07-16 12:00:00","fileSizeBytes":1,\ + "status":"UPLOADED","progress":100,"albumId":33,"userId":"u1",\ + "remoteUrl":"https://cdn/a.jpg","updatedAt":1} + """.data(using: .utf8)! + let legacy = try JSONDecoder().decode(TravelAlbumOTGPhotoRecord.self, from: legacyJSON) + XCTAssertTrue(legacy.clientPhotoId.isEmpty) + store.save([legacy], albumId: 33) + + let first = try XCTUnwrap(store.load(albumId: 33).first?.clientPhotoId) + let second = try XCTUnwrap(store.load(albumId: 33).first?.clientPhotoId) + + XCTAssertNotNil(UUID(uuidString: first)) + XCTAssertEqual(first, first.lowercased()) + XCTAssertEqual(first, second) + } + + func testServerStatusPolicyPromotesDemotesAndPreservesActiveOrChangedRecords() { + let matched = makeStatusRecord(id: "matched", clientPhotoId: "server-id", status: .failed) + let missing = makeStatusRecord(id: "missing", clientPhotoId: "missing-id", status: .uploaded) + let active = makeStatusRecord(id: "active", clientPhotoId: "active-id", status: .uploaded) + let changedBaseline = makeStatusRecord(id: "changed", clientPhotoId: "changed-id", status: .uploaded) + var changed = changedBaseline + changed.progress = 37 + + let result = TravelAlbumOTGServerStatusPolicy.reconcile( + records: [matched, missing, active, changed], + baselineRecordsById: [ + "matched": matched, + "missing": missing, + "active": active, + "changed": changedBaseline, + ], + activePhotoIds: ["active"], + serverClientPhotoIds: [" SERVER-ID "], + now: 9 + ) + let byId = Dictionary(uniqueKeysWithValues: result.map { ($0.id, $0) }) + + XCTAssertEqual(byId["matched"]?.status, .uploaded) + XCTAssertEqual(byId["matched"]?.progress, 100) + XCTAssertEqual(byId["missing"]?.status, .pending) + XCTAssertEqual(byId["active"]?.status, .uploaded) + XCTAssertEqual(byId["changed"]?.status, .uploaded) + } + + private func makeStatusRecord( + id: String, + clientPhotoId: String, + status: TravelAlbumOTGUploadStatus + ) -> TravelAlbumOTGPhotoRecord { + TravelAlbumOTGPhotoRecord( + id: id, + clientPhotoId: clientPhotoId, + fileName: "\(id).JPG", + localPath: "", + capturedAt: "2026-07-16 12:00:00", + fileSizeBytes: 1, + status: status, + progress: status == .uploaded ? 100 : 0, + albumId: 33, + userId: "u1", + remoteUrl: "https://cdn/\(id).jpg", + updatedAt: 1 + ) + } + private func makeStore(root: URL) -> TravelAlbumOTGPhotoStore { TravelAlbumOTGPhotoStore( context: TravelAlbumOTGStorageContext( diff --git a/suixinkanTests/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbumViewModelTests.swift index 75833a9..80dd3f4 100644 --- a/suixinkanTests/TravelAlbumViewModelTests.swift +++ b/suixinkanTests/TravelAlbumViewModelTests.swift @@ -483,6 +483,37 @@ final class WiredCameraTransferViewModelTests: XCTestCase { XCTAssertEqual(Set(result.importedPhotoIds), Set(records.map(\.id))) XCTAssertEqual(Set(records.map(\.status)), [.uploaded]) XCTAssertEqual(Set(records.map(\.fileName)).count, 2) + XCTAssertEqual(Set(records.map(\.clientPhotoId)).count, 2) + XCTAssertTrue(records.allSatisfy { UUID(uuidString: $0.clientPhotoId) != nil }) + } + + func testStartSynchronizesPersistedUploadStatusesFromServerClientPhotoIds() async { + let context = makeOTGTestContext() + let api = TravelAlbumMockAPI() + api.materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse( + clientPhotoIds: ["server-id"] + ) + let records = [ + makeOTGRecord(id: "matched", capturedAt: "2026-07-16 12:00:00", status: .failed) + .withClientPhotoId("server-id"), + makeOTGRecord(id: "missing", capturedAt: "2026-07-16 12:01:00", status: .uploaded) + .withClientPhotoId("missing-id"), + ] + context.store.save(records, albumId: 9) + let viewModel = makeWiredViewModel( + context: context, + manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])), + api: api + ) + + viewModel.start() + await waitUntil { + let byId = Dictionary(uniqueKeysWithValues: context.store.load(albumId: 9).map { ($0.id, $0) }) + return byId["matched"]?.status == .uploaded && byId["missing"]?.status == .pending + } + viewModel.stop() + + XCTAssertEqual(api.materialClientPhotoIdsCallCount, 1) } } @@ -683,6 +714,7 @@ private func makeWiredViewModel( context: OTGTestContext, manager: MockWiredCameraConnectionManager, uploader: MockTravelAlbumOTGUploader? = nil, + api: TravelAlbumMockAPI? = nil, userDefaults: UserDefaults = makeOTGTestDefaults() ) -> WiredCameraTransferViewModel { prepareAppStoreForOTGTests() @@ -693,10 +725,19 @@ private func makeWiredViewModel( connectionManager: manager, storage: context.store, uploader: uploader ?? MockTravelAlbumOTGUploader(), + api: api ?? TravelAlbumMockAPI(), userDefaults: userDefaults ) } +private extension TravelAlbumOTGPhotoRecord { + func withClientPhotoId(_ value: String) -> TravelAlbumOTGPhotoRecord { + var copy = self + copy.clientPhotoId = value + return copy + } +} + private func makeOTGPhotoItem( id: String, capturedAt: String, @@ -914,6 +955,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing { var infoResponse = TravelAlbum() var materialListResponses: [TravelAlbumListResponse] = [] var uploadMaterialResponse = TravelAlbumMaterial() + var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: []) var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "") var createError: Error? @@ -921,6 +963,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing { private(set) var createRequests: [TravelAlbumCreateRequest] = [] private(set) var materialRequests: [MaterialRequest] = [] private(set) var uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = [] + private(set) var materialClientPhotoIdsCallCount = 0 private(set) var deletedAlbumIds: [Int] = [] private(set) var deletedMaterialIds: [Int] = [] @@ -968,6 +1011,11 @@ final class TravelAlbumMockAPI: TravelAlbumServing { return uploadMaterialResponse } + func materialClientPhotoIds(userEquityTravelId: Int) async throws -> TravelAlbumMaterialClientPhotoIDsResponse { + materialClientPhotoIdsCallCount += 1 + return materialClientPhotoIdsResponse + } + func deleteAlbum(id: Int) async throws { deletedAlbumIds.append(id) } diff --git a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift index 1b7694b..d32a1d9 100644 --- a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift +++ b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift @@ -505,6 +505,7 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertEqual(queryItems["latitude"], "43.3779") XCTAssertEqual(queryItems["longitude"], "84.0217") XCTAssertEqual(response.name, "阿依达娜") + XCTAssertEqual(response.avatar, "https://example.com/avatar.jpg") XCTAssertEqual(response.storeName, "空中草原旅拍店") XCTAssertTrue(response.online) }