feat: 增加门店身份注销流程
This commit is contained in:
@@ -19,6 +19,7 @@ final class APIClient {
|
||||
private let session: URLSessionProtocol
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
private let notificationCenter: NotificationCenter
|
||||
private var authTokenProvider: (() -> String?)?
|
||||
|
||||
private let environment: APIEnvironment
|
||||
@@ -32,12 +33,14 @@ final class APIClient {
|
||||
encoder: JSONEncoder = JSONEncoder(),
|
||||
decoder: JSONDecoder = JSONDecoder(),
|
||||
appVersion: String = AppClientInfo.appVersion(),
|
||||
osType: String = AppClientInfo.osType
|
||||
osType: String = AppClientInfo.osType,
|
||||
notificationCenter: NotificationCenter = .default
|
||||
) {
|
||||
self.environment = environment
|
||||
self.session = session
|
||||
self.encoder = encoder
|
||||
self.decoder = decoder
|
||||
self.notificationCenter = notificationCenter
|
||||
self.appVersion = appVersion.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "1.0.0"
|
||||
self.osType = osType.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? AppClientInfo.osType
|
||||
}
|
||||
@@ -61,7 +64,7 @@ final class APIClient {
|
||||
tokenOverride: String? = nil
|
||||
) async throws -> Response {
|
||||
let request = try makeURLRequest(apiRequest, tokenOverride: tokenOverride)
|
||||
logRequest(request)
|
||||
logRequest(request, includeBody: apiRequest.logsPayload)
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
@@ -80,12 +83,12 @@ final class APIClient {
|
||||
throw APIError.networkFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
logResponse(for: request, response: response, data: data)
|
||||
logResponse(for: request, response: response, data: data, includeBody: apiRequest.logsPayload)
|
||||
do {
|
||||
try validateHTTPResponse(response, data: data)
|
||||
return try decodeEnvelope(Response.self, from: data)
|
||||
} catch let error as APIError {
|
||||
notifySessionExpiredIfNeeded(for: error)
|
||||
notifySessionErrorIfNeeded(for: error, request: request)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -141,7 +144,7 @@ final class APIClient {
|
||||
try validateHTTPResponse(response, data: data)
|
||||
return try decodeEnvelope(Response.self, from: data)
|
||||
} catch let error as APIError {
|
||||
notifySessionExpiredIfNeeded(for: error)
|
||||
notifySessionErrorIfNeeded(for: error, request: request)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -212,7 +215,7 @@ final class APIClient {
|
||||
try validateHTTPResponse(response, data: responseData)
|
||||
return try decodeEnvelope(Response.self, from: responseData)
|
||||
} catch let error as APIError {
|
||||
notifySessionExpiredIfNeeded(for: error)
|
||||
notifySessionErrorIfNeeded(for: error, request: request)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -267,6 +270,9 @@ final class APIClient {
|
||||
}
|
||||
|
||||
guard 200 ..< 300 ~= httpResponse.statusCode else {
|
||||
if let envelope = try? decoder.decode(ErrorEnvelope.self, from: data), envelope.code == 150015 {
|
||||
throw APIError.serverCode(150015, parseHTTPErrorMessage(data: data))
|
||||
}
|
||||
throw APIError.httpStatus(httpResponse.statusCode, parseHTTPErrorMessage(data: data))
|
||||
}
|
||||
}
|
||||
@@ -295,10 +301,19 @@ final class APIClient {
|
||||
return payload
|
||||
}
|
||||
|
||||
/// Token 失效时广播 sessionDidExpire,触发全局登出。
|
||||
private func notifySessionExpiredIfNeeded(for error: APIError) {
|
||||
/// 区分身份注销受限与凭证失效;150015 附带原请求 Token,便于忽略切换身份后的旧错误。
|
||||
private func notifySessionErrorIfNeeded(for error: APIError, request: URLRequest) {
|
||||
if case .serverCode(150015, _) = error {
|
||||
guard let token = request.value(forHTTPHeaderField: "token"), !token.isEmpty else { return }
|
||||
notificationCenter.post(
|
||||
name: NotificationName.storeAccountDeregistrationRestricted,
|
||||
object: nil,
|
||||
userInfo: [NotificationUserInfoKey.deregistrationRequestToken: token]
|
||||
)
|
||||
return
|
||||
}
|
||||
guard APIError.isAuthenticationExpired(error) else { return }
|
||||
NotificationCenter.default.post(name: NotificationName.sessionDidExpire, object: nil)
|
||||
notificationCenter.post(name: NotificationName.sessionDidExpire, object: nil)
|
||||
}
|
||||
|
||||
/// 从 HTTP 错误响应中提取更适合展示给用户的错误信息。
|
||||
@@ -341,7 +356,7 @@ final class APIClient {
|
||||
}
|
||||
|
||||
/// 在 Debug 环境打印请求信息(含 GET 查询参数与 POST 请求体,对齐 Android Ktor `LogLevel.BODY`)。
|
||||
private func logRequest(_ request: URLRequest) {
|
||||
private func logRequest(_ request: URLRequest, includeBody: Bool = true) {
|
||||
#if DEBUG
|
||||
let method = request.httpMethod ?? "REQUEST"
|
||||
let url = request.url?.absoluteString ?? "<invalid url>"
|
||||
@@ -359,7 +374,7 @@ final class APIClient {
|
||||
}
|
||||
|
||||
let contentType = request.value(forHTTPHeaderField: "Content-Type")
|
||||
if let body = Self.debugRequestBody(from: request.httpBody, contentType: contentType) {
|
||||
if includeBody, let body = Self.debugRequestBody(from: request.httpBody, contentType: contentType) {
|
||||
lines.append("body:\n\(body)")
|
||||
}
|
||||
|
||||
@@ -368,12 +383,12 @@ final class APIClient {
|
||||
}
|
||||
|
||||
/// 在 Debug 环境打印响应状态和响应体。
|
||||
private func logResponse(for request: URLRequest, response: URLResponse, data: Data) {
|
||||
private func logResponse(for request: URLRequest, response: URLResponse, data: Data, includeBody: Bool = true) {
|
||||
#if DEBUG
|
||||
let method = request.httpMethod ?? "REQUEST"
|
||||
let url = request.url?.absoluteString ?? "<invalid url>"
|
||||
let statusCode = (response as? HTTPURLResponse).map { String($0.statusCode) } ?? "unknown"
|
||||
let body = Self.debugResponseBody(from: data)
|
||||
let body = includeBody ? Self.debugResponseBody(from: data) : "<sensitive payload omitted>"
|
||||
print("[API][Response] \(method) \(url) status=\(statusCode)\n\(body)")
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ nonisolated struct APIRequest<Response: Decodable> {
|
||||
var queryItems: [URLQueryItem]
|
||||
var headers: [String: String]
|
||||
var body: AnyEncodable?
|
||||
/// 敏感请求关闭正文日志,避免验证码和注销资产进入调试日志。
|
||||
var logsPayload: Bool
|
||||
|
||||
/// 创建一个 API 请求,并把可编码请求体擦除为统一的 AnyEncodable。
|
||||
init<Body: Encodable>(
|
||||
@@ -27,13 +29,15 @@ nonisolated struct APIRequest<Response: Decodable> {
|
||||
path: String,
|
||||
queryItems: [URLQueryItem] = [],
|
||||
headers: [String: String] = [:],
|
||||
body: Body? = Optional<EmptyPayload>.none
|
||||
body: Body? = Optional<EmptyPayload>.none,
|
||||
logsPayload: Bool = true
|
||||
) {
|
||||
self.method = method
|
||||
self.path = path
|
||||
self.queryItems = queryItems
|
||||
self.headers = headers
|
||||
self.body = body.map(AnyEncodable.init)
|
||||
self.logsPayload = logsPayload
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,8 @@ final class PushNotificationManager: NSObject {
|
||||
private var isInitialized = false
|
||||
private var didRequestAuthorization = false
|
||||
private var uploadTask: Task<Void, Never>?
|
||||
private var uploadAttemptID: UUID?
|
||||
private var isAccountBindingSuspended = false
|
||||
private var queuedForcedUpload = false
|
||||
private var isFetchingRegistrationID = false
|
||||
private var queuedForcedFetch = false
|
||||
@@ -161,7 +163,7 @@ final class PushNotificationManager: NSObject {
|
||||
/// 登录成功后请求通知权限,并强制绑定当前账号。
|
||||
func handleLoginCompleted() {
|
||||
initializeIfPrivacyAccepted()
|
||||
guard isInitialized, appStore.session.isLoggedIn else { return }
|
||||
guard !isAccountBindingSuspended, isInitialized, appStore.session.isLoggedIn else { return }
|
||||
if !didRequestAuthorization {
|
||||
didRequestAuthorization = true
|
||||
sdk.requestAuthorization(delegate: self)
|
||||
@@ -171,7 +173,7 @@ final class PushNotificationManager: NSObject {
|
||||
|
||||
/// 账号切换后把同一设备重新绑定到新的业务账号。
|
||||
func handleAccountSwitched() {
|
||||
guard appStore.session.isLoggedIn else { return }
|
||||
guard !isAccountBindingSuspended, appStore.session.isLoggedIn else { return }
|
||||
bindCurrentAccount()
|
||||
}
|
||||
|
||||
@@ -179,11 +181,22 @@ final class PushNotificationManager: NSObject {
|
||||
func handleLogout() {
|
||||
uploadTask?.cancel()
|
||||
uploadTask = nil
|
||||
uploadAttemptID = nil
|
||||
queuedForcedUpload = false
|
||||
router.resetPendingRoute()
|
||||
Task { await updateApplicationIconBadgeCount(0) }
|
||||
}
|
||||
|
||||
/// 注销核验或受限期间暂停业务账号绑定,保留 Token、Registration ID 与待处理通知。
|
||||
func setAccountBindingSuspended(_ suspended: Bool) {
|
||||
isAccountBindingSuspended = suspended
|
||||
guard suspended else { return }
|
||||
uploadTask?.cancel()
|
||||
uploadTask = nil
|
||||
uploadAttemptID = nil
|
||||
queuedForcedUpload = false
|
||||
}
|
||||
|
||||
/// 将桌面 App Icon 角标更新为最新未读消息数量。
|
||||
func updateApplicationIconBadgeCount(_ count: Int) async {
|
||||
await applicationIconBadgeSetter.setBadgeCount(max(count, 0))
|
||||
@@ -191,13 +204,14 @@ final class PushNotificationManager: NSObject {
|
||||
|
||||
/// App 回到前台时补偿失败或尚未完成的 Registration ID 上报。
|
||||
func retryPendingRegistrationUpload() {
|
||||
guard appStore.session.isLoggedIn else { return }
|
||||
guard !isAccountBindingSuspended, appStore.session.isLoggedIn else { return }
|
||||
uploadCachedRegistrationID(force: false)
|
||||
refreshRegistrationID(forceUpload: false)
|
||||
}
|
||||
|
||||
/// 登录根页面建立后继续执行通知点击暂存的路由。
|
||||
func routePendingNotificationIfPossible() {
|
||||
guard !isAccountBindingSuspended else { return }
|
||||
router.routePendingIfPossible()
|
||||
}
|
||||
|
||||
@@ -323,7 +337,7 @@ final class PushNotificationManager: NSObject {
|
||||
}
|
||||
|
||||
private func upload(registrationID: String, force: Bool) {
|
||||
guard appStore.session.isLoggedIn,
|
||||
guard !isAccountBindingSuspended, appStore.session.isLoggedIn,
|
||||
let uploadedKey = appStore.session.accountScopedKey(Key.uploadedRegistrationIDSuffix)
|
||||
else { return }
|
||||
|
||||
@@ -336,12 +350,15 @@ final class PushNotificationManager: NSObject {
|
||||
}
|
||||
|
||||
let accountScope = appStore.session.accountCachePrefix
|
||||
let attemptID = UUID()
|
||||
uploadAttemptID = attemptID
|
||||
uploadTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
var succeeded = false
|
||||
do {
|
||||
try await self.api.registerJPushID(registrationID)
|
||||
if self.appStore.session.accountCachePrefix == accountScope {
|
||||
if self.uploadAttemptID == attemptID, !self.isAccountBindingSuspended,
|
||||
self.appStore.session.accountCachePrefix == accountScope {
|
||||
self.defaults.set(registrationID, forKey: uploadedKey)
|
||||
}
|
||||
succeeded = true
|
||||
@@ -353,7 +370,9 @@ final class PushNotificationManager: NSObject {
|
||||
#endif
|
||||
}
|
||||
|
||||
guard self.uploadAttemptID == attemptID else { return }
|
||||
self.uploadTask = nil
|
||||
self.uploadAttemptID = nil
|
||||
let shouldForceAgain = self.queuedForcedUpload
|
||||
self.queuedForcedUpload = false
|
||||
if succeeded, shouldForceAgain {
|
||||
|
||||
Reference in New Issue
Block a user