为旅拍 OTG 上传增加 client_photo_id,并按服务端已登记 ID 同步本地上传状态。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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(
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<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` 同步来的边拍边传写入调用。
|
||||
enum AlbumPhotoStorage {
|
||||
/// 写入原片数据到当前账号与相册隔离目录。
|
||||
|
||||
@ -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 "照片标识缺失,请重新导入后重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String> = []
|
||||
private var suppressSelectedTimeSlotNotification = false
|
||||
private var serverStatusSyncTask: Task<Void, Never>?
|
||||
|
||||
@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) }
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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<TravelAlbumMaterial>] = []
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user