为旅拍 OTG 上传增加 client_photo_id,并按服务端已登记 ID 同步本地上传状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-16 19:13:19 +08:00
parent 3d5ad8a614
commit b019e1e494
11 changed files with 323 additions and 115 deletions

View File

@ -32,6 +32,9 @@ protocol TravelAlbumServing {
/// ///
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial
/// ID
func materialClientPhotoIds(userEquityTravelId: Int) async throws -> TravelAlbumMaterialClientPhotoIDsResponse
/// ///
func deleteAlbum(id: Int) async throws 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)) 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 { func deleteAlbum(id: Int) async throws {
let _: EmptyPayload = try await client.send( let _: EmptyPayload = try await client.send(

View File

@ -203,11 +203,22 @@ struct TravelAlbumUploadMaterialRequest: Encodable, Sendable, Equatable {
let userEquityTravelId: Int let userEquityTravelId: Int
let fileName: String let fileName: String
let fileUrl: String let fileUrl: String
let clientPhotoId: String
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case userEquityTravelId = "user_equity_travel_id" case userEquityTravelId = "user_equity_travel_id"
case fileName = "file_name" case fileName = "file_name"
case fileUrl = "file_url" 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"
} }
} }

View File

@ -5,6 +5,25 @@
import Foundation 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` /// OTG Android `WiredTransferUploadStatus`
enum TravelAlbumOTGUploadStatus: String, Codable, Equatable, Sendable { enum TravelAlbumOTGUploadStatus: String, Codable, Equatable, Sendable {
case pending = "PENDING" case pending = "PENDING"
@ -25,6 +44,7 @@ enum TravelAlbumOTGPhotoFormatOption: String, CaseIterable, Equatable, Sendable
struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable { struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
let id: String let id: String
let sourceId: String let sourceId: String
var clientPhotoId: String
let fileName: String let fileName: String
var localPath: String var localPath: String
var thumbnailPath: String var thumbnailPath: String
@ -42,6 +62,7 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
init( init(
id: String, id: String,
sourceId: String? = nil, sourceId: String? = nil,
clientPhotoId: String = TravelAlbumClientPhotoID.make(),
fileName: String, fileName: String,
localPath: String, localPath: String,
thumbnailPath: String = "", thumbnailPath: String = "",
@ -57,6 +78,7 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
) { ) {
self.id = id self.id = id
self.sourceId = sourceId ?? id self.sourceId = sourceId ?? id
self.clientPhotoId = TravelAlbumClientPhotoID.normalize(clientPhotoId) ?? TravelAlbumClientPhotoID.make()
self.fileName = fileName self.fileName = fileName
self.localPath = localPath self.localPath = localPath
self.thumbnailPath = thumbnailPath self.thumbnailPath = thumbnailPath
@ -71,6 +93,31 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
self.updatedAt = updatedAt 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 { func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord {
guard status == .transferring || status == .uploading else { return self } 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 { let decoded = try? JSONDecoder().decode([TravelAlbumOTGPhotoRecord].self, from: data) else {
return [] return []
} }
return decoded let scoped = decoded.filter { $0.albumId == albumId && $0.userId == context.userId }
.filter { $0.albumId == albumId && $0.userId == context.userId } let migrated = scoped.map { record -> TravelAlbumOTGPhotoRecord in
.map { $0.normalizedAfterInterruptedTransfer() } 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) } .filter { isDisplayable($0, albumId: albumId) }
.sorted { $0.capturedAt > $1.capturedAt } .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<String>,
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` /// `otg_swift`
enum AlbumPhotoStorage { enum AlbumPhotoStorage {
/// ///

View File

@ -44,6 +44,9 @@ final class TravelAlbumOTGUploader: TravelAlbumOTGUploading {
FileManager.default.fileExists(atPath: record.localPath) else { FileManager.default.fileExists(atPath: record.localPath) else {
throw TravelAlbumOTGUploadError.localFileMissing throw TravelAlbumOTGUploadError.localFileMissing
} }
guard let clientPhotoId = TravelAlbumClientPhotoID.normalize(record.clientPhotoId) else {
throw TravelAlbumOTGUploadError.clientPhotoIdMissing
}
let data = try Data(contentsOf: URL(fileURLWithPath: record.localPath)) let data = try Data(contentsOf: URL(fileURLWithPath: record.localPath))
let remoteURL = try await uploader.uploadTravelAlbumMaterial( let remoteURL = try await uploader.uploadTravelAlbumMaterial(
@ -56,7 +59,8 @@ final class TravelAlbumOTGUploader: TravelAlbumOTGUploading {
TravelAlbumUploadMaterialRequest( TravelAlbumUploadMaterialRequest(
userEquityTravelId: record.albumId, userEquityTravelId: record.albumId,
fileName: record.fileName, fileName: record.fileName,
fileUrl: remoteURL fileUrl: remoteURL,
clientPhotoId: clientPhotoId
) )
) )
} }
@ -66,6 +70,7 @@ final class TravelAlbumOTGUploader: TravelAlbumOTGUploading {
enum TravelAlbumOTGUploadError: LocalizedError, Equatable { enum TravelAlbumOTGUploadError: LocalizedError, Equatable {
case invalidAlbum case invalidAlbum
case localFileMissing case localFileMissing
case clientPhotoIdMissing
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
@ -73,6 +78,8 @@ enum TravelAlbumOTGUploadError: LocalizedError, Equatable {
return "请先选择有效的相册" return "请先选择有效的相册"
case .localFileMissing: case .localFileMissing:
return "本地文件不存在,无法上传" return "本地文件不存在,无法上传"
case .clientPhotoIdMissing:
return "照片标识缺失,请重新导入后重试"
} }
} }
} }

View File

@ -77,6 +77,7 @@ final class WiredCameraTransferViewModel {
private let storage: TravelAlbumOTGPhotoStore private let storage: TravelAlbumOTGPhotoStore
private let repository: TravelAlbumOTGPhotoRepository private let repository: TravelAlbumOTGPhotoRepository
private let uploader: any TravelAlbumOTGUploading private let uploader: any TravelAlbumOTGUploading
private let api: any TravelAlbumServing
private let appStore: AppStore private let appStore: AppStore
private let userDefaults: UserDefaults private let userDefaults: UserDefaults
@ -85,6 +86,7 @@ final class WiredCameraTransferViewModel {
private var isUploading = false private var isUploading = false
private var queuedAutoUploadPhotoIds: Set<String> = [] private var queuedAutoUploadPhotoIds: Set<String> = []
private var suppressSelectedTimeSlotNotification = false private var suppressSelectedTimeSlotNotification = false
private var serverStatusSyncTask: Task<Void, Never>?
@MainActor @MainActor
init( init(
@ -95,6 +97,7 @@ final class WiredCameraTransferViewModel {
connectionManager: (any WiredCameraConnectionManaging)? = nil, connectionManager: (any WiredCameraConnectionManaging)? = nil,
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(), storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
uploader: (any TravelAlbumOTGUploading)? = nil, uploader: (any TravelAlbumOTGUploading)? = nil,
api: (any TravelAlbumServing)? = nil,
appStore: AppStore = .shared, appStore: AppStore = .shared,
userDefaults: UserDefaults = .standard userDefaults: UserDefaults = .standard
) { ) {
@ -106,6 +109,7 @@ final class WiredCameraTransferViewModel {
self.storage = storage self.storage = storage
self.repository = TravelAlbumOTGPhotoRepository(storage: storage) self.repository = TravelAlbumOTGPhotoRepository(storage: storage)
self.uploader = uploader ?? TravelAlbumOTGUploader() self.uploader = uploader ?? TravelAlbumOTGUploader()
self.api = api ?? NetworkServices.shared.travelAlbumAPI
self.appStore = appStore self.appStore = appStore
self.userDefaults = userDefaults self.userDefaults = userDefaults
self.transferMode = Self.persistedTransferMode(in: userDefaults) self.transferMode = Self.persistedTransferMode(in: userDefaults)
@ -225,11 +229,14 @@ final class WiredCameraTransferViewModel {
connectionManager.delegate = self connectionManager.delegate = self
syncFromConnectionManager() syncFromConnectionManager()
loadPersistedPhotos() loadPersistedPhotos()
syncServerUploadStatuses()
connectionManager.start() connectionManager.start()
} }
/// UI delegate /// UI delegate
func stop() { func stop() {
serverStatusSyncTask?.cancel()
serverStatusSyncTask = nil
connectionManager.unbindDelegate() connectionManager.unbindDelegate()
connectionManager.suspendLiveTransfer() connectionManager.suspendLiveTransfer()
} }
@ -483,6 +490,39 @@ final class WiredCameraTransferViewModel {
applyMergedPhotos() 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() { private func applyMergedPhotos() {
let persistedItems = persistedRecordsById.values let persistedItems = persistedRecordsById.values
.map { $0.toPhotoItem(storage: storage, albumId: albumId) } .map { $0.toPhotoItem(storage: storage, albumId: albumId) }

View File

@ -12,19 +12,16 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
private let viewModel: TravelAlbumEntryViewModel private let viewModel: TravelAlbumEntryViewModel
private let api: any TravelAlbumServing private let api: any TravelAlbumServing
private var mode: TravelAlbumEntryViewModel.CreateMode = .preShoot private let mode: TravelAlbumEntryViewModel.CreateMode = .preShoot
private var selectedOrder: TravelAlbumAvailableOrder?
private let scrollView = UIScrollView() private let scrollView = UIScrollView()
private let contentView = UIView() private let contentView = UIView()
private let titleLabel = UILabel() private let titleLabel = UILabel()
private let preShootCard = TravelAlbumModeOptionView() private let preShootCard = TravelAlbumModeOptionView()
private let preOrderCard = TravelAlbumModeOptionView()
private let fieldsStack = UIStackView() private let fieldsStack = UIStackView()
private let freeCountField = UITextField() private let freeCountField = UITextField()
private let singlePriceField = UITextField() private let singlePriceField = UITextField()
private let packagePriceField = UITextField() private let packagePriceField = UITextField()
private let orderButton = UIButton(type: .system)
private let cancelButton = UIButton(type: .system) private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system)
@ -58,11 +55,6 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
desc: "拍完后分享给用户,用户在小程序上选择性购买", desc: "拍完后分享给用户,用户在小程序上选择性购买",
selected: true selected: true
) )
preOrderCard.apply(
title: "买了再拍",
desc: "表示用户已经在小程序上下过单了,绑定对应订单上传后用户可以直接选片",
selected: false
)
fieldsStack.axis = .vertical fieldsStack.axis = .vertical
fieldsStack.spacing = 14 fieldsStack.spacing = 14
@ -71,23 +63,12 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad) configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad)
configureTextField(packagePriceField, 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(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary)
configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white) configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white)
view.addSubview(scrollView) view.addSubview(scrollView)
scrollView.addSubview(contentView) scrollView.addSubview(contentView)
[titleLabel, preShootCard, preOrderCard, fieldsStack, cancelButton, confirmButton].forEach(contentView.addSubview) [titleLabel, preShootCard, fieldsStack, cancelButton, confirmButton].forEach(contentView.addSubview)
rebuildFields() rebuildFields()
} }
@ -107,12 +88,8 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
make.top.equalTo(titleLabel.snp.bottom).offset(18) make.top.equalTo(titleLabel.snp.bottom).offset(18)
make.leading.trailing.equalToSuperview().inset(16) 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 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) make.leading.trailing.equalTo(preShootCard)
} }
cancelButton.snp.makeConstraints { make in cancelButton.snp.makeConstraints { make in
@ -130,9 +107,6 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
} }
override func bindActions() { 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) cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
[freeCountField, singlePriceField, packagePriceField].forEach { [freeCountField, singlePriceField, packagePriceField].forEach {
@ -171,13 +145,9 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
private func rebuildFields() { private func rebuildFields() {
fieldsStack.arrangedSubviews.forEach { $0.removeFromSuperview() } fieldsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
if mode == .preShoot { fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField))
fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField)) fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField))
fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField)) fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField))
fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField))
} else {
fieldsStack.addArrangedSubview(makeOrderGroup())
}
} }
private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView { private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView {
@ -196,23 +166,6 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
return container 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 { private func fieldTitle(_ text: String, required: Bool) -> NSAttributedString {
let result = NSMutableAttributedString( let result = NSMutableAttributedString(
string: text, string: text,
@ -224,58 +177,6 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
return result 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() { @objc private func cancelTapped() {
dismiss(animated: true) dismiss(animated: true)
} }
@ -287,7 +188,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
freeCount: freeCountField.text ?? "", freeCount: freeCountField.text ?? "",
singlePrice: singlePriceField.text ?? "", singlePrice: singlePriceField.text ?? "",
packagePrice: packagePriceField.text ?? "", packagePrice: packagePriceField.text ?? "",
order: selectedOrder, order: nil,
api: api api: api
) )
await MainActor.run { await MainActor.run {

View File

@ -421,13 +421,19 @@ final class WildReportRiskMapViewController: BaseViewController {
let avatarWrap = UIView() let avatarWrap = UIView()
avatarWrap.backgroundColor = AppColor.success.withAlphaComponent(0.12) avatarWrap.backgroundColor = AppColor.success.withAlphaComponent(0.12)
avatarWrap.layer.cornerRadius = 28 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.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) avatarWrap.addSubview(avatar)
avatar.snp.makeConstraints { make in avatar.snp.makeConstraints { make in
make.center.equalToSuperview() make.edges.equalToSuperview()
make.size.equalTo(42)
} }
avatarWrap.snp.makeConstraints { make in avatarWrap.snp.makeConstraints { make in
make.size.equalTo(56) make.size.equalTo(56)

View File

@ -82,7 +82,8 @@ final class TravelAlbumAPITests: XCTestCase {
TravelAlbumUploadMaterialRequest( TravelAlbumUploadMaterialRequest(
userEquityTravelId: 3, userEquityTravelId: 3,
fileName: "A.JPG", 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?["user_equity_travel_id"] as? Int, 3)
XCTAssertEqual(body?["file_name"] as? String, "A.JPG") XCTAssertEqual(body?["file_name"] as? String, "A.JPG")
XCTAssertEqual(body?["file_url"] as? String, "https://cdn/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 { private func envelopeJSON(_ dataJSON: String) -> Data {

View File

@ -90,6 +90,78 @@ final class TravelAlbumOTGStorageTests: XCTestCase {
XCTAssertFalse(FileManager.default.fileExists(atPath: another.deletingLastPathComponent().deletingLastPathComponent().path)) 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 { private func makeStore(root: URL) -> TravelAlbumOTGPhotoStore {
TravelAlbumOTGPhotoStore( TravelAlbumOTGPhotoStore(
context: TravelAlbumOTGStorageContext( context: TravelAlbumOTGStorageContext(

View File

@ -483,6 +483,37 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertEqual(Set(result.importedPhotoIds), Set(records.map(\.id))) XCTAssertEqual(Set(result.importedPhotoIds), Set(records.map(\.id)))
XCTAssertEqual(Set(records.map(\.status)), [.uploaded]) XCTAssertEqual(Set(records.map(\.status)), [.uploaded])
XCTAssertEqual(Set(records.map(\.fileName)).count, 2) 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, context: OTGTestContext,
manager: MockWiredCameraConnectionManager, manager: MockWiredCameraConnectionManager,
uploader: MockTravelAlbumOTGUploader? = nil, uploader: MockTravelAlbumOTGUploader? = nil,
api: TravelAlbumMockAPI? = nil,
userDefaults: UserDefaults = makeOTGTestDefaults() userDefaults: UserDefaults = makeOTGTestDefaults()
) -> WiredCameraTransferViewModel { ) -> WiredCameraTransferViewModel {
prepareAppStoreForOTGTests() prepareAppStoreForOTGTests()
@ -693,10 +725,19 @@ private func makeWiredViewModel(
connectionManager: manager, connectionManager: manager,
storage: context.store, storage: context.store,
uploader: uploader ?? MockTravelAlbumOTGUploader(), uploader: uploader ?? MockTravelAlbumOTGUploader(),
api: api ?? TravelAlbumMockAPI(),
userDefaults: userDefaults userDefaults: userDefaults
) )
} }
private extension TravelAlbumOTGPhotoRecord {
func withClientPhotoId(_ value: String) -> TravelAlbumOTGPhotoRecord {
var copy = self
copy.clientPhotoId = value
return copy
}
}
private func makeOTGPhotoItem( private func makeOTGPhotoItem(
id: String, id: String,
capturedAt: String, capturedAt: String,
@ -914,6 +955,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
var infoResponse = TravelAlbum() var infoResponse = TravelAlbum()
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = [] var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
var uploadMaterialResponse = TravelAlbumMaterial() var uploadMaterialResponse = TravelAlbumMaterial()
var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "") var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
var createError: Error? var createError: Error?
@ -921,6 +963,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
private(set) var createRequests: [TravelAlbumCreateRequest] = [] private(set) var createRequests: [TravelAlbumCreateRequest] = []
private(set) var materialRequests: [MaterialRequest] = [] private(set) var materialRequests: [MaterialRequest] = []
private(set) var uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = [] private(set) var uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = []
private(set) var materialClientPhotoIdsCallCount = 0
private(set) var deletedAlbumIds: [Int] = [] private(set) var deletedAlbumIds: [Int] = []
private(set) var deletedMaterialIds: [Int] = [] private(set) var deletedMaterialIds: [Int] = []
@ -968,6 +1011,11 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
return uploadMaterialResponse return uploadMaterialResponse
} }
func materialClientPhotoIds(userEquityTravelId: Int) async throws -> TravelAlbumMaterialClientPhotoIDsResponse {
materialClientPhotoIdsCallCount += 1
return materialClientPhotoIdsResponse
}
func deleteAlbum(id: Int) async throws { func deleteAlbum(id: Int) async throws {
deletedAlbumIds.append(id) deletedAlbumIds.append(id)
} }

View File

@ -505,6 +505,7 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(queryItems["latitude"], "43.3779") XCTAssertEqual(queryItems["latitude"], "43.3779")
XCTAssertEqual(queryItems["longitude"], "84.0217") XCTAssertEqual(queryItems["longitude"], "84.0217")
XCTAssertEqual(response.name, "") XCTAssertEqual(response.name, "")
XCTAssertEqual(response.avatar, "https://example.com/avatar.jpg")
XCTAssertEqual(response.storeName, "空中草原旅拍店") XCTAssertEqual(response.storeName, "空中草原旅拍店")
XCTAssertTrue(response.online) XCTAssertTrue(response.online)
} }