为旅拍 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
/// 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(

View File

@ -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"
}
}

View File

@ -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 {
///

View File

@ -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 "照片标识缺失,请重新导入后重试"
}
}
}

View File

@ -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) }

View File

@ -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 {

View File

@ -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)