From 726f19c124e67f93b4a45a5e0325f15d772a1f4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Mon, 22 Jun 2026 16:30:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20OSS=20=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E4=B8=8E=20Kingfisher=20=E5=9B=BE=E7=89=87=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- suixinkan.xcodeproj/project.pbxproj | 22 ++ suixinkan/App/RootView.swift | 7 + suixinkan/Core/Core.md | 14 +- suixinkan/Core/UI/RemoteImage.swift | 81 ++++++ suixinkan/Core/Upload/OSSUploadService.swift | 245 ++++++++++++++++++ suixinkan/Core/Upload/Upload.md | 51 ++++ suixinkan/Core/Upload/UploadAPI.swift | 41 +++ .../Core/Upload/UploadImageProcessors.swift | 121 +++++++++ suixinkan/Core/Upload/UploadModels.swift | 100 +++++++ .../Home/Views/HomeMoreFunctionsView.swift | 13 +- suixinkan/Features/Home/Views/HomeView.swift | 15 +- suixinkan/Features/Profile/Profile.md | 30 ++- .../Profile/ViewModels/ProfileViewModel.swift | 56 +++- .../ViewModels/RealNameAuthViewModel.swift | 67 ++++- .../Profile/Views/AccountSwitchView.swift | 16 +- .../Features/Profile/Views/ProfileView.swift | 90 ++++--- .../Profile/Views/RealNameAuthView.swift | 134 +++++++--- .../ProfileSecondaryPagesTests.swift | 182 +++++++++++++ suixinkanTests/UploadTests.swift | 113 ++++++++ 功能同步Checklist.md | 135 ++++++++++ 20 files changed, 1399 insertions(+), 134 deletions(-) create mode 100644 suixinkan/Core/UI/RemoteImage.swift create mode 100644 suixinkan/Core/Upload/OSSUploadService.swift create mode 100644 suixinkan/Core/Upload/Upload.md create mode 100644 suixinkan/Core/Upload/UploadAPI.swift create mode 100644 suixinkan/Core/Upload/UploadImageProcessors.swift create mode 100644 suixinkan/Core/Upload/UploadModels.swift create mode 100644 suixinkanTests/UploadTests.swift create mode 100644 功能同步Checklist.md diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index a67f015..5b28eba 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -6,6 +6,11 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 93DAED0B2FE8E50000B9E2B1 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 93DAED0D2FE8E50000B9E2B1 /* Kingfisher */; }; + 93DAED0C2FE8E50000B9E2B1 /* AlibabaCloudOSS in Frameworks */ = {isa = PBXBuildFile; productRef = 93DAED0E2FE8E50000B9E2B1 /* AlibabaCloudOSS */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ A00000072FE9000000000007 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -39,6 +44,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 93DAED0B2FE8E50000B9E2B1 /* Kingfisher in Frameworks */, + 93DAED0C2FE8E50000B9E2B1 /* AlibabaCloudOSS in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -90,6 +97,8 @@ ); name = suixinkan; packageProductDependencies = ( + 93DAED0D2FE8E50000B9E2B1 /* Kingfisher */, + 93DAED0E2FE8E50000B9E2B1 /* AlibabaCloudOSS */, ); productName = suixinkan; productReference = 939AC7962FE3F832004B22E4 /* suixinkan.app */; @@ -515,6 +524,19 @@ }; }; /* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 93DAED0D2FE8E50000B9E2B1 /* Kingfisher */ = { + isa = XCSwiftPackageProductDependency; + package = 93DAED092FE8E14D00B9E2B1 /* XCRemoteSwiftPackageReference "Kingfisher" */; + productName = Kingfisher; + }; + 93DAED0E2FE8E50000B9E2B1 /* AlibabaCloudOSS */ = { + isa = XCSwiftPackageProductDependency; + package = 93DAED0A2FE8E17E00B9E2B1 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */; + productName = AlibabaCloudOSS; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 939AC78E2FE3F832004B22E4 /* Project object */; } diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index 615a5a0..aca796e 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -18,6 +18,8 @@ struct RootView: View { @State private var apiClient: APIClient @State private var authAPI: AuthAPI @State private var profileAPI: ProfileAPI + @State private var uploadAPI: UploadAPI + @State private var ossUploadService: OSSUploadService @State private var accountContextAPI: AccountContextAPI @State private var ordersAPI: OrdersAPI @State private var statisticsAPI: StatisticsAPI @@ -32,6 +34,9 @@ struct RootView: View { _apiClient = State(initialValue: apiClient) _authAPI = State(initialValue: AuthAPI(client: apiClient)) _profileAPI = State(initialValue: ProfileAPI(client: apiClient)) + let uploadAPI = UploadAPI(client: apiClient) + _uploadAPI = State(initialValue: uploadAPI) + _ossUploadService = State(initialValue: OSSUploadService(configService: uploadAPI)) _accountContextAPI = State(initialValue: AccountContextAPI(client: apiClient)) _ordersAPI = State(initialValue: OrdersAPI(client: apiClient)) _statisticsAPI = State(initialValue: StatisticsAPI(client: apiClient)) @@ -62,6 +67,8 @@ struct RootView: View { .environment(apiClient) .environment(authAPI) .environment(profileAPI) + .environment(uploadAPI) + .environment(ossUploadService) .environment(accountContextAPI) .environment(ordersAPI) .environment(statisticsAPI) diff --git a/suixinkan/Core/Core.md b/suixinkan/Core/Core.md index aa60590..fcffc1a 100644 --- a/suixinkan/Core/Core.md +++ b/suixinkan/Core/Core.md @@ -8,6 +8,7 @@ Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存 - `Networking`:统一 API 请求、响应解析、错误处理和 token 注入。 - `Storage`:统一登录 token、账号快照和 App 偏好的本地存储。 - `Design`:统一颜色、字号、间距、控件尺寸和圆角。 +- `Upload`:统一阿里云 OSS 上传、图片压缩、上传策略和 Kingfisher 网络图片展示。 ## Networking @@ -49,7 +50,7 @@ Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存 - 正式 token 只放 Keychain。 - 临时 token 只放内存。 - 密码、验证码、OSS STS token、一次性扫码结果和错误提示不落盘。 -- 头像图片缓存交给 Kingfisher,Core 不保存图片 Data。 +- 头像、证件照等图片缓存交给 Kingfisher,Core 不保存图片 Data。 `AccountSnapshot` 保存可重建的账号展示和业务上下文: - `AccountProfile` @@ -63,3 +64,14 @@ Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存 `AppDesign` 管理跨页面颜色。`AppMetrics` 管理常用字号、间距、控件尺寸、行距和圆角。 新增页面时优先使用 `AppMetrics` 和 `AppDesign`。只有明显属于单个页面的特殊尺寸,才保留在页面本地。 + +## Upload + +`UploadAPI` 通过 `/api/app/config/get-sts-token` 获取阿里云 OSS 临时上传配置。`OSSUploadService` 负责校验文件、生成 objectKey、调用 `AlibabaCloudOSS` SDK 并返回最终文件 URL。 + +上传模块只保存内存状态: +- STS token 不写入 Keychain 或 UserDefaults。 +- 用户选择的本地图片 Data 不落盘。 +- 上传进度只用于当前页面展示。 + +网络图片统一使用 `RemoteImage` / `RemoteAvatarImage`,内部由 Kingfisher 负责下载和缓存。业务页面不要再直接使用 `AsyncImage` 加载网络图片。 diff --git a/suixinkan/Core/UI/RemoteImage.swift b/suixinkan/Core/UI/RemoteImage.swift new file mode 100644 index 0000000..f430f83 --- /dev/null +++ b/suixinkan/Core/UI/RemoteImage.swift @@ -0,0 +1,81 @@ +// +// RemoteImage.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Kingfisher +import SwiftUI + +/// 网络图片组件,统一使用 Kingfisher 加载和缓存远程图片。 +struct RemoteImage: View { + let urlString: String? + let contentMode: SwiftUI.ContentMode + @ViewBuilder let placeholder: () -> Placeholder + + @State private var didFail = false + + /// 初始化网络图片组件。 + init( + urlString: String?, + contentMode: SwiftUI.ContentMode = .fill, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.urlString = urlString + self.contentMode = contentMode + self.placeholder = placeholder + } + + var body: some View { + Group { + if let url = imageURL, !didFail { + KFImage(url) + .placeholder { placeholder() } + .onFailure { _ in didFail = true } + .resizable() + .cacheOriginalImage() + .aspectRatio(contentMode: contentMode) + } else { + placeholder() + } + } + .onChange(of: normalizedURLString) { _, _ in + didFail = false + } + } + + private var imageURL: URL? { + guard let normalizedURLString else { return nil } + return URL(string: normalizedURLString) + } + + private var normalizedURLString: String? { + let text = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return text.isEmpty ? nil : text + } +} + +/// 远程头像组件,提供统一的圆形头像占位和 Kingfisher 加载逻辑。 +struct RemoteAvatarImage: View { + let urlString: String + let systemImageName: String + let iconSize: CGFloat + + /// 初始化远程头像组件。 + init(urlString: String, systemImageName: String = "person.fill", iconSize: CGFloat = 44) { + self.urlString = urlString + self.systemImageName = systemImageName + self.iconSize = iconSize + } + + var body: some View { + RemoteImage(urlString: urlString, contentMode: .fill) { + Image(systemName: systemImageName) + .font(.system(size: iconSize, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppDesign.primarySoft) + } + } +} diff --git a/suixinkan/Core/Upload/OSSUploadService.swift b/suixinkan/Core/Upload/OSSUploadService.swift new file mode 100644 index 0000000..573d41c --- /dev/null +++ b/suixinkan/Core/Upload/OSSUploadService.swift @@ -0,0 +1,245 @@ +// +// OSSUploadService.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation +import Observation +import UniformTypeIdentifiers + +#if canImport(AlibabaCloudOSS) +import AlibabaCloudOSS +#endif + +/// OSS 上传服务协议,定义业务模块可复用的上传入口。 +@MainActor +protocol OSSUploadServing { + /// 上传用户头像,并返回可访问的 OSS 文件 URL。 + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + + /// 上传实名认证证件图片,并返回可访问的 OSS 文件 URL。 + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + + /// 上传云盘文件,并返回可访问的 OSS 文件 URL。 + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + + /// 上传相册文件,并返回可访问的 OSS 文件 URL。 + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + + /// 上传任务附件,并返回可访问的 OSS 文件 URL。 + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + + /// 上传打卡点图片,并返回可访问的 OSS 文件 URL。 + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + + /// 上传景区申请图片,并返回可访问的 OSS 文件 URL。 + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String +} + +@MainActor +@Observable +/// OSS 上传服务,负责获取 STS 配置、校验文件、调用阿里云 SDK 并返回最终 URL。 +final class OSSUploadService { + @ObservationIgnored private let configService: any OSSConfigServing + + /// 初始化 OSS 上传服务,并注入 STS 配置服务。 + init(configService: any OSSConfigServing) { + self.configService = configService + } + + /// 上传用户头像,并返回可访问的 OSS 文件 URL。 + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "user_avatar", onProgress: onProgress) + } + + /// 上传实名认证证件图片,并返回可访问的 OSS 文件 URL。 + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "real_name", onProgress: onProgress) + } + + /// 上传云盘文件,并返回可访问的 OSS 文件 URL。 + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "cloud_driver", onProgress: onProgress) + } + + /// 上传相册文件,并返回可访问的 OSS 文件 URL。 + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "album_upload", onProgress: onProgress) + } + + /// 上传任务附件,并返回可访问的 OSS 文件 URL。 + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "task_upload", onProgress: onProgress) + } + + /// 上传打卡点图片,并返回可访问的 OSS 文件 URL。 + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "punch_point", onProgress: onProgress) + } + + /// 上传景区申请图片,并返回可访问的 OSS 文件 URL。 + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "scenic_apply", onProgress: onProgress) + } + + /// 执行通用 OSS 上传流程。 + private func uploadFile( + data: Data, + fileName: String, + fileType: Int, + scenicId: Int, + moduleType: String, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + onProgress(1) + try OSSUploadPolicy.validate(dataSize: data.count, fileName: fileName) + + let config = try await configService.aliyunOSSBucket(bucket: "vipsky") + let objectKey = OSSUploadPolicy.objectKey(fileName: fileName, scenicId: scenicId, moduleType: moduleType) + onProgress(12) + + #if canImport(AlibabaCloudOSS) + let credentialsProvider = StaticCredentialsProvider( + accessKeyId: config.credentials.accessKeyId, + accessKeySecret: config.credentials.accessKeySecret, + securityToken: config.credentials.securityToken + ) + let clientConfig = Configuration.default() + .withRegion(config.region) + .withCredentialsProvider(credentialsProvider) + if !config.endpoint.isEmpty { + clientConfig.withEndpoint(config.endpoint) + } + onProgress(25) + let client = Client(clientConfig) + _ = try await client.putObject( + PutObjectRequest( + bucket: config.bucket, + key: objectKey, + contentType: OSSUploadPolicy.contentType(for: fileName, fileType: fileType), + body: .data(data), + progress: ProgressClosure { _, transferred, expected in + guard expected > 0 else { return } + let uploadProgress = Double(transferred) / Double(expected) + let progress = max(26, min(99, 25 + Int(uploadProgress * 74))) + onProgress(progress) + } + ) + ) + onProgress(100) + return OSSUploadPolicy.joinURL(baseURL: config.baseUrl, objectKey: objectKey) + #else + throw OSSUploadError.sdkUnavailable + #endif + } +} + +extension OSSUploadService: OSSUploadServing {} + +/// OSS 上传策略实体,集中管理文件限制、路径生成、MIME 类型和 URL 拼接规则。 +enum OSSUploadPolicy { + static let maxFileSize = 2_048 * 1_024 * 1_024 + private static let allowedExtensions: Set = ["mp4", "mov", "m4v", "avi", "png", "jpg", "jpeg", "heic", "heif"] + + /// 校验待上传文件大小和扩展名。 + static func validate(dataSize: Int, fileName: String) throws { + guard dataSize > 0 else { + throw OSSUploadError.emptyFile + } + guard dataSize <= maxFileSize else { + throw OSSUploadError.fileTooLarge + } + let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased() + guard allowedExtensions.contains(ext) else { + throw OSSUploadError.unsupportedFileType + } + } + + /// 根据业务模块、景区和文件名生成 OSS objectKey。 + static func objectKey( + fileName: String, + scenicId: Int, + moduleType: String, + date: Date = Date(), + uuid: UUID = UUID(), + timeZone: TimeZone = .current + ) -> String { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.timeZone = timeZone + formatter.dateFormat = "yyyyMMdd" + let currentDate = formatter.string(from: date) + let uploadId = uuid.uuidString.replacingOccurrences(of: "-", with: "") + let safeName = sanitizedFileName(fileName) + switch moduleType { + case "task_upload": + return "task_upload/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "cloud_driver": + return "cloud_driver/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "album_upload": + return "album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "punch_point": + return "punch_point/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "scenic_apply": + return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "user_avatar": + return "avatar/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "real_name": + return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + default: + return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + } + } + + /// 根据文件名扩展名和业务文件类型推断 MIME 类型。 + static func contentType(for fileName: String, fileType: Int) -> String { + let ext = URL(fileURLWithPath: fileName).pathExtension + if let type = UTType(filenameExtension: ext)?.preferredMIMEType { + return type + } + return fileType == 1 ? "video/mp4" : "image/jpeg" + } + + /// 拼接 OSS 访问域名和 objectKey。 + static func joinURL(baseURL: String, objectKey: String) -> String { + if baseURL.hasSuffix("/") { + return baseURL + objectKey + } + return baseURL + "/" + objectKey + } + + /// 清理文件名中的危险字符,避免生成非法 objectKey。 + private static func sanitizedFileName(_ fileName: String) -> String { + let trimmedName = fileName.trimmingCharacters(in: .whitespacesAndNewlines) + var unsafeCharacters = CharacterSet.controlCharacters + unsafeCharacters.formUnion(CharacterSet(charactersIn: "/\\")) + + let safeName = trimmedName.unicodeScalars.reduce(into: "") { result, scalar in + result += unsafeCharacters.contains(scalar) ? "_" : String(scalar) + } + return safeName.isEmpty ? "upload" : safeName + } +} + +/// OSS 上传错误实体,表示本地校验或 SDK 链接状态异常。 +enum OSSUploadError: LocalizedError, Equatable { + case sdkUnavailable + case emptyFile + case fileTooLarge + case unsupportedFileType + + var errorDescription: String? { + switch self { + case .sdkUnavailable: + "阿里云 OSS Swift SDK 尚未链接到当前 iOS target" + case .emptyFile: + "文件内容不能为空" + case .fileTooLarge: + "文件大小不能超过2048MB" + case .unsupportedFileType: + "仅支持.mp4,.mov,.m4v,.avi,.png,.jpg,.jpeg,.heic,.heif格式" + } + } +} diff --git a/suixinkan/Core/Upload/Upload.md b/suixinkan/Core/Upload/Upload.md new file mode 100644 index 0000000..0a7f987 --- /dev/null +++ b/suixinkan/Core/Upload/Upload.md @@ -0,0 +1,51 @@ +# Upload 模块业务逻辑 + +## 模块职责 + +Upload 模块负责 App 内通用文件上传能力,当前主要服务个人头像和实名认证证件图片,后续云盘、相册、任务、打卡点等模块迁移时复用同一套 OSS 上传入口。 + +该模块不负责业务表单提交,只负责: +- 获取阿里云 OSS STS 临时配置。 +- 校验待上传文件大小和扩展名。 +- 生成按业务模块隔离的 OSS objectKey。 +- 调用阿里云 OSS Swift SDK 上传文件。 +- 返回最终可访问的文件 URL。 + +## 核心对象 + +- `UploadAPI`:封装 `/api/app/config/get-sts-token`,只负责获取 STS 临时上传配置。 +- `OSSUploadService`:统一上传服务,封装 SDK 调用和进度回调。 +- `OSSUploadPolicy`:上传策略,管理大小限制、扩展名白名单、路径规则、MIME 类型和 URL 拼接。 +- `AvatarImageProcessor`:头像图片处理器,上传前把图片压缩为 JPEG。 +- `RealNameImageProcessor`:实名认证证件图片处理器,上传前把证件图压缩为 JPEG。 +- `RemoteImage`:Kingfisher 网络图片组件,统一远程图片加载、缓存和失败占位。 + +## 上传流程 + +1. 页面或 ViewModel 将用户选择的本地图片处理成上传数据。 +2. ViewModel 调用 `OSSUploadService` 的业务上传方法。 +3. `OSSUploadService` 调用 `UploadAPI.aliyunOSSBucket(bucket:)` 获取 STS 配置。 +4. `OSSUploadPolicy` 校验文件并生成 objectKey。 +5. `OSSUploadService` 使用 `AlibabaCloudOSS` SDK 上传数据。 +6. 上传成功后返回 `base_url + objectKey`。 +7. 业务 ViewModel 再把 URL 提交给对应业务接口。 + +## 路径规则 + +当前模块路径: +- `user_avatar`:`avatar/yyyyMMdd/scenicId/uuid_fileName` +- `real_name`:`real_name/yyyyMMdd/scenicId/uuid_fileName` +- `task_upload`:`task_upload/yyyyMMdd/scenicId/uuid_fileName` +- `cloud_driver`:`cloud_driver/yyyyMMdd/scenicId/uuid_fileName` +- `album_upload`:`album/yyyyMMdd/scenicId/uuid_fileName` +- `punch_point`:`punch_point/yyyyMMdd/scenicId/uuid_fileName` +- `scenic_apply`:`scenic_apply/yyyyMMdd/scenicId/uuid_fileName` + +文件名会清理控制字符、`/` 和 `\`,避免生成非法 objectKey。 + +## 缓存边界 + +- OSS STS token 不落盘,只在一次上传流程中临时使用。 +- 原始图片 Data、压缩后图片 Data、上传进度不落盘。 +- 图片展示缓存交给 Kingfisher,业务代码不自行保存网络图片文件。 +- 正式登录 token 仍由 `SessionTokenStore` 使用 Keychain 保存,上传模块不直接读取或保存登录态。 diff --git a/suixinkan/Core/Upload/UploadAPI.swift b/suixinkan/Core/Upload/UploadAPI.swift new file mode 100644 index 0000000..1209938 --- /dev/null +++ b/suixinkan/Core/Upload/UploadAPI.swift @@ -0,0 +1,41 @@ +// +// UploadAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation +import Observation + +/// OSS 配置服务协议,抽象 STS token 获取能力,便于上传服务测试替换。 +@MainActor +protocol OSSConfigServing { + /// 获取指定 bucket 的阿里云 OSS 临时上传配置。 + func aliyunOSSBucket(bucket: String) async throws -> AliyunOSSResponse +} + +@MainActor +@Observable +/// 上传 API,封装文件上传前需要的服务端配置接口。 +final class UploadAPI { + @ObservationIgnored private let client: APIClient + + /// 初始化上传 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取指定 bucket 的阿里云 OSS 临时上传配置。 + func aliyunOSSBucket(bucket: String = "vipsky") async throws -> AliyunOSSResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/config/get-sts-token", + queryItems: [URLQueryItem(name: "bucket", value: bucket)] + ) + ) + } +} + +extension UploadAPI: OSSConfigServing {} diff --git a/suixinkan/Core/Upload/UploadImageProcessors.swift b/suixinkan/Core/Upload/UploadImageProcessors.swift new file mode 100644 index 0000000..cc0f6e2 --- /dev/null +++ b/suixinkan/Core/Upload/UploadImageProcessors.swift @@ -0,0 +1,121 @@ +// +// UploadImageProcessors.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation +import UIKit +import UniformTypeIdentifiers + +/// 头像图片处理器,负责压缩和规范化用户选择的头像图片。 +enum AvatarImageProcessor { + static let maxPixelLength: CGFloat = 1024 + static let jpegCompressionQuality: CGFloat = 0.82 + + /// 处理后的上传图片实体,包含数据、文件名和内容类型。 + struct ProcessedImage: Equatable { + let data: Data + let fileName: String + let contentType: UTType + } + + /// 将原始图片数据转换为适合头像上传的 JPEG 数据。 + static func process(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws -> ProcessedImage { + try UploadImageRenderer.process( + data: data, + fileNamePrefix: "avatar", + maxPixelLength: maxPixelLength, + jpegCompressionQuality: jpegCompressionQuality, + timestamp: timestamp + ) + } +} + +/// 实名证件图片处理器,负责压缩和规范化身份证正反面图片。 +enum RealNameImageProcessor { + static let maxPixelLength: CGFloat = 1800 + static let jpegCompressionQuality: CGFloat = 0.88 + + /// 将原始图片数据转换为适合实名认证上传的 JPEG 数据。 + static func process(data: Data, side: RealNameImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws -> AvatarImageProcessor.ProcessedImage { + try UploadImageRenderer.process( + data: data, + fileNamePrefix: "real_name_\(side.rawValue)", + maxPixelLength: maxPixelLength, + jpegCompressionQuality: jpegCompressionQuality, + timestamp: timestamp + ) + } +} + +/// 实名认证证件图片面向实体,区分人像面和国徽面。 +enum RealNameImageSide: String { + case front + case back +} + +/// 上传图片处理错误实体,表示图片无效或编码失败。 +enum UploadImageProcessingError: LocalizedError, Equatable { + case invalidImage + case encodingFailed + + var errorDescription: String? { + switch self { + case .invalidImage: + "请选择有效的图片" + case .encodingFailed: + "图片处理失败,请重新选择" + } + } +} + +/// 上传图片渲染器,提供通用图片缩放、铺底和 JPEG 编码能力。 +private enum UploadImageRenderer { + /// 将原始图片渲染为指定大小限制的 JPEG 上传数据。 + static func process( + data: Data, + fileNamePrefix: String, + maxPixelLength: CGFloat, + jpegCompressionQuality: CGFloat, + timestamp: TimeInterval + ) throws -> AvatarImageProcessor.ProcessedImage { + guard let image = UIImage(data: data), image.size.width > 0, image.size.height > 0 else { + throw UploadImageProcessingError.invalidImage + } + + let normalized = image.normalizedForUpload(maxPixelLength: maxPixelLength) + guard let jpegData = normalized.jpegData(compressionQuality: jpegCompressionQuality), !jpegData.isEmpty else { + throw UploadImageProcessingError.encodingFailed + } + + return AvatarImageProcessor.ProcessedImage( + data: jpegData, + fileName: "\(fileNamePrefix)_\(Int(timestamp)).jpg", + contentType: .jpeg + ) + } +} + +private extension UIImage { + /// 按最长边限制缩放图片,并用白色背景消除透明通道。 + func normalizedForUpload(maxPixelLength: CGFloat) -> UIImage { + let longestSide = max(size.width, size.height) + let scale = min(1, maxPixelLength / longestSide) + let targetSize = CGSize( + width: max(1, (size.width * scale).rounded()), + height: max(1, (size.height * scale).rounded()) + ) + + let format = UIGraphicsImageRendererFormat.default() + format.scale = 1 + format.opaque = true + let renderer = UIGraphicsImageRenderer(size: targetSize, format: format) + return renderer.image { context in + UIColor.white.setFill() + context.fill(CGRect(origin: .zero, size: targetSize)) + draw(in: CGRect(origin: .zero, size: targetSize)) + } + } +} diff --git a/suixinkan/Core/Upload/UploadModels.swift b/suixinkan/Core/Upload/UploadModels.swift new file mode 100644 index 0000000..cf0b49a --- /dev/null +++ b/suixinkan/Core/Upload/UploadModels.swift @@ -0,0 +1,100 @@ +// +// UploadModels.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 阿里云 OSS STS 配置响应实体,表示一次临时上传授权。 +struct AliyunOSSResponse: Decodable, Equatable { + let baseUrl: String + let endpoint: String + let region: String + let bucket: String + let expireSeconds: Int + let credentials: AliyunOSSCredentials + + /// 阿里云 OSS STS 配置响应 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case baseUrl = "base_url" + case endpoint + case region + case bucket + case expireSeconds = "expire_seconds" + case credentials + } + + /// 自定义解码,兼容后端字段类型不稳定的情况。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + baseUrl = try container.decodeLossyString(forKey: .baseUrl) + endpoint = try container.decodeLossyString(forKey: .endpoint) + region = try container.decodeLossyString(forKey: .region) + bucket = try container.decodeLossyString(forKey: .bucket) + expireSeconds = try container.decodeLossyInt(forKey: .expireSeconds) ?? 0 + credentials = try container.decode(AliyunOSSCredentials.self, forKey: .credentials) + } +} + +/// 阿里云 OSS 临时凭证实体,表示 SDK 上传所需的访问密钥和安全 token。 +struct AliyunOSSCredentials: Decodable, Equatable { + let accessKeyId: String + let accessKeySecret: String + let securityToken: String + + /// 阿里云 OSS 临时凭证 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case accessKeyId = "access_key_id" + case accessKeySecret = "access_key_secret" + case securityToken = "security_token" + } + + /// 自定义解码,兼容后端字段类型不稳定的情况。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + accessKeyId = try container.decodeLossyString(forKey: .accessKeyId) + accessKeySecret = try container.decodeLossyString(forKey: .accessKeySecret) + securityToken = try container.decodeLossyString(forKey: .securityToken) + } +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + func decodeLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? "true" : "false" + } + return "" + } + + /// 将 Int、Double 或数字字符串宽松解码为整数。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { + return intValue + } + if let doubleValue = Double(text) { + return Int(doubleValue) + } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + return nil + } +} diff --git a/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift b/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift index 33ddf07..4660562 100644 --- a/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift +++ b/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift @@ -144,18 +144,7 @@ struct HomeMoreFunctionsView: View { @ViewBuilder private func menuIconView(for item: HomeMenuItem) -> some View { - if let src = item.iconSrc, - let url = URL(string: src), - !src.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - AsyncImage(url: url) { phase in - switch phase { - case .success(let image): - image.resizable().scaledToFit() - default: - fallbackMenuIcon(for: item.uri) - } - } - } else { + RemoteImage(urlString: item.iconSrc, contentMode: .fit) { fallbackMenuIcon(for: item.uri) } } diff --git a/suixinkan/Features/Home/Views/HomeView.swift b/suixinkan/Features/Home/Views/HomeView.swift index e17de54..ba2a5d9 100644 --- a/suixinkan/Features/Home/Views/HomeView.swift +++ b/suixinkan/Features/Home/Views/HomeView.swift @@ -363,20 +363,7 @@ struct HomeView: View { @ViewBuilder private func menuIconView(for item: HomeMenuItem) -> some View { - if let src = item.iconSrc, - let url = URL(string: src), - !src.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - AsyncImage(url: url) { phase in - switch phase { - case .success(let image): - image - .resizable() - .scaledToFit() - default: - fallbackMenuIcon(for: item.uri) - } - } - } else { + RemoteImage(urlString: item.iconSrc, contentMode: .fit) { fallbackMenuIcon(for: item.uri) } } diff --git a/suixinkan/Features/Profile/Profile.md b/suixinkan/Features/Profile/Profile.md index 9a362ad..73e5a2c 100644 --- a/suixinkan/Features/Profile/Profile.md +++ b/suixinkan/Features/Profile/Profile.md @@ -8,7 +8,7 @@ Profile 模块负责“我的/个人信息”页面及其二级页面,包括 - 拉取用户基础资料。 - 拉取实名认证信息。 - 展示头像、昵称、UID、手机号、账号状态和实名认证状态。 -- 修改昵称。 +- 修改昵称和头像。 - 修改密码。 - 切换当前景区/门店业务账号。 - 提交实名认证基础资料。 @@ -26,6 +26,7 @@ Profile 模块负责“我的/个人信息”页面及其二级页面,包括 - `RealNameAuthView` / `RealNameAuthViewModel`:实名认证页面和表单逻辑。 - `SettingsCenterView` / `AgreementView`:设置中心和协议 H5 页面。 - `ProfileAPI`:封装用户资料、实名认证和资料更新接口。 +- `OSSUploadService`:上传头像和实名认证证件图片,返回可提交给业务接口的 OSS URL。 - `UserInfoResponse`:用户基础资料。 - `RealNameInfoResponse` / `RealNameInfo`:实名认证信息。 - `UpdateInfoRequest`:昵称、密码或头像更新请求体。 @@ -42,15 +43,18 @@ Profile 模块负责“我的/个人信息”页面及其二级页面,包括 5. `ProfileView` 将最新 `userInfo` 交给 `AuthSessionCoordinator.refreshCachedProfile`。 6. 协调器更新 `AccountContext.profile`,并同步刷新账号快照。 -## 昵称编辑流程 +## 资料编辑流程 1. 用户点击编辑按钮。 2. `ProfileViewModel.beginEditing` 进入编辑状态,并把当前昵称写入编辑框。 -3. 用户提交后,`ProfileViewModel.saveProfile` 校验昵称非空。 -4. 昵称未变化时直接退出编辑状态。 -5. 昵称变化时调用 `ProfileAPI.updateUserInfo(nickname:)`。 -6. 接口成功后本地更新 `userInfo.nickname`。 -7. `ProfileView` 刷新全局账号资料和账号快照。 +3. 用户点击头像时通过 `PhotosPicker` 选择本地图片。 +4. `AvatarImageProcessor` 将头像压缩为 JPEG,并暂存在内存中。 +5. 用户提交后,`ProfileViewModel.saveProfile` 校验昵称非空。 +6. 头像变化时先调用 `OSSUploadService.uploadUserAvatar` 上传到 OSS。 +7. 头像上传成功后调用 `ProfileAPI.updateUserAvatarURL` 回写头像 URL。 +8. 昵称变化时调用 `ProfileAPI.updateUserInfo(nickname:)`。 +9. 接口成功后本地更新 `userInfo.nickname` 和 `userInfo.avatar`。 +10. `ProfileView` 刷新全局账号资料和账号快照。 ## 密码修改流程 @@ -76,11 +80,15 @@ Profile 模块负责“我的/个人信息”页面及其二级页面,包括 1. 用户点击“认证状态”进入 `RealNameAuthView`。 2. `RealNameAuthViewModel.load` 调用 `ProfileAPI.realNameInfo` 并回填姓名、身份证号、证件有效期、图片 URL 和审核状态。 3. 用户可发送短信验证码,接口为 `/api/yf-handset-app/photog/real-name/sms-verify-code`。 -4. 提交前校验姓名、身份证号、短信验证码、证件图片 URL 和有效期。 -5. 校验通过后调用 `/api/yf-handset-app/photog/real-name/submit`。 -6. 提交成功后重新加载实名认证信息。 +4. 用户通过图片卡片选择身份证人像面和国徽面。 +5. `RealNameImageProcessor` 将证件图片压缩为 JPEG,并暂存在内存中。 +6. 提交前校验姓名、身份证号、短信验证码、证件图片和有效期。 +7. 校验通过后先调用 `OSSUploadService.uploadRealNameImage` 上传证件图片。 +8. 上传成功后把 OSS URL 写入 `RealNameAuthRequest.frontUrl/backUrl`。 +9. 调用 `/api/yf-handset-app/photog/real-name/submit` 提交实名资料。 +10. 提交成功后重新加载实名认证信息。 -本轮证件图片先保留 URL 输入和网络预览,阿里云 OSS 图片选择上传后续接入时替换该输入区。 +证件图片的原始 Data、压缩 Data 和上传进度只保存在内存中,不写入本地缓存。 ## 设置和协议流程 diff --git a/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift b/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift index a8e4e2a..6dd9749 100644 --- a/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift +++ b/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift @@ -18,6 +18,9 @@ final class ProfileViewModel { var isSaving = false var isEditingProfile = false var editingNickname = "" + private(set) var pendingAvatarData: Data? + private(set) var pendingAvatarFileName: String? + private(set) var avatarUploadProgress: Int? var displayNickname: String { nonEmpty(userInfo?.nickname) ?? "未设置昵称" @@ -75,29 +78,51 @@ final class ProfileViewModel { func cancelEditing() { editingNickname = "" isEditingProfile = false + clearPendingAvatar() } - /// 保存昵称修改,并同步更新本地 userInfo。 - func saveProfile(api: ProfileAPI) async throws { + /// 准备待上传头像,并进入资料编辑状态。 + func prepareAvatarImage(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws { + let processed = try AvatarImageProcessor.process(data: data, timestamp: timestamp) + pendingAvatarData = processed.data + pendingAvatarFileName = processed.fileName + if !isEditingProfile { + beginEditing() + } + } + + /// 保存昵称和头像修改,并同步更新本地 userInfo。 + func saveProfile(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws { let nextNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines) guard !nextNickname.isEmpty else { throw ProfileValidationError.emptyNickname } let nicknameChanged = nextNickname != displayNickname - guard nicknameChanged else { + let avatarChanged = pendingAvatarData != nil + guard nicknameChanged || avatarChanged else { cancelEditing() return } guard !isSaving else { return } isSaving = true - defer { isSaving = false } + avatarUploadProgress = avatarChanged ? 1 : nil + defer { + isSaving = false + avatarUploadProgress = nil + } - try await api.updateUserInfo(nickname: nextNickname) + let uploadedAvatarURL = try await uploadPendingAvatarIfNeeded(uploader: uploader, scenicId: scenicId) + if let uploadedAvatarURL { + try await api.updateUserAvatarURL(uploadedAvatarURL) + } + if nicknameChanged { + try await api.updateUserInfo(nickname: nextNickname) + } var nextUser = userInfo ?? UserInfoResponse() nextUser = UserInfoResponse( - avatar: nextUser.avatar, + avatar: uploadedAvatarURL ?? nextUser.avatar, realName: nextUser.realName, phone: nextUser.phone, nickname: nextNickname, @@ -106,6 +131,7 @@ final class ProfileViewModel { statusName: nextUser.statusName ) userInfo = nextUser + clearPendingAvatar() cancelEditing() } @@ -139,6 +165,24 @@ final class ProfileViewModel { let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return text.isEmpty ? nil : text } + + /// 上传待保存头像;没有待上传图片时返回 nil。 + private func uploadPendingAvatarIfNeeded(uploader: any OSSUploadServing, scenicId: Int) async throws -> String? { + guard let pendingAvatarData else { return nil } + let fileName = pendingAvatarFileName ?? "avatar_\(Int(Date().timeIntervalSince1970)).jpg" + return try await uploader.uploadUserAvatar(data: pendingAvatarData, fileName: fileName, scenicId: scenicId) { [weak self] progress in + Task { @MainActor in + self?.avatarUploadProgress = progress + } + } + } + + /// 清空本地待上传头像数据。 + private func clearPendingAvatar() { + pendingAvatarData = nil + pendingAvatarFileName = nil + avatarUploadProgress = nil + } } /// 个人信息页校验错误实体,表示昵称和密码输入不符合要求。 diff --git a/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift b/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift index 6802c1c..feab7d6 100644 --- a/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift +++ b/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift @@ -21,6 +21,12 @@ final class RealNameAuthViewModel { var isLongValid = false var frontUrl = "" var backUrl = "" + private(set) var pendingFrontImageData: Data? + private(set) var pendingBackImageData: Data? + private(set) var frontUploadProgress: Int? + private(set) var backUploadProgress: Int? + private var pendingFrontFileName: String? + private var pendingBackFileName: String? private(set) var loading = false private(set) var sendingCode = false private(set) var submitting = false @@ -46,8 +52,23 @@ final class RealNameAuthViewModel { statusMessage = "验证码已发送" } + /// 准备待上传证件图片,并更新对应面的本地预览。 + func prepareIdentityImage(data: Data, side: RealNameImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws { + let processed = try RealNameImageProcessor.process(data: data, side: side, timestamp: timestamp) + switch side { + case .front: + pendingFrontImageData = processed.data + pendingFrontFileName = processed.fileName + frontUrl = "" + case .back: + pendingBackImageData = processed.data + pendingBackFileName = processed.fileName + backUrl = "" + } + } + /// 提交实名认证资料,成功后刷新审核状态。 - func submit(api: ProfileAPI) async throws { + func submit(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws { if info?.verified == true { statusMessage = "已完成实名认证" return @@ -58,6 +79,7 @@ final class RealNameAuthViewModel { submitting = true defer { submitting = false } + try await uploadPendingIdentityImages(uploader: uploader, scenicId: scenicId) try await api.realNameSubmit(makeRequest()) statusMessage = "已提交审核" try await load(api: api) @@ -80,8 +102,8 @@ final class RealNameAuthViewModel { if !Self.isValidMainlandIDCardNumber(idCardNo) { return "请输入有效的身份证号" } if info?.verified != true { if smsCode.trimmed.isEmpty { return "请输入短信验证码" } - if frontUrl.trimmed.isEmpty { return "请填写身份证人像面图片 URL" } - if backUrl.trimmed.isEmpty { return "请填写身份证国徽面图片 URL" } + if frontUrl.trimmed.isEmpty && pendingFrontImageData == nil { return "请选择身份证人像面图片" } + if backUrl.trimmed.isEmpty && pendingBackImageData == nil { return "请选择身份证国徽面图片" } } if !isLongValid && endDate < startDate { return "证件结束日期不能早于起始日期" } return nil @@ -95,6 +117,10 @@ final class RealNameAuthViewModel { idCardNo = info.idCardNo frontUrl = info.frontUrl ?? "" backUrl = info.backUrl ?? "" + pendingFrontImageData = nil + pendingBackImageData = nil + pendingFrontFileName = nil + pendingBackFileName = nil isLongValid = info.isLongValid startDate = Self.date(from: info.startDate) ?? startDate endDate = Self.date(from: info.endDate) ?? endDate @@ -148,6 +174,41 @@ final class RealNameAuthViewModel { guard let value, !value.isEmpty else { return nil } return dateFormatter.date(from: value) } + + /// 上传待提交证件图片,并把返回 URL 写回表单字段。 + private func uploadPendingIdentityImages(uploader: any OSSUploadServing, scenicId: Int) async throws { + if let pendingFrontImageData { + frontUploadProgress = 1 + defer { frontUploadProgress = nil } + frontUrl = try await uploader.uploadRealNameImage( + data: pendingFrontImageData, + fileName: pendingFrontFileName ?? "real_name_front_\(Int(Date().timeIntervalSince1970)).jpg", + scenicId: scenicId + ) { [weak self] progress in + Task { @MainActor in + self?.frontUploadProgress = progress + } + } + self.pendingFrontImageData = nil + pendingFrontFileName = nil + } + + if let pendingBackImageData { + backUploadProgress = 1 + defer { backUploadProgress = nil } + backUrl = try await uploader.uploadRealNameImage( + data: pendingBackImageData, + fileName: pendingBackFileName ?? "real_name_back_\(Int(Date().timeIntervalSince1970)).jpg", + scenicId: scenicId + ) { [weak self] progress in + Task { @MainActor in + self?.backUploadProgress = progress + } + } + self.pendingBackImageData = nil + pendingBackFileName = nil + } + } } /// 实名认证校验错误实体,用于把表单错误统一抛给页面展示。 diff --git a/suixinkan/Features/Profile/Views/AccountSwitchView.swift b/suixinkan/Features/Profile/Views/AccountSwitchView.swift index fd7d722..97a3f8f 100644 --- a/suixinkan/Features/Profile/Views/AccountSwitchView.swift +++ b/suixinkan/Features/Profile/Views/AccountSwitchView.swift @@ -156,21 +156,11 @@ struct AccountSwitchView: View { @ViewBuilder /// 构造账号头像,网络图加载失败时显示账号类型首字。 private func accountAvatar(_ account: AccountSwitchAccount) -> some View { - if let url = URL(string: account.avatar), !account.avatar.isEmpty { - AsyncImage(url: url) { phase in - switch phase { - case .success(let image): - image.resizable().scaledToFill() - default: - avatarFallback(account) - } - } - .frame(width: 50, height: 50) - .clipShape(Circle()) - } else { + RemoteImage(urlString: account.avatar) { avatarFallback(account) - .frame(width: 50, height: 50) } + .frame(width: 50, height: 50) + .clipShape(Circle()) } /// 构造账号头像占位。 diff --git a/suixinkan/Features/Profile/Views/ProfileView.swift b/suixinkan/Features/Profile/Views/ProfileView.swift index 6e4c695..f1e51a0 100644 --- a/suixinkan/Features/Profile/Views/ProfileView.swift +++ b/suixinkan/Features/Profile/Views/ProfileView.swift @@ -5,7 +5,9 @@ // Created by Codex on 2026/6/20. // +import PhotosUI import SwiftUI +import UIKit /// 个人信息页视图,展示用户资料、账号状态、实名认证状态和退出登录入口。 struct ProfileView: View { @@ -16,6 +18,7 @@ struct ProfileView: View { @Environment(AppRouter.self) private var appRouter @Environment(RouterPath.self) private var router @Environment(ProfileAPI.self) private var profileAPI + @Environment(OSSUploadService.self) private var ossUploadService @Environment(ToastCenter.self) private var toastCenter @Environment(AuthSessionCoordinator.self) private var authSessionCoordinator @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -23,6 +26,7 @@ struct ProfileView: View { @State private var viewModel = ProfileViewModel() @State private var showsPasswordSheet = false @State private var showsLogoutConfirm = false + @State private var pickedAvatarItem: PhotosPickerItem? @FocusState private var nicknameFocused: Bool private var contentMaxWidth: CGFloat { @@ -83,6 +87,9 @@ struct ProfileView: View { nicknameFocused = true } } + .onChange(of: pickedAvatarItem) { _, item in + Task { await prepareAvatarImage(from: item) } + } } private var profileBackground: some View { @@ -201,10 +208,8 @@ struct ProfileView: View { } private var avatarImage: some View { - Button { - toastCenter.show("头像上传待接入阿里云 OSS") - } label: { - AsyncAvatarImage(urlString: viewModel.displayAvatarURL) + PhotosPicker(selection: $pickedAvatarItem, matching: .images) { + avatarPreview .frame(width: 86, height: 86) .clipShape(Circle()) .overlay { @@ -221,11 +226,34 @@ struct ProfileView: View { Circle().stroke(.white, lineWidth: 2) } } + .overlay { + if let progress = viewModel.avatarUploadProgress { + ZStack { + Circle() + .fill(.black.opacity(0.34)) + Text("\(progress)%") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + } + } + } } .buttonStyle(.plain) + .disabled(viewModel.isSaving) .accessibilityLabel("头像") } + @ViewBuilder + private var avatarPreview: some View { + if let data = viewModel.pendingAvatarData, let image = UIImage(data: data) { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + RemoteAvatarImage(urlString: viewModel.displayAvatarURL) + } + } + private var infoCard: some View { VStack(spacing: 0) { infoRow(title: "姓名") { @@ -503,7 +531,11 @@ struct ProfileView: View { /// 保存昵称编辑内容,并在成功后刷新全局账号展示。 private func saveProfileEdits() async { do { - try await viewModel.saveProfile(api: profileAPI) + try await viewModel.saveProfile( + api: profileAPI, + uploader: ossUploadService, + scenicId: accountContext.currentScenic?.id ?? 0 + ) if let userInfo = viewModel.userInfo { authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext) } else { @@ -515,6 +547,21 @@ struct ProfileView: View { } } + /// 处理用户选择的头像图片,并生成待上传数据。 + private func prepareAvatarImage(from item: PhotosPickerItem?) async { + guard let item else { return } + defer { pickedAvatarItem = nil } + do { + guard let data = try await item.loadTransferable(type: Data.self) else { + toastCenter.show("请选择有效的头像图片") + return + } + try viewModel.prepareAvatarImage(data: data) + } catch { + toastCenter.show(error.localizedDescription) + } + } + /// 提交新密码,并在成功后关闭密码弹窗。 private func updatePassword(_ password: String) async { do { @@ -533,38 +580,6 @@ struct ProfileView: View { } } -/// 异步头像视图,负责加载网络头像并在失败或空地址时展示占位图。 -private struct AsyncAvatarImage: View { - let urlString: String - - var body: some View { - if let url = URL(string: urlString), !urlString.isEmpty { - AsyncImage(url: url) { phase in - switch phase { - case let .success(image): - image - .resizable() - .scaledToFill() - case .failure, .empty: - placeholder - @unknown default: - placeholder - } - } - } else { - placeholder - } - } - - private var placeholder: some View { - Image(systemName: "person.fill") - .font(.system(size: 44, weight: .semibold)) - .foregroundStyle(AppDesign.primary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(AppDesign.primarySoft) - } -} - /// 个人信息头部背景波浪形状,用于还原旧版蓝白卡片视觉。 private struct ProfileHeaderWaveShape: Shape { var phase: CGFloat = 0 @@ -643,6 +658,7 @@ private struct PasswordUpdateSheet: View { .environment(AppRouter()) .environment(ToastCenter()) .environment(ProfileAPI(client: APIClient())) + .environment(OSSUploadService(configService: UploadAPI(client: APIClient()))) .environment(AuthSessionCoordinator()) } } diff --git a/suixinkan/Features/Profile/Views/RealNameAuthView.swift b/suixinkan/Features/Profile/Views/RealNameAuthView.swift index 0a6bfaa..ca79638 100644 --- a/suixinkan/Features/Profile/Views/RealNameAuthView.swift +++ b/suixinkan/Features/Profile/Views/RealNameAuthView.swift @@ -5,13 +5,19 @@ // Created by Codex on 2026/6/22. // +import PhotosUI import SwiftUI +import UIKit /// 实名认证页面,展示审核状态并允许提交基础实名资料。 struct RealNameAuthView: View { + @Environment(AccountContext.self) private var accountContext @Environment(ProfileAPI.self) private var profileAPI + @Environment(OSSUploadService.self) private var ossUploadService @Environment(ToastCenter.self) private var toastCenter @State private var viewModel = RealNameAuthViewModel() + @State private var pickedFrontItem: PhotosPickerItem? + @State private var pickedBackItem: PhotosPickerItem? var body: some View { ScrollView { @@ -39,6 +45,12 @@ struct RealNameAuthView: View { .background(Color.white.opacity(0.35)) } } + .onChange(of: pickedFrontItem) { _, item in + Task { await prepareIdentityImage(from: item, side: .front) } + } + .onChange(of: pickedBackItem) { _, item in + Task { await prepareIdentityImage(from: item, side: .back) } + } } private var auditStatusPanel: some View { @@ -69,16 +81,28 @@ struct RealNameAuthView: View { private var identityCard: some View { VStack(alignment: .leading, spacing: 14) { - identityImageSection(title: "身份证国徽面", url: viewModel.backUrl) - identityImageSection(title: "身份证人像面", url: viewModel.frontUrl) + identityImageSection( + title: "身份证国徽面", + side: .back, + pickedItem: $pickedBackItem, + pendingData: viewModel.pendingBackImageData, + remoteURL: viewModel.backUrl, + progress: viewModel.backUploadProgress + ) + identityImageSection( + title: "身份证人像面", + side: .front, + pickedItem: $pickedFrontItem, + pendingData: viewModel.pendingFrontImageData, + remoteURL: viewModel.frontUrl, + progress: viewModel.frontUploadProgress + ) formField("姓名", text: realNameBinding, placeholder: "请输入姓名") formField("身份证号码", text: idCardNoBinding, placeholder: "请输入身份证号码") validitySection if shouldShowSmsSection { smsSection } - urlField("身份证人像面图片 URL", text: frontURLBinding) - urlField("身份证国徽面图片 URL", text: backURLBinding) submitButton statusBanner } @@ -88,12 +112,23 @@ struct RealNameAuthView: View { } /// 构造证件图片预览区域。 - private func identityImageSection(title: String, url: String) -> some View { + private func identityImageSection( + title: String, + side: RealNameImageSide, + pickedItem: Binding, + pendingData: Data?, + remoteURL: String, + progress: Int? + ) -> some View { VStack(alignment: .leading, spacing: 10) { Text(title) .font(.system(size: 17, weight: .semibold)) .foregroundStyle(Color(hex: 0x222222)) - imagePreview(url: url) + PhotosPicker(selection: pickedItem, matching: .images) { + imagePreview(side: side, pendingData: pendingData, remoteURL: remoteURL, progress: progress) + } + .buttonStyle(.plain) + .disabled(viewModel.submitting || viewModel.info?.verified == true) } } @@ -237,21 +272,27 @@ struct RealNameAuthView: View { } /// 构造证件图片预览。 - private func imagePreview(url: String) -> some View { + private func imagePreview(side: RealNameImageSide, pendingData: Data?, remoteURL: String, progress: Int?) -> some View { ZStack { - if let imageURL = URL(string: url.trimmingCharacters(in: .whitespacesAndNewlines)), !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - AsyncImage(url: imageURL) { phase in - switch phase { - case .success(let image): - image.resizable().scaledToFit() - case .empty: - ProgressView().tint(AppDesign.primary) - default: - placeholderImageContent - } - } + if let pendingData, let image = UIImage(data: pendingData) { + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - placeholderImageContent + RemoteImage(urlString: remoteURL, contentMode: .fit) { + placeholderImageContent(side: side) + } + } + + if let progress { + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(.black.opacity(0.32)) + Text("\(progress)%") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.white) + } } } .frame(maxWidth: .infinity) @@ -264,12 +305,18 @@ struct RealNameAuthView: View { ) } - private var placeholderImageContent: some View { + /// 构造证件图片空状态内容。 + private func placeholderImageContent(side: RealNameImageSide) -> some View { ZStack { Color(hex: 0xF3F6FA) - Image(systemName: "photo") - .font(.system(size: 26, weight: .semibold)) - .foregroundStyle(Color(hex: 0x8A94A6)) + VStack(spacing: 8) { + Image(systemName: "photo.badge.plus") + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(Color(hex: 0x8A94A6)) + Text(side == .front ? "选择身份证人像面" : "选择身份证国徽面") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Color(hex: 0x8A94A6)) + } } } @@ -286,11 +333,6 @@ struct RealNameAuthView: View { } } - /// 构造 URL 输入行。 - private func urlField(_ title: String, text: Binding) -> some View { - formField(title, text: text, placeholder: "OSS 上传接入前可先填写图片 URL") - } - /// 构造日期选择器。 private func dateField(selection: Binding) -> some View { DatePicker("", selection: selection, displayedComponents: .date) @@ -327,14 +369,6 @@ struct RealNameAuthView: View { Binding(get: { viewModel.smsCode }, set: { viewModel.smsCode = $0 }) } - private var frontURLBinding: Binding { - Binding(get: { viewModel.frontUrl }, set: { viewModel.frontUrl = $0 }) - } - - private var backURLBinding: Binding { - Binding(get: { viewModel.backUrl }, set: { viewModel.backUrl = $0 }) - } - private var startDateBinding: Binding { Binding(get: { viewModel.startDate }, set: { viewModel.startDate = $0 }) } @@ -366,12 +400,38 @@ struct RealNameAuthView: View { /// 提交实名资料。 private func submit() async { do { - try await viewModel.submit(api: profileAPI) + try await viewModel.submit( + api: profileAPI, + uploader: ossUploadService, + scenicId: accountContext.currentScenic?.id ?? 0 + ) } catch { viewModel.statusMessage = error.localizedDescription toastCenter.show(error.localizedDescription) } } + + /// 处理用户选择的证件图片,并生成待上传数据。 + private func prepareIdentityImage(from item: PhotosPickerItem?, side: RealNameImageSide) async { + guard let item else { return } + defer { + switch side { + case .front: + pickedFrontItem = nil + case .back: + pickedBackItem = nil + } + } + do { + guard let data = try await item.loadTransferable(type: Data.self) else { + toastCenter.show("请选择有效的证件图片") + return + } + try viewModel.prepareIdentityImage(data: data, side: side) + } catch { + toastCenter.show(error.localizedDescription) + } + } } private extension String { diff --git a/suixinkanTests/ProfileSecondaryPagesTests.swift b/suixinkanTests/ProfileSecondaryPagesTests.swift index cb8d06f..1d3e552 100644 --- a/suixinkanTests/ProfileSecondaryPagesTests.swift +++ b/suixinkanTests/ProfileSecondaryPagesTests.swift @@ -5,6 +5,7 @@ // Created by Codex on 2026/6/22. // +import UIKit import XCTest @testable import suixinkan @@ -170,6 +171,114 @@ final class ProfileSecondaryPagesTests: XCTestCase { XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("123")) } + /// 测试头像变更时先上传 OSS,再回写头像 URL。 + func testProfileAvatarUploadUpdatesAvatarURL() async throws { + let session = ProfileSecondaryURLSession(responses: [ + try TestFixture.data(named: "empty_success") + ]) + let api = ProfileAPI(client: APIClient(session: session)) + let uploader = MockOSSUploader() + let viewModel = ProfileViewModel() + viewModel.userInfo = UserInfoResponse(avatar: "", nickname: "旧昵称") + viewModel.beginEditing() + try viewModel.prepareAvatarImage(data: makeJPEGData(), timestamp: 1_767_225_600) + + try await viewModel.saveProfile(api: api, uploader: uploader, scenicId: 10) + + XCTAssertEqual(uploader.userAvatarUploads.count, 1) + XCTAssertEqual(uploader.userAvatarUploads.first?.scenicId, 10) + XCTAssertEqual(viewModel.userInfo?.avatar, uploader.avatarURL) + XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/yf-handset-app/userinfo-update-avatar-url"]) + } + + /// 测试头像未变更时不会调用 OSS 上传。 + func testProfileNicknameOnlySaveDoesNotUploadAvatar() async throws { + let session = ProfileSecondaryURLSession(responses: [ + try TestFixture.data(named: "empty_success") + ]) + let api = ProfileAPI(client: APIClient(session: session)) + let uploader = MockOSSUploader() + let viewModel = ProfileViewModel() + viewModel.userInfo = UserInfoResponse(avatar: "https://cdn.example.com/old.jpg", nickname: "旧昵称") + viewModel.beginEditing() + viewModel.editingNickname = "新昵称" + + try await viewModel.saveProfile(api: api, uploader: uploader, scenicId: 10) + + XCTAssertTrue(uploader.userAvatarUploads.isEmpty) + XCTAssertEqual(viewModel.userInfo?.nickname, "新昵称") + XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/yf-handset-app/userinfo-update"]) + } + + /// 测试实名认证提交前会上传正反面图片并提交最终 URL。 + func testRealNameSubmitUploadsImagesBeforeSubmitting() async throws { + let session = ProfileSecondaryURLSession(responses: [ + try TestFixture.data(named: "empty_success"), + try TestFixture.data(named: "real_name_info_success") + ]) + let api = ProfileAPI(client: APIClient(session: session)) + let uploader = MockOSSUploader() + let viewModel = RealNameAuthViewModel() + viewModel.realName = "测试摄影师" + viewModel.idCardNo = "11010519491231002X" + viewModel.smsCode = "123456" + try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .front, timestamp: 1_767_225_600) + try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .back, timestamp: 1_767_225_601) + + try await viewModel.submit(api: api, uploader: uploader, scenicId: 10) + + XCTAssertEqual(uploader.realNameUploads.map(\.fileName), ["real_name_front_1767225600.jpg", "real_name_back_1767225601.jpg"]) + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/real-name/submit", + "/api/yf-handset-app/photog/real-name/info" + ]) + let body = try XCTUnwrap(session.requests.first?.httpBody) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(json["front_url"] as? String, uploader.realNameURLs[0]) + XCTAssertEqual(json["back_url"] as? String, uploader.realNameURLs[1]) + } + + /// 测试实名认证缺少图片时阻止提交。 + func testRealNameSubmitRequiresImages() async throws { + let api = ProfileAPI(client: APIClient(session: ProfileSecondaryURLSession(responses: []))) + let uploader = MockOSSUploader() + let viewModel = RealNameAuthViewModel() + viewModel.realName = "测试摄影师" + viewModel.idCardNo = "11010519491231002X" + viewModel.smsCode = "123456" + + do { + try await viewModel.submit(api: api, uploader: uploader, scenicId: 10) + XCTFail("缺少证件图片时应阻止提交") + } catch { + XCTAssertEqual(error as? RealNameValidationError, .message("请选择身份证人像面图片")) + } + XCTAssertTrue(uploader.realNameUploads.isEmpty) + } + + /// 测试实名认证图片上传失败时不会提交资料。 + func testRealNameSubmitStopsWhenUploadFails() async throws { + let session = ProfileSecondaryURLSession(responses: []) + let api = ProfileAPI(client: APIClient(session: session)) + let uploader = MockOSSUploader(error: OSSUploadError.unsupportedFileType) + let viewModel = RealNameAuthViewModel() + viewModel.realName = "测试摄影师" + viewModel.idCardNo = "11010519491231002X" + viewModel.smsCode = "123456" + try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .front) + try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .back) + + do { + try await viewModel.submit(api: api, uploader: uploader, scenicId: 10) + XCTFail("上传失败时应阻止提交") + } catch { + XCTAssertEqual(error as? OSSUploadError, .unsupportedFileType) + } + + XCTAssertTrue(session.requests.isEmpty) + XCTAssertFalse(viewModel.submitting) + } + /// 测试首页系统设置 URI 已接入真实设置页。 func testHomeSystemSettingsRouteResolvesToSettingsPage() { XCTAssertEqual( @@ -199,3 +308,76 @@ private final class ProfileSecondaryURLSession: URLSessionProtocol { ) } } + +/// 个人中心测试用 OSS 上传替身,记录上传请求并返回固定 URL。 +private final class MockOSSUploader: OSSUploadServing { + struct UploadRecord: Equatable { + let fileName: String + let scenicId: Int + } + + let avatarURL = "https://cdn.example.com/avatar-new.jpg" + let realNameURLs = [ + "https://cdn.example.com/front-new.jpg", + "https://cdn.example.com/back-new.jpg" + ] + private let error: Error? + private(set) var userAvatarUploads: [UploadRecord] = [] + private(set) var realNameUploads: [UploadRecord] = [] + + /// 初始化上传替身,可选指定要抛出的错误。 + init(error: Error? = nil) { + self.error = error + } + + /// 模拟上传用户头像。 + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + if let error { throw error } + userAvatarUploads.append(UploadRecord(fileName: fileName, scenicId: scenicId)) + onProgress(100) + return avatarURL + } + + /// 模拟上传实名认证证件图片。 + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + if let error { throw error } + realNameUploads.append(UploadRecord(fileName: fileName, scenicId: scenicId)) + onProgress(100) + return realNameURLs[min(realNameUploads.count - 1, realNameURLs.count - 1)] + } + + /// 模拟上传云盘文件。 + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + avatarURL + } + + /// 模拟上传相册文件。 + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + avatarURL + } + + /// 模拟上传任务附件。 + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + avatarURL + } + + /// 模拟上传打卡点图片。 + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + avatarURL + } + + /// 模拟上传景区申请图片。 + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + avatarURL + } +} + +/// 生成测试用 JPEG 图片数据。 +private func makeJPEGData() throws -> Data { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 12, height: 12)) + let image = renderer.image { context in + UIColor.systemBlue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 12, height: 12)) + } + return try XCTUnwrap(image.jpegData(compressionQuality: 0.9)) +} diff --git a/suixinkanTests/UploadTests.swift b/suixinkanTests/UploadTests.swift new file mode 100644 index 0000000..1d463dd --- /dev/null +++ b/suixinkanTests/UploadTests.swift @@ -0,0 +1,113 @@ +// +// UploadTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 上传模块测试,覆盖 OSS 上传策略和 STS 配置接口。 +final class UploadTests: XCTestCase { + /// 测试文件校验能识别空文件、超大文件和不支持扩展名。 + func testOSSUploadPolicyValidatesFileConstraints() { + XCTAssertThrowsError(try OSSUploadPolicy.validate(dataSize: 0, fileName: "avatar.jpg")) { error in + XCTAssertEqual(error as? OSSUploadError, .emptyFile) + } + XCTAssertThrowsError(try OSSUploadPolicy.validate(dataSize: OSSUploadPolicy.maxFileSize + 1, fileName: "avatar.jpg")) { error in + XCTAssertEqual(error as? OSSUploadError, .fileTooLarge) + } + XCTAssertThrowsError(try OSSUploadPolicy.validate(dataSize: 12, fileName: "avatar.txt")) { error in + XCTAssertEqual(error as? OSSUploadError, .unsupportedFileType) + } + XCTAssertNoThrow(try OSSUploadPolicy.validate(dataSize: 12, fileName: "avatar.heic")) + } + + /// 测试 objectKey 按业务模块生成稳定路径并清洗文件名。 + func testOSSUploadPolicyBuildsObjectKeys() { + let date = Date(timeIntervalSince1970: 1_767_225_600) + let uuid = UUID(uuidString: "12345678-1234-1234-1234-1234567890ab")! + let timeZone = TimeZone(secondsFromGMT: 0)! + + XCTAssertEqual( + OSSUploadPolicy.objectKey(fileName: "a/b.jpg", scenicId: 9, moduleType: "user_avatar", date: date, uuid: uuid, timeZone: timeZone), + "avatar/20260101/9/123456781234123412341234567890AB_a_b.jpg" + ) + XCTAssertEqual( + OSSUploadPolicy.objectKey(fileName: "id.jpg", scenicId: 9, moduleType: "real_name", date: date, uuid: uuid, timeZone: timeZone), + "real_name/20260101/9/123456781234123412341234567890AB_id.jpg" + ) + XCTAssertEqual( + OSSUploadPolicy.objectKey(fileName: "video.mp4", scenicId: 9, moduleType: "task_upload", date: date, uuid: uuid, timeZone: timeZone), + "task_upload/20260101/9/123456781234123412341234567890AB_video.mp4" + ) + } + + /// 测试 MIME 类型推断和 URL 拼接规则。 + func testOSSUploadPolicyContentTypeAndURLJoin() { + XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.jpg", fileType: 2), "image/jpeg") + XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.png", fileType: 2), "image/png") + XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.mp4", fileType: 1), "video/mp4") + XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.unknown", fileType: 1), "video/mp4") + XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.unknown", fileType: 2), "image/jpeg") + XCTAssertEqual(OSSUploadPolicy.joinURL(baseURL: "https://cdn.example.com", objectKey: "a/b.jpg"), "https://cdn.example.com/a/b.jpg") + XCTAssertEqual(OSSUploadPolicy.joinURL(baseURL: "https://cdn.example.com/", objectKey: "a/b.jpg"), "https://cdn.example.com/a/b.jpg") + } + + /// 测试 STS token 接口使用正确路径、bucket 参数并兼容宽松字段类型。 + func testUploadAPIRequestsSTSConfig() async throws { + let response = """ + { + "code": 100000, + "msg": "success", + "data": { + "base_url": 123, + "endpoint": "oss-cn-hangzhou.aliyuncs.com", + "region": "cn-hangzhou", + "bucket": "vipsky", + "expire_seconds": "900", + "credentials": { + "access_key_id": 456, + "access_key_secret": "secret", + "security_token": "token" + } + } + } + """.data(using: .utf8)! + let session = UploadURLSession(responses: [response]) + let api = UploadAPI(client: APIClient(session: session)) + + let config = try await api.aliyunOSSBucket() + + XCTAssertEqual(config.baseUrl, "123") + XCTAssertEqual(config.expireSeconds, 900) + XCTAssertEqual(config.credentials.accessKeyId, "456") + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/app/config/get-sts-token") + XCTAssertEqual(URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems?.first?.value, "vipsky") + } +} + +/// 上传测试用 URLSession 替身,按顺序返回预设响应。 +private final class UploadURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + /// 初始化顺序响应。 + init(responses: [Data]) { + self.responses = responses + } + + /// 记录请求并返回下一份响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? Data() : responses.removeFirst() + return ( + data, + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } +} diff --git a/功能同步Checklist.md b/功能同步Checklist.md new file mode 100644 index 0000000..1b10280 --- /dev/null +++ b/功能同步Checklist.md @@ -0,0 +1,135 @@ +# 功能同步 Checklist + +更新时间:2026-06-22 + +## 状态说明 + +- 完成:UI、核心功能、文档和必要单元测试已同步,并通过编译验证。 +- 部分完成:已有入口、壳、路由或部分主流程,但旧工程完整业务尚未迁移。 +- 未开始:new 工程尚无对应真实业务页面或服务。 +- 不迁移:已明确不纳入 iOS 迁移范围。 + +## 基础架构 + +| 状态 | 模块 | 同步内容 | 剩余事项 | +| --- | --- | --- | --- | +| 完成 | App 根结构 | `RootView` 按登录态切换登录页/主 Tab;主界面使用 `TabView`,每个 Tab 独立 `NavigationStack`。 | 持续随业务路由扩展。 | +| 完成 | Tab 导航 | 首页、订单、数据、我的四个 Tab;push 子页面时隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 | +| 完成 | 全局状态拆分 | `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter` 拆分。 | 继续避免新增大而全全局状态。 | +| 完成 | 登录态缓存 | token 使用 Keychain;账号快照、上次手机号、偏好使用 UserDefaults;冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 | +| 完成 | 网络框架 | `APIClient`、`APIRequest`、`APIEnvelope`、统一错误处理和 token 注入。 | 业务 API 继续按模块拆分。 | +| 完成 | OSS 上传 | `UploadAPI`、`OSSUploadService`、上传策略、头像和实名图片压缩处理。 | 后续云盘、相册、任务等模块迁移时复用。 | +| 完成 | 网络图片 | 新增 `RemoteImage` / `RemoteAvatarImage`,当前网络图片展示均使用 Kingfisher。 | 后续新页面禁止直接使用 `AsyncImage` 加载网络图片。 | +| 完成 | 设计常量 | `AppDesign`、`AppMetrics` 统一颜色、字号、间距等通用尺寸。 | 继续替换新页面中的零散常量。 | +| 完成 | 文档规范 | 已有 `AGENTS.md` 约束;现有模块均有模块说明文档。 | 后续每个新功能模块同步新增模块 md。 | + +## 登录与账号上下文 + +| 状态 | 模块 | 同步内容 | 剩余事项 | +| --- | --- | --- | --- | +| 完成 | 登录页 | 登录 UI、手机号/密码登录、协议勾选、上次手机号恢复。 | 后续如有验证码登录再迁移。 | +| 完成 | 多账号选择 | 登录后多账号选择、临时 token 内存保存、选择后写正式 token。 | 继续跟随后端账号结构变化维护。 | +| 完成 | 账号上下文 | 用户信息、角色权限、景区列表、门店列表、当前景区/门店选择和恢复。 | 后续业务模块按当前上下文取数。 | +| 完成 | 景点/打卡点上下文 | 当前景区下景点/打卡点懒加载,失败不影响登录态。 | 具体打卡点管理页面尚未迁移。 | +| 完成 | 权限菜单数据 | 递归解析角色权限、URI 去重、按旧工程顺序排序。 | 新 URI 出现时补路由映射或标记不支持。 | + +## 首页 + +| 状态 | 模块 | 同步内容 | 剩余事项 | +| --- | --- | --- | --- | +| 完成 | 首页主 UI | 顶部景区、工作状态、位置上报卡、快捷操作、门店卡、常用应用网格。 | 视觉可随旧工程细节继续微调。 | +| 完成 | 首页权限菜单 | 从 `PermissionContext` 构建菜单,支持 URI 别名去重和旧工程排序。 | 未知 URI 继续通过诊断报告跟踪。 | +| 完成 | 常用应用 | 默认常用应用、添加/移除、UserDefaults 持久化、按权限过滤。 | 后续可加入拖拽排序。 | +| 完成 | 更多功能 | 全部功能页、常用应用增删、首页入口跳转。 | 未迁移功能仍进入占位页。 | +| 完成 | 首页路由壳 | 已迁移入口跳真实页面;订单/数据入口切换 Tab;未知入口安全占位。 | 按模块逐步替换占位页。 | +| 部分完成 | 景区选择入口 | 首页轻量景区选择,可调用 `AccountContext.selectScenic(id:)`。 | 如旧工程有完整筛选/搜索/权限细节,后续补齐。 | + +## 订单 Tab + +| 状态 | 模块 | 同步内容 | 剩余事项 | +| --- | --- | --- | --- | +| 完成 | 订单 Tab 根页面 | 订单管理/核销订单分段入口。 | 后续补完整详情流程。 | +| 完成 | 订单管理列表 | 景区上下文校验、状态筛选、手机号搜索、日期筛选、分页、刷新。 | 订单详情仍为占位。 | +| 完成 | 核销订单列表 | 核销列表、分页、刷新、手动输入订单号核销。 | 核销详情仍为占位。 | +| 部分完成 | 扫码核销 | 入口存在,进入安全占位。 | 迁移相机权限、扫码识别、核销闭环。 | +| 部分完成 | 订单详情 | 列表点击可进入占位详情。 | 迁移完整详情、退款、历史拍摄、任务上传、视频预告等长尾流程。 | +| 未开始 | 退款流程 | 暂未迁移。 | 需要同步旧工程退款 UI、接口和状态流。 | +| 未开始 | 历史拍摄 | 暂未迁移。 | 需要同步旧工程相关页面和接口。 | + +## 数据 Tab + +| 状态 | 模块 | 同步内容 | 剩余事项 | +| --- | --- | --- | --- | +| 完成 | 数据看板 | 今日、昨日、7 日、本月时间段切换;统计汇总卡;每日明细。 | 后续按旧工程继续补图表细节。 | +| 完成 | 数据分页 | 日数据分页加载、切换时间段重置分页、加载失败保留当前数据。 | 持续跟随后端字段变化。 | +| 完成 | 角色接口分流 | 摄影师/景区管理员两套统计接口。 | 其他角色如有特殊逻辑再补。 | + +## 个人中心 + +| 状态 | 模块 | 同步内容 | 剩余事项 | +| --- | --- | --- | --- | +| 完成 | 个人信息首页 | 头像、昵称、UID、姓名、手机号、账号状态、当前景区、退出登录。 | 后续可继续细化旧工程视觉。 | +| 完成 | 头像上传 | `PhotosPicker` 选图、压缩、OSS 上传、回写头像 URL、刷新账号快照。 | 后续可补裁剪体验。 | +| 完成 | 昵称编辑 | 昵称校验、保存、同步 `AccountContext`。 | 无。 | +| 完成 | 修改密码 | 密码弹窗、最小长度校验、接口提交。 | 无。 | +| 完成 | 账号切换 | 可切换账号列表、默认选中、set-user、刷新登录上下文。 | 无。 | +| 完成 | 实名认证 | 信息加载、短信验证码、身份证校验、证件图片选择、OSS 上传、提交审核。 | 后续可补示例图或拍照入口。 | +| 完成 | 系统设置 | 关于我们、版本号、下载链接、协议入口。 | 若旧工程有更多设置项需继续核对。 | +| 完成 | 协议页 | 关于我们、用户协议、隐私政策、钱包协议页面。 | 无。 | + +## 首页子业务模块 + +| 状态 | 模块 | 当前情况 | 后续事项 | +| --- | --- | --- | --- | +| 部分完成 | 钱包 | 首页入口已识别,进入占位页。 | 迁移钱包首页、提现、流水、余额等流程。 | +| 部分完成 | 提现审核 | 首页入口已识别,进入占位页。 | 迁移审核列表、详情、操作接口。 | +| 部分完成 | 消息中心 | 首页入口已识别,进入占位页。 | 迁移消息列表、已读状态和详情。 | +| 部分完成 | 排队管理 | `/scenic-queue` / `queue_management` 已识别,进入占位页。 | 迁移队列列表、叫号、设置等流程。 | +| 部分完成 | 立即收款/收款码 | 入口已识别,进入占位页。 | 迁移收款码、收款记录、支付结果。 | +| 部分完成 | 押金订单 | 入口已识别,进入占位页。 | 迁移押金订单详情和拍摄信息。 | +| 部分完成 | 任务管理 | 入口已识别,进入占位页。 | 迁移任务列表、筛选、详情、状态操作。 | +| 部分完成 | 发布任务 | 入口已识别,进入占位页。 | 迁移任务创建、选择订单、附件上传。 | +| 部分完成 | 日程管理 | 入口已识别,进入占位页。 | 迁移日程列表、日历和详情。 | +| 部分完成 | 打卡点管理 | 入口已识别,景点上下文已具备。 | 迁移打卡点列表、创建/编辑、图片上传。 | +| 部分完成 | 项目管理 | `pm` / `pm_manager` / `project_edit` 已识别,进入占位页。 | 迁移项目列表、详情、编辑、新建。 | +| 部分完成 | 相册云盘 | 入口已识别,OSS 上传服务已具备。 | 迁移云盘列表、文件上传、传输管理。 | +| 部分完成 | 相册管理 | 入口已识别,进入占位页。 | 迁移相册文件夹、相册内容、预览上传。 | +| 部分完成 | 素材管理 | 入口已识别,OSS 上传服务已具备。 | 迁移素材列表、上传素材。 | +| 部分完成 | 样片管理 | 入口已识别,OSS 上传服务已具备。 | 迁移样片列表、上传样片。 | +| 部分完成 | 直播管理 | 入口已识别,进入占位页。 | 迁移直播管理和直播相册。 | +| 部分完成 | 景区申请 | 入口已识别,OSS 上传服务已具备。 | 迁移申请表单、图片上传、提交状态。 | +| 部分完成 | 权限申请 | 入口已识别,进入占位页。 | 迁移权限申请、申请状态。 | +| 部分完成 | 景区结算 | 入口已识别,进入占位页。 | 迁移结算列表、审核、详情。 | +| 部分完成 | 运营区域 | 入口已识别,进入占位页。 | 迁移区域列表、地图/范围配置。 | +| 部分完成 | 位置上报 | 首页已有位置上报卡;具体页面入口进入占位。 | 迁移上报提交、历史记录、定位权限。 | +| 部分完成 | 注册邀请 | 入口已识别,进入占位页。 | 迁移邀请摄影师、邀请记录。 | +| 部分完成 | 飞手认证 | 入口已识别,进入占位页。 | 迁移飞手认证页面。 | +| 不迁移 | 飞控/DJI | `fly` / `pilot_controller` 已明确为 iOS 不支持。 | 不再迁移。 | + +## 旧工程能力尚未系统迁移 + +| 状态 | 模块 | 说明 | +| --- | --- | --- | +| 未开始 | Assets 模块 | 旧工程 `Feature/Assets` 下相册内容、素材内容尚未完整迁移。 | +| 未开始 | Core/Map | 旧工程地图相关能力尚未迁移。 | +| 未开始 | Core/Push | 旧工程推送能力尚未迁移。 | +| 未开始 | Core/Queue | 旧工程队列基础能力尚未迁移。 | +| 未开始 | Core/Validation | 旧工程独立校验能力尚未系统迁移;当前只迁移了实名身份证校验等必要逻辑。 | +| 未开始 | UIKit 旧代码 | 旧工程 UIKit 目录下的兼容/旧版页面尚未迁移,除非后续明确仍需保留。 | + +## 最近验证记录 + +已通过: + +```bash +xcodebuild test -project suixinkan.xcodeproj -scheme suixinkan -destination 'platform=iOS Simulator,name=iPhone 17' +xcodebuild build -project suixinkan.xcodeproj -scheme suixinkan -destination 'generic/platform=iOS Simulator' +``` + +## 下一批建议迁移顺序 + +1. 订单详情与扫码核销:订单 Tab 已具备入口和列表,继续补闭环收益最大。 +2. 任务管理/发布任务:已具备 OSS 上传服务,可复用当前上传层。 +3. 相册云盘/素材管理:上传能力已准备好,适合接着迁移文件类模块。 +4. 钱包/提现/结算:涉及资金流,建议单独一轮迁移并补足单元测试。 +5. 打卡点管理/位置上报:依赖当前景区和景点上下文,适合在账号上下文稳定后推进。