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

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

View File

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

View File

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

View File

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