Add OSS upload and Kingfisher image support

This commit is contained in:
2026-06-22 16:30:05 +08:00
parent 484566d27e
commit 0b83a73509
20 changed files with 1399 additions and 134 deletions

View File

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

View File

@ -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、一次性扫码结果和错误提示不落盘。
- 头像图片缓存交给 KingfisherCore 不保存图片 Data。
- 头像、证件照等图片缓存交给 KingfisherCore 不保存图片 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` 加载网络图片。

View File

@ -0,0 +1,81 @@
//
// RemoteImage.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Kingfisher
import SwiftUI
/// 使 Kingfisher
struct RemoteImage<Placeholder: View>: 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)
}
}
}

View File

@ -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<String> = ["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格式"
}
}
}

View File

@ -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 保存,上传模块不直接读取或保存登录态。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 和上传进度只保存在内存中,不写入本地缓存
## 设置和协议流程

View File

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

View File

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

View File

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

View File

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

View File

@ -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<PhotosPickerItem?>,
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<String>) -> some View {
formField(title, text: text, placeholder: "OSS 上传接入前可先填写图片 URL")
}
///
private func dateField(selection: Binding<Date>) -> 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<String> {
Binding(get: { viewModel.frontUrl }, set: { viewModel.frontUrl = $0 })
}
private var backURLBinding: Binding<String> {
Binding(get: { viewModel.backUrl }, set: { viewModel.backUrl = $0 })
}
private var startDateBinding: Binding<Date> {
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 {