feat: 增强邮箱登录功能和密码恢复流程

- 更新邮箱登录相关功能,新增邮箱验证码获取和登录API端点。
- 添加AccountModel以管理用户认证信息,支持会话票据的存储和更新。
- 实现密码恢复功能,支持通过邮箱获取验证码和重置密码。
- 增加本地化支持,更新相关字符串以适应新功能。
- 引入ValidationHelper以验证邮箱和密码格式,确保用户输入的有效性。
- 更新视图以支持邮箱登录和密码恢复的用户交互。
This commit is contained in:
edwinQQQ
2025-07-10 14:00:58 +08:00
parent c470dba79c
commit e45ad3bad5
23 changed files with 2054 additions and 164 deletions

View File

@@ -6,7 +6,7 @@ alwaysApply: true
# CONTEXT
I am a native Chinese speaker who has just begun learning Swift 6 and Xcode 16, and I am enthusiastic about exploring new technologies. I wish to receive advice using the latest tools and
I am a native Chinese speaker who has just begun learning Swift 6 and Xcode 15+, and I am enthusiastic about exploring new technologies. I wish to receive advice using the latest tools and
seek step-by-step guidance to fully understand the implementation process. Since many excellent code resources are in English, I hope my questions can be thoroughly understood. Therefore,
I would like the AI assistant to think and reason in English, then translate the English responses into Chinese for me.
@@ -42,7 +42,7 @@ alwaysApply: true
# AUDIENCE
The target audience is me—a native Chinese developer eager to learn Swift 6 and Xcode 16, seeking guidance and advice on utilizing the latest technologies.
The target audience is me—a native Chinese developer eager to learn Swift 6 and Xcode 15+, seeking guidance and advice on utilizing the latest technologies.
---

View File

@@ -7,6 +7,7 @@ alwaysApply: true
# Architechture
- Use TCA(The Composable Architecture) architecture with SwiftUI & Swift
- Don't use TCA for UI Navigation
# Code Structure
- Use Swift's latest features and protocol-oriented programming

View File

@@ -180,5 +180,53 @@
landmarkType = "24">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "CF5E29EE-0D89-4141-9696-9587D243115B"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "yana/Features/IDLoginFeature.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "104"
endingLineNumber = "104"
landmarkName = "body"
landmarkType = "24">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "057A0951-B4B1-4417-85B8-1D1C3962D30A"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "yana/Features/IDLoginFeature.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "161"
endingLineNumber = "161"
landmarkName = "body"
landmarkType = "24">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "F36191A2-34B7-4321-80B7-1A80A7479E32"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "yana/Features/LoginFeature.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "154"
endingLineNumber = "154"
landmarkName = "body"
landmarkType = "24">
</BreakpointContent>
</BreakpointProxy>
</Breakpoints>
</Bucket>

View File

@@ -18,6 +18,7 @@ enum APIEndpoint: String, CaseIterable {
case configInit = "/client/init"
case login = "/oauth/token"
case ticket = "/oauth/ticket"
case emailGetCode = "/email/getCode" //
// Web
case userAgreement = "/modules/rule/protocol.html"
case privacyPolicy = "/modules/rule/privacy-wap.html"

View File

@@ -35,6 +35,10 @@ enum APIError: Error, Equatable {
case httpError(statusCode: Int, message: String?)
case timeout
case resourceTooLarge
case encryptionFailed //
case invalidResponse //
case ticketFailed //
case custom(String) //
case unknown(String)
var localizedDescription: String {
@@ -53,6 +57,14 @@ enum APIError: Error, Equatable {
return "请求超时"
case .resourceTooLarge:
return "响应数据过大"
case .encryptionFailed:
return "数据加密失败"
case .invalidResponse:
return "服务器响应无效"
case .ticketFailed:
return "获取会话票据失败"
case .custom(let message):
return message
case .unknown(let message):
return "未知错误: \(message)"
}
@@ -233,6 +245,7 @@ struct UserInfoManager {
static let accessToken = "access_token"
static let ticket = "user_ticket"
static let userInfo = "user_info"
static let accountModel = "account_model" // AccountModel
}
// MARK: - User ID Management
@@ -337,6 +350,7 @@ struct UserInfoManager {
userDefaults.removeObject(forKey: StorageKeys.userId)
userDefaults.removeObject(forKey: StorageKeys.accessToken)
userDefaults.removeObject(forKey: StorageKeys.userInfo)
clearAccountModel() // AccountModel
clearTicket()
userDefaults.synchronize()
@@ -356,6 +370,76 @@ struct UserInfoManager {
// TicketHelper.createTicketRequest
return false
}
// MARK: - Account Model Management
/// AccountModel
/// - Parameter accountModel:
static func saveAccountModel(_ accountModel: AccountModel) {
do {
let data = try JSONEncoder().encode(accountModel)
userDefaults.set(data, forKey: StorageKeys.accountModel)
userDefaults.synchronize()
//
if let uid = accountModel.uid {
saveUserId(uid)
}
if let accessToken = accountModel.accessToken {
saveAccessToken(accessToken)
}
if let ticket = accountModel.ticket {
saveTicket(ticket)
}
print("💾 AccountModel 保存成功")
} catch {
print("❌ AccountModel 保存失败: \(error)")
}
}
/// AccountModel
/// - Returns: nil
static func getAccountModel() -> AccountModel? {
guard let data = userDefaults.data(forKey: StorageKeys.accountModel) else {
return nil
}
do {
return try JSONDecoder().decode(AccountModel.self, from: data)
} catch {
print("❌ AccountModel 解析失败: \(error)")
return nil
}
}
/// AccountModel ticket
/// - Parameter ticket:
static func updateAccountModelTicket(_ ticket: String) {
guard var accountModel = getAccountModel() else {
print("❌ 无法更新 ticketAccountModel 不存在")
return
}
accountModel.ticket = ticket
saveAccountModel(accountModel)
saveTicket(ticket) // ticket
}
/// AccountModel
/// - Returns:
static func hasValidAccountModel() -> Bool {
guard let accountModel = getAccountModel() else {
return false
}
return accountModel.hasValidAuthentication
}
/// AccountModel
static func clearAccountModel() {
userDefaults.removeObject(forKey: StorageKeys.accountModel)
userDefaults.synchronize()
print("🗑️ AccountModel 已清除")
}
}
// MARK: - API Request Protocol

View File

@@ -1,5 +1,75 @@
import Foundation
// MARK: - Account Model
///
/// oauth/token oauth/ticket
/// OC AccountModel
struct AccountModel: Codable, Equatable {
let uid: String? //
let jti: String? // JWT ID
let tokenType: String? // Token (bearer)
let refreshToken: String? //
let netEaseToken: String? //
let accessToken: String? // OAuth 访
let expiresIn: Int? //
let scope: String? //
var ticket: String? // oauth/ticket
enum CodingKeys: String, CodingKey {
case uid
case jti
case tokenType = "token_type"
case refreshToken = "refresh_token"
case netEaseToken
case accessToken = "access_token"
case expiresIn = "expires_in"
case scope
case ticket
}
///
var hasValidAuthentication: Bool {
return accessToken != nil && !accessToken!.isEmpty
}
///
var hasValidSession: Bool {
return hasValidAuthentication && ticket != nil && !ticket!.isEmpty
}
/// IDLoginData AccountModel
/// - Parameter loginData:
/// - Returns: AccountModel nil
static func from(loginData: IDLoginData) -> AccountModel? {
// accessToken uid
guard let accessToken = loginData.accessToken,
let uid = loginData.uid else {
return nil
}
return AccountModel(
uid: String(uid),
jti: loginData.jti,
tokenType: loginData.tokenType,
refreshToken: loginData.refreshToken,
netEaseToken: loginData.netEaseToken,
accessToken: accessToken,
expiresIn: loginData.expiresIn,
scope: loginData.scope,
ticket: nil // oauth/ticket
)
}
/// ticket
/// - Parameter ticket: oauth/ticket
/// - Returns: AccountModel
func withTicket(_ ticket: String) -> AccountModel {
var updatedModel = self
updatedModel.ticket = ticket
return updatedModel
}
}
// MARK: - ID Login Request Model
struct IDLoginAPIRequest: APIRequestProtocol {
typealias Response = IDLoginResponse
@@ -234,3 +304,120 @@ struct TicketHelper {
// MARK: - LoginResponse
typealias LoginResponse = IDLoginResponse
// MARK: - Email Verification Code Models
///
struct EmailGetCodeRequest: APIRequestProtocol {
typealias Response = EmailGetCodeResponse
let endpoint = APIEndpoint.emailGetCode.path
let method: HTTPMethod = .POST
let includeBaseParameters = true
let queryParameters: [String: String]?
let bodyParameters: [String: Any]? = nil
let timeout: TimeInterval = 30.0
///
/// - Parameters:
/// - emailAddress: DES
/// - type: 1=/
init(emailAddress: String, type: Int = 1) {
self.queryParameters = [
"emailAddress": emailAddress,
"type": String(type)
]
}
}
///
struct EmailGetCodeResponse: Codable, Equatable {
let status: String?
let message: String?
let code: Int?
let data: String? //
///
var isSuccess: Bool {
return code == 200 || status?.lowercased() == "success"
}
///
var errorMessage: String {
return message ?? "验证码发送失败,请重试"
}
}
///
struct EmailLoginRequest: APIRequestProtocol {
typealias Response = IDLoginResponse // ID
let endpoint = APIEndpoint.login.path
let method: HTTPMethod = .POST
let includeBaseParameters = true
let queryParameters: [String: String]?
let bodyParameters: [String: Any]? = nil
let timeout: TimeInterval = 30.0
///
/// - Parameters:
/// - email: DES
/// - code:
/// - clientSecret: "uyzjdhds"
/// - version: "1"
/// - clientId: ID"erban-client"
/// - grantType: "email"
init(email: String, code: String, clientSecret: String = "uyzjdhds", version: String = "1", clientId: String = "erban-client", grantType: String = "email") {
self.queryParameters = [
"email": email,
"code": code,
"client_secret": clientSecret,
"version": version,
"client_id": clientId,
"grant_type": grantType
]
}
}
// MARK: - Email Login Helper
extension LoginHelper {
///
/// - Parameter email:
/// - Returns: APInil
static func createEmailGetCodeRequest(email: String) -> EmailGetCodeRequest? {
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else {
print("❌ 邮箱DES加密失败")
return nil
}
print("🔐 邮箱DES加密成功")
print(" 原始邮箱: \(email)")
print(" 加密邮箱: \(encryptedEmail)")
return EmailGetCodeRequest(emailAddress: email, type: 1)
}
///
/// - Parameters:
/// - email:
/// - code:
/// - Returns: APInil
static func createEmailLoginRequest(email: String, code: String) -> EmailLoginRequest? {
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else {
print("❌ 邮箱DES加密失败")
return nil
}
print("🔐 邮箱验证码登录DES加密成功")
print(" 原始邮箱: \(email)")
print(" 加密邮箱: \(encryptedEmail)")
print(" 验证码: \(code)")
return EmailLoginRequest(email: encryptedEmail, code: code)
}
}

View File

@@ -0,0 +1,434 @@
# 邮箱验证码登录流程文档
## 概述
本文档详细描述了 YuMi iOS 应用中 `LoginTypesViewController``LoginDisplayType_email` 模式下的邮箱验证码登录流程。该流程实现了基于邮箱和验证码的用户认证机制。
## 系统架构
### 核心组件
- **LoginTypesViewController**: 登录类型控制器,负责 UI 展示和用户交互
- **LoginPresenter**: 登录业务逻辑处理器,负责与 API 交互
- **LoginInputItemView**: 输入组件,提供邮箱和验证码输入界面
- **Api+Login**: 登录相关 API 接口封装
- **AccountInfoStorage**: 账户信息本地存储管理
### 数据模型
#### LoginDisplayType 枚举
```objc
typedef NS_ENUM(NSUInteger, LoginDisplayType) {
LoginDisplayType_id, // ID 登录
LoginDisplayType_email, // 邮箱登录 ✓
LoginDisplayType_phoneNum, // 手机号登录
LoginDisplayType_email_forgetPassword, // 邮箱忘记密码
LoginDisplayType_phoneNum_forgetPassword, // 手机号忘记密码
};
```
#### LoginInputType 枚举
```objc
typedef NS_ENUM(NSUInteger, LoginInputType) {
LoginInputType_email, // 邮箱输入
LoginInputType_verificationCode, // 验证码输入
LoginInputType_login, // 登录按钮
// ... 其他类型
};
```
#### GetSmsType 验证码类型
```objc
typedef NS_ENUM(NSUInteger, GetSmsType) {
GetSmsType_Regist = 1, // 注册(邮箱登录使用此类型)
GetSmsType_Login = 2, // 登录
GetSmsType_Reset_Password = 3, // 重设密码
// ... 其他类型
};
```
## 登录流程详解
### 1. 界面初始化流程
#### 1.1 控制器初始化
```objc
// 在 LoginViewController 中点击邮箱登录按钮
- (void)didTapEntrcyButton:(UIButton *)sender {
if (sender.tag == LoginType_Email) {
LoginTypesViewController *vc = [[LoginTypesViewController alloc] init];
[self.navigationController pushViewController:vc animated:YES];
[vc updateLoginType:LoginDisplayType_email]; // 设置为邮箱登录模式
}
}
```
#### 1.2 输入区域设置
```objc
- (void)setupEmailInputArea {
[self setupInpuArea:LoginInputType_email // 第一行:邮箱输入
second:LoginInputType_verificationCode // 第二行:验证码输入
third:LoginInputType_none // 第三行:无
action:LoginInputType_login // 操作按钮:登录
showForgetPassword:NO]; // 不显示忘记密码
}
```
#### 1.3 UI 组件配置
- **第一行输入框**: 邮箱地址输入
- 占位符: "请输入邮箱地址"
- 键盘类型: `UIKeyboardTypeEmailAddress`
- 回调: `handleFirstInputContentUpdate`
- **第二行输入框**: 验证码输入
- 占位符: "请输入验证码"
- 键盘类型: `UIKeyboardTypeDefault`
- 附带"获取验证码"按钮
- 回调: `handleSecondInputContentUpdate`
### 2. 验证码获取流程
#### 2.1 用户交互触发
```objc
// 用户点击"获取验证码"按钮
[self.secondLineInputView setHandleItemAction:^(LoginInputType inputType) {
if (inputType == LoginInputType_verificationCode) {
if (self.type == LoginDisplayType_email) {
[self handleTapGetMailVerificationCode];
}
}
}];
```
#### 2.2 邮箱验证码获取处理
```objc
- (void)handleTapGetMailVerificationCode {
NSString *email = [self.firstLineInputView inputContent];
// 邮箱地址验证
if (email.length == 0) {
[self.secondLineInputView endVerificationCountDown];
return;
}
// 调用 Presenter 发送验证码
[self.presenter sendMailVerificationCode:email type:GetSmsType_Regist];
}
```
#### 2.3 Presenter 层处理
```objc
- (void)sendMailVerificationCode:(NSString *)emailAddress type:(NSInteger)type {
// DES 加密邮箱地址
NSString *desEmail = [DESEncrypt encryptUseDES:emailAddress
key:KeyWithType(KeyType_PasswordEncode)];
@kWeakify(self);
[Api emailGetCode:[self createHttpCompletion:^(BaseModel *data) {
@kStrongify(self);
if ([[self getView] respondsToSelector:@selector(emailCodeSucess:type:)]) {
[[self getView] emailCodeSucess:@"" type:type];
}
} fail:^(NSInteger code, NSString *msg) {
@kStrongify(self);
if ([[self getView] respondsToSelector:@selector(emailCodeFailure)]) {
[[self getView] emailCodeFailure];
}
} showLoading:YES errorToast:YES]
emailAddress:desEmail
type:@(type)];
}
```
#### 2.4 API 接口调用
```objc
+ (void)emailGetCode:(HttpRequestHelperCompletion)completion
emailAddress:(NSString *)emailAddress
type:(NSNumber *)type {
[self makeRequest:@"email/getCode"
method:HttpRequestHelperMethodPOST
completion:completion, __FUNCTION__, emailAddress, type, nil];
}
```
**API 详情**:
- **接口路径**: `POST /email/getCode`
- **请求参数**:
- `emailAddress`: 邮箱地址DES 加密)
- `type`: 验证码类型1=注册)
#### 2.5 获取验证码成功处理
```objc
- (void)emailCodeSucess:(NSString *)message type:(GetSmsType)type {
[self showSuccessToast:YMLocalizedString(@"XPLoginPhoneViewController2")]; // "验证码已发送"
[self.secondLineInputView startVerificationCountDown]; // 开始倒计时
[self.secondLineInputView displayKeyboard]; // 显示键盘
}
```
#### 2.6 获取验证码失败处理
```objc
- (void)emailCodeFailure {
[self.secondLineInputView endVerificationCountDown]; // 结束倒计时
}
```
### 3. 邮箱登录流程
#### 3.1 登录按钮状态检查
```objc
- (void)checkActionButtonStatus {
switch (self.type) {
case LoginDisplayType_email: {
NSString *accountString = [self.firstLineInputView inputContent]; // 邮箱
NSString *codeString = [self.secondLineInputView inputContent]; // 验证码
// 只有当邮箱和验证码都不为空时才启用登录按钮
if (![NSString isEmpty:accountString] && ![NSString isEmpty:codeString]) {
self.bottomActionButton.enabled = YES;
} else {
self.bottomActionButton.enabled = NO;
}
}
break;
}
}
```
#### 3.2 登录按钮点击处理
```objc
- (void)didTapActionButton {
[self.view endEditing:true];
switch (self.type) {
case LoginDisplayType_email: {
// 调用 Presenter 进行邮箱登录
[self.presenter loginWithEmail:[self.firstLineInputView inputContent]
code:[self.secondLineInputView inputContent]];
}
break;
}
}
```
#### 3.3 Presenter 层登录处理
```objc
- (void)loginWithEmail:(NSString *)email code:(NSString *)code {
// DES 加密邮箱地址
NSString *desMail = [DESEncrypt encryptUseDES:email
key:KeyWithType(KeyType_PasswordEncode)];
@kWeakify(self);
[Api loginWithCode:[self createHttpCompletion:^(BaseModel *data) {
@kStrongify(self);
// 解析账户模型
AccountModel *accountModel = [AccountModel modelWithDictionary:data.data];
// 保存账户信息
if (accountModel && accountModel.access_token.length > 0) {
[[AccountInfoStorage instance] saveAccountInfo:accountModel];
}
// 通知登录成功
if ([[self getView] respondsToSelector:@selector(loginSuccess)]) {
[[self getView] loginSuccess];
}
} fail:^(NSInteger code, NSString *msg) {
@kStrongify(self);
[[self getView] loginFailWithMsg:msg];
} errorToast:NO]
email:desMail
code:code
client_secret:clinet_s // 客户端密钥
version:@"1"
client_id:@"erban-client"
grant_type:@"email"]; // 邮箱登录类型
}
```
#### 3.4 API 接口调用
```objc
+ (void)loginWithCode:(HttpRequestHelperCompletion)completion
email:(NSString *)email
code:(NSString *)code
client_secret:(NSString *)client_secret
version:(NSString *)version
client_id:(NSString *)client_id
grant_type:(NSString *)grant_type {
NSString *fang = [NSString stringFromBase64String:@"b2F1dGgvdG9rZW4="]; // oauth/token
[self makeRequest:fang
method:HttpRequestHelperMethodPOST
completion:completion, __FUNCTION__, email, code, client_secret,
version, client_id, grant_type, nil];
}
```
**API 详情**:
- **接口路径**: `POST /oauth/token`
- **请求参数**:
- `email`: 邮箱地址DES 加密)
- `code`: 验证码
- `client_secret`: 客户端密钥
- `version`: 版本号 "1"
- `client_id`: 客户端ID "erban-client"
- `grant_type`: 授权类型 "email"
#### 3.5 登录成功处理
```objc
- (void)loginSuccess {
[self showSuccessToast:YMLocalizedString(@"XPLoginPhoneViewController1")]; // "登录成功"
[PILoginManager loginWithVC:self isLoginPhone:NO]; // 执行登录后续处理
}
```
#### 3.6 登录失败处理
```objc
- (void)loginFailWithMsg:(NSString *)msg {
[self showSuccessToast:msg]; // 显示错误信息
}
```
## 数据流时序图
```mermaid
sequenceDiagram
participant User as 用户
participant VC as LoginTypesViewController
participant IV as LoginInputItemView
participant P as LoginPresenter
participant API as Api+Login
participant Storage as AccountInfoStorage
Note over User,Storage: 1. 初始化邮箱登录界面
User->>VC: 选择邮箱登录
VC->>VC: updateLoginType(LoginDisplayType_email)
VC->>VC: setupEmailInputArea()
VC->>IV: 创建邮箱输入框
VC->>IV: 创建验证码输入框
Note over User,Storage: 2. 获取邮箱验证码
User->>IV: 输入邮箱地址
User->>IV: 点击"获取验证码"
IV->>VC: handleTapGetMailVerificationCode
VC->>VC: 验证邮箱地址非空
VC->>P: sendMailVerificationCode(email, GetSmsType_Regist)
P->>P: DES加密邮箱地址
P->>API: emailGetCode(encryptedEmail, type=1)
API-->>P: 验证码发送结果
P-->>VC: emailCodeSucess / emailCodeFailure
VC->>IV: startVerificationCountDown / endVerificationCountDown
VC->>User: 显示成功/失败提示
Note over User,Storage: 3. 邮箱验证码登录
User->>IV: 输入验证码
IV->>VC: 输入内容变化回调
VC->>VC: checkActionButtonStatus()
VC->>User: 启用/禁用登录按钮
User->>VC: 点击登录按钮
VC->>VC: didTapActionButton()
VC->>P: loginWithEmail(email, code)
P->>P: DES加密邮箱地址
P->>API: loginWithCode(email, code, ...)
API-->>P: OAuth Token 响应
P->>P: 解析 AccountModel
P->>Storage: saveAccountInfo(accountModel)
P-->>VC: loginSuccess / loginFailWithMsg
VC->>User: 显示登录结果
VC->>User: 跳转到主界面
```
## 安全机制
### 1. 数据加密
- **邮箱地址加密**: 使用 DES 算法加密邮箱地址后传输
```objc
NSString *desEmail = [DESEncrypt encryptUseDES:email key:KeyWithType(KeyType_PasswordEncode)];
```
### 2. 输入验证
- **邮箱格式验证**: 通过 `UIKeyboardTypeEmailAddress` 键盘类型引导正确输入
- **非空验证**: 邮箱和验证码都必须非空才能执行登录
### 3. 验证码安全
- **时效性**: 验证码具有倒计时机制,防止重复获取
- **类型标识**: 使用 `GetSmsType_Regist = 1` 标识登录验证码
### 4. 网络安全
- **错误处理**: 完整的成功/失败回调机制
- **加载状态**: `showLoading:YES` 防止重复请求
- **错误提示**: `errorToast:YES` 显示网络错误
## 错误处理机制
### 1. 邮箱验证码获取错误
```objc
- (void)emailCodeFailure {
[self.secondLineInputView endVerificationCountDown]; // 停止倒计时
// 用户可以重新获取验证码
}
```
### 2. 登录失败处理
```objc
- (void)loginFailWithMsg:(NSString *)msg {
[self showSuccessToast:msg]; // 显示具体错误信息
// 用户可以重新尝试登录
}
```
### 3. 网络请求错误
- **自动重试**: 用户可以手动重新点击获取验证码或登录
- **错误提示**: 通过 Toast 显示具体错误信息
- **状态恢复**: 失败后恢复按钮可点击状态
## 本地化支持
### 关键文本资源
- `@"20.20.51_text_1"`: "邮箱登录"
- `@"20.20.51_text_4"`: "请输入邮箱地址"
- `@"20.20.51_text_7"`: "请输入验证码"
- `@"XPLoginPhoneViewController2"`: "验证码已发送"
- `@"XPLoginPhoneViewController1"`: "登录成功"
### 多语言支持
- 简体中文 (`zh-Hant.lproj`)
- 英文 (`en.lproj`)
- 阿拉伯语 (`ar.lproj`)
- 土耳其语 (`tr.lproj`)
## 依赖组件
### 外部框架
- **MASConstraintMaker**: 自动布局
- **ReactiveObjC**: 响应式编程(部分组件使用)
### 内部组件
- **YMLocalizedString**: 本地化字符串管理
- **DESEncrypt**: DES 加密工具
- **AccountInfoStorage**: 账户信息存储
- **HttpRequestHelper**: 网络请求管理
## 扩展和维护
### 新增功能建议
1. **邮箱格式验证**: 添加正则表达式验证邮箱格式
2. **验证码长度限制**: 限制验证码输入长度
3. **自动填充**: 支持系统邮箱自动填充
4. **记住邮箱**: 保存最近使用的邮箱地址
### 性能优化
1. **请求去重**: 防止短时间内重复请求验证码
2. **缓存机制**: 缓存验证码倒计时状态
3. **网络优化**: 添加请求超时和重试机制
### 代码维护
1. **常量管理**: 将硬编码字符串提取为常量
2. **错误码统一**: 统一管理API错误码
3. **日志记录**: 添加详细的操作日志
## 总结
邮箱验证码登录流程是一个完整的用户认证系统,包含了界面展示、验证码获取、用户登录、数据存储等完整环节。该流程具有良好的安全性、用户体验和错误处理机制,符合现代移动应用的认证标准。
通过本文档,开发人员可以全面了解邮箱登录的实现细节,便于后续的功能扩展和维护工作。

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -10,32 +10,29 @@ struct EMailLoginFeature {
var isLoading: Bool = false
var isCodeLoading: Bool = false
var errorMessage: String? = nil
var codeCountdown: Int = 0
var isCodeButtonEnabled: Bool = true
var isCodeSent: Bool = false
// Debug
#if DEBUG
init() {
self.email = "85494536@gmail.com"
self.email = "exzero@126.com"
self.verificationCode = ""
}
#endif
}
enum Action: Equatable {
enum Action {
case emailChanged(String)
case verificationCodeChanged(String)
case getVerificationCodeTapped
case getCodeResponse(Result<EmailGetCodeResponse, Error>)
case loginButtonTapped(email: String, verificationCode: String)
case loginResponse(Result<AccountModel, Error>)
case forgotPasswordTapped
case codeCountdownTick
case setLoading(Bool)
case setCodeLoading(Bool)
case setError(String?)
case startCodeCountdown
case resetCodeCountdown
case resetState
}
@Dependency(\.apiService) var apiService
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
@@ -55,28 +52,57 @@ struct EMailLoginFeature {
return .none
}
guard isValidEmail(state.email) else {
guard ValidationHelper.isValidEmail(state.email) else {
state.errorMessage = "email_login.invalid_email".localized
return .none
}
state.isCodeLoading = true
state.isCodeSent = false //
state.errorMessage = nil
return .run { send in
// API
try await Task.sleep(nanoseconds: 1_000_000_000) // 1
await send(.setCodeLoading(false))
await send(.startCodeCountdown)
return .run { [email = state.email] send in
do {
guard let request = LoginHelper.createEmailGetCodeRequest(email: email) else {
await send(.getCodeResponse(.failure(APIError.encryptionFailed)))
return
}
let response = try await apiService.request(request)
await send(.getCodeResponse(.success(response)))
} catch {
await send(.getCodeResponse(.failure(error)))
}
}
case .getCodeResponse(.success(let response)):
state.isCodeLoading = false
if response.isSuccess {
state.isCodeSent = true
return .none
} else {
state.errorMessage = response.errorMessage
return .none
}
case .getCodeResponse(.failure(let error)):
state.isCodeLoading = false
if let apiError = error as? APIError {
state.errorMessage = apiError.localizedDescription
} else {
state.errorMessage = "验证码发送失败,请检查网络连接"
}
return .none
case .loginButtonTapped(let email, let verificationCode):
guard !email.isEmpty && !verificationCode.isEmpty else {
state.errorMessage = "email_login.fields_required".localized
return .none
}
guard isValidEmail(email) else {
guard ValidationHelper.isValidEmail(email) else {
state.errorMessage = "email_login.invalid_email".localized
return .none
}
@@ -85,61 +111,78 @@ struct EMailLoginFeature {
state.errorMessage = nil
return .run { send in
// API
try await Task.sleep(nanoseconds: 2_000_000_000) // 2
await send(.setLoading(false))
//
print("🔐 邮箱登录尝试: \(email), 验证码: \(verificationCode)")
do {
guard let request = LoginHelper.createEmailLoginRequest(email: email, code: verificationCode) else {
await send(.loginResponse(.failure(APIError.encryptionFailed)))
return
}
let response = try await apiService.request(request)
if response.isSuccess, let loginData = response.data {
guard let accountModel = AccountModel.from(loginData: loginData) else {
await send(.loginResponse(.failure(APIError.invalidResponse)))
return
}
// Ticket
let ticketRequest = TicketHelper.createTicketRequest(
accessToken: accountModel.accessToken ?? "",
uid: accountModel.uid.flatMap { Int($0) }
)
let ticketResponse = try await apiService.request(ticketRequest)
if ticketResponse.isSuccess, let ticket = ticketResponse.ticket {
let completeAccount = accountModel.withTicket(ticket)
await send(.loginResponse(.success(completeAccount)))
} else {
await send(.loginResponse(.failure(APIError.ticketFailed)))
}
} else {
await send(.loginResponse(.failure(APIError.custom(response.errorMessage))))
}
} catch {
await send(.loginResponse(.failure(error)))
}
}
case .loginResponse(.success(let accountModel)):
state.isLoading = false
// AccountModel
UserInfoManager.saveAccountModel(accountModel)
//
NotificationCenter.default.post(name: .ticketSuccess, object: nil)
return .none
case .loginResponse(.failure(let error)):
state.isLoading = false
if let apiError = error as? APIError {
state.errorMessage = apiError.localizedDescription
} else {
state.errorMessage = "登录失败,请重试"
}
return .none
case .forgotPasswordTapped:
//
print("📧 忘记密码点击")
return .none
case .codeCountdownTick:
if state.codeCountdown > 0 {
state.codeCountdown -= 1
state.isCodeButtonEnabled = false
return .run { send in
try await Task.sleep(nanoseconds: 1_000_000_000) // 1
await send(.codeCountdownTick)
}
} else {
state.isCodeButtonEnabled = true
return .none
}
case .setLoading(let isLoading):
state.isLoading = isLoading
return .none
case .setCodeLoading(let isLoading):
state.isCodeLoading = isLoading
return .none
case .setError(let error):
state.errorMessage = error
return .none
case .startCodeCountdown:
state.codeCountdown = 60
state.isCodeButtonEnabled = false
return .send(.codeCountdownTick)
case .resetCodeCountdown:
state.codeCountdown = 0
state.isCodeButtonEnabled = true
case .resetState:
state.email = ""
state.verificationCode = ""
state.isLoading = false
state.isCodeLoading = false
state.errorMessage = nil
state.isCodeSent = false
return .none
}
}
}
// MARK: - Helper Methods
private func isValidEmail(_ email: String) -> Bool {
let emailRegex = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"
let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex)
return emailPredicate.evaluate(with: email)
}
}
}

View File

@@ -0,0 +1,70 @@
import Foundation
import ComposableArchitecture
@Reducer
struct HomeFeature {
@ObservableState
struct State: Equatable {
var isInitialized = false
var userInfo: UserInfo?
var accountModel: AccountModel?
var error: String?
}
enum Action: Equatable {
case onAppear
case loadUserInfo
case userInfoLoaded(UserInfo?)
case loadAccountModel
case accountModelLoaded(AccountModel?)
case logoutTapped
case logout
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
state.isInitialized = true
return .concatenate(
.send(.loadUserInfo),
.send(.loadAccountModel)
)
case .loadUserInfo:
//
let userInfo = UserInfoManager.getUserInfo()
return .send(.userInfoLoaded(userInfo))
case let .userInfoLoaded(userInfo):
state.userInfo = userInfo
return .none
case .loadAccountModel:
//
let accountModel = UserInfoManager.getAccountModel()
return .send(.accountModelLoaded(accountModel))
case let .accountModelLoaded(accountModel):
state.accountModel = accountModel
return .none
case .logoutTapped:
return .send(.logout)
case .logout:
//
UserInfoManager.clearAllAuthenticationData()
//
NotificationCenter.default.post(name: .homeLogout, object: nil)
return .none
}
}
}
}
// MARK: - Notification Extension
extension Notification.Name {
static let homeLogout = Notification.Name("homeLogout")
}

View File

@@ -11,13 +11,11 @@ struct IDLoginFeature {
var isLoading = false
var errorMessage: String?
// Ticket
var accessToken: String?
var ticket: String?
// Account Model Ticket
var accountModel: AccountModel?
var isTicketLoading = false
var ticketError: String?
var loginStep: LoginStep = .initial
var uid: Int? // uidInt
enum LoginStep: Equatable {
case initial //
@@ -101,22 +99,26 @@ struct IDLoginFeature {
if response.isSuccess {
// OAuth
state.errorMessage = nil
state.accessToken = response.data?.accessToken
state.uid = response.data?.uid // uid
//
if let userInfo = response.data?.userInfo {
UserInfoManager.saveUserInfo(userInfo)
}
print("✅ ID 登录 OAuth 认证成功")
if let accessToken = response.data?.accessToken {
print("🔑 Access Token: \(accessToken)")
// ticket uid
return .send(.requestTicket(accessToken: accessToken))
}
if let uid = response.data?.uid {
print("🆔 用户 UID: \(uid)")
// AccountModel
if let loginData = response.data,
let accountModel = AccountModel.from(loginData: loginData) {
state.accountModel = accountModel
//
if let userInfo = loginData.userInfo {
UserInfoManager.saveUserInfo(userInfo)
}
print("✅ ID 登录 OAuth 认证成功")
print("🔑 Access Token: \(accountModel.accessToken ?? "nil")")
print("🆔 用户 UID: \(accountModel.uid ?? "nil")")
// ticket
return .send(.requestTicket(accessToken: accountModel.accessToken!))
} else {
state.errorMessage = "登录数据格式错误"
state.loginStep = .failed
}
} else {
state.errorMessage = response.errorMessage
@@ -135,9 +137,10 @@ struct IDLoginFeature {
state.ticketError = nil
state.loginStep = .gettingTicket
return .run { [uid = state.uid] send in
return .run { [accountModel = state.accountModel] send in
do {
// 使 TicketHelper uid
// AccountModel uid Int
let uid = accountModel?.uid != nil ? Int(accountModel!.uid!) : nil
let ticketRequest = TicketHelper.createTicketRequest(accessToken: accessToken, uid: uid)
let response = try await apiService.request(ticketRequest)
await send(.ticketResponse(.success(response)))
@@ -151,27 +154,32 @@ struct IDLoginFeature {
state.isTicketLoading = false
if response.isSuccess {
state.ticketError = nil
state.ticket = response.ticket
state.loginStep = .completed
print("✅ ID 登录完整流程成功")
print("🎫 Ticket 获取成功: \(response.ticket ?? "nil")")
//
if let accessToken = state.accessToken,
let ticket = response.ticket {
//
let userInfo = UserInfoManager.getUserInfo()
UserInfoManager.saveCompleteAuthenticationData(
accessToken: accessToken,
ticket: ticket,
uid: state.uid,
userInfo: userInfo
)
// AccountModel ticket
if let ticket = response.ticket {
if var accountModel = state.accountModel {
accountModel.ticket = ticket
state.accountModel = accountModel
// AccountModel
UserInfoManager.saveAccountModel(accountModel)
// Ticket
NotificationCenter.default.post(name: .ticketSuccess, object: nil)
} else {
print("❌ AccountModel 不存在,无法保存 ticket")
state.ticketError = "内部错误:账户信息丢失"
state.loginStep = .failed
}
} else {
state.ticketError = "Ticket 为空"
state.loginStep = .failed
}
// TODO:
} else {
state.ticketError = response.errorMessage
state.loginStep = .failed
@@ -194,9 +202,7 @@ struct IDLoginFeature {
state.isTicketLoading = false
state.errorMessage = nil
state.ticketError = nil
state.accessToken = nil
state.ticket = nil
state.uid = nil // uid
state.accountModel = nil // AccountModel
state.loginStep = .initial
//

View File

@@ -10,14 +10,13 @@ struct LoginFeature {
var isLoading = false
var error: String?
var idLoginState = IDLoginFeature.State()
var emailLoginState = EMailLoginFeature.State() //
// Ticket
var accessToken: String?
var ticket: String?
// Account Model Ticket
var accountModel: AccountModel?
var isTicketLoading = false
var ticketError: String?
var loginStep: LoginStep = .initial
var uid: Int? // uidInt
enum LoginStep: Equatable {
case initial //
@@ -36,12 +35,13 @@ struct LoginFeature {
#endif
}
enum Action: Equatable {
enum Action {
case updateAccount(String)
case updatePassword(String)
case login
case loginResponse(TaskResult<IDLoginResponse>)
case idLogin(IDLoginFeature.Action)
case emailLogin(EMailLoginFeature.Action) // action
// Ticket actions
case requestTicket(accessToken: String)
@@ -57,6 +57,10 @@ struct LoginFeature {
IDLoginFeature()
}
Scope(state: \.emailLoginState, action: \.emailLogin) {
EMailLoginFeature()
}
Reduce { state, action in
switch action {
case let .updateAccount(account):
@@ -99,20 +103,21 @@ struct LoginFeature {
if response.isSuccess {
// OAuth
state.error = nil
state.accessToken = response.data?.accessToken
state.uid = response.data?.uid // uid
print("✅ OAuth 认证成功")
if let accessToken = response.data?.accessToken {
print("🔑 Access Token: \(accessToken)")
// ticket uid
return .send(.requestTicket(accessToken: accessToken))
}
if let userInfo = response.data?.userInfo {
print("👤 用户信息: \(userInfo)")
}
if let uid = response.data?.uid {
print("🆔 用户 UID: \(uid)")
// AccountModel
if let loginData = response.data,
let accountModel = AccountModel.from(loginData: loginData) {
state.accountModel = accountModel
print("✅ OAuth 认证成功")
print("🔑 Access Token: \(accountModel.accessToken ?? "nil")")
print("🆔 用户 UID: \(accountModel.uid ?? "nil")")
// ticket
return .send(.requestTicket(accessToken: accountModel.accessToken!))
} else {
state.error = "登录数据格式错误"
state.loginStep = .failed
}
} else {
state.error = response.errorMessage
@@ -131,9 +136,10 @@ struct LoginFeature {
state.ticketError = nil
state.loginStep = .gettingTicket
return .run { [uid = state.uid] send in
return .run { [accountModel = state.accountModel] send in
do {
// 使 TicketHelper uid
// AccountModel uid Int
let uid = accountModel?.uid != nil ? Int(accountModel!.uid!) : nil
let ticketRequest = TicketHelper.createTicketRequest(accessToken: accessToken, uid: uid)
let response = try await apiService.request(ticketRequest)
await send(.ticketResponse(.success(response)))
@@ -147,25 +153,32 @@ struct LoginFeature {
state.isTicketLoading = false
if response.isSuccess {
state.ticketError = nil
state.ticket = response.ticket
state.loginStep = .completed
print("✅ 完整登录流程成功")
print("🎫 Ticket 获取成功: \(response.ticket ?? "nil")")
//
if let accessToken = state.accessToken,
let ticket = response.ticket {
UserInfoManager.saveCompleteAuthenticationData(
accessToken: accessToken,
ticket: ticket,
uid: state.uid,
userInfo: nil // LoginFeature
)
// AccountModel ticket
if let ticket = response.ticket {
if var accountModel = state.accountModel {
accountModel.ticket = ticket
state.accountModel = accountModel
// AccountModel
UserInfoManager.saveAccountModel(accountModel)
// Ticket
NotificationCenter.default.post(name: .ticketSuccess, object: nil)
} else {
print("❌ AccountModel 不存在,无法保存 ticket")
state.ticketError = "内部错误:账户信息丢失"
state.loginStep = .failed
}
} else {
state.ticketError = "Ticket 为空"
state.loginStep = .failed
}
// TODO:
} else {
state.ticketError = response.errorMessage
state.loginStep = .failed
@@ -188,9 +201,7 @@ struct LoginFeature {
state.isTicketLoading = false
state.error = nil
state.ticketError = nil
state.accessToken = nil
state.ticket = nil
state.uid = nil // uid
state.accountModel = nil // AccountModel
state.loginStep = .initial
//
@@ -201,6 +212,10 @@ struct LoginFeature {
case .idLogin:
// IDLoginfeature
return .none
case .emailLogin:
// EmailLoginfeature
return .none
}
}
}

View File

@@ -0,0 +1,274 @@
import Foundation
import ComposableArchitecture
@Reducer
struct RecoverPasswordFeature {
@ObservableState
struct State: Equatable {
var email: String = ""
var verificationCode: String = ""
var newPassword: String = ""
var isCodeLoading: Bool = false
var isResetLoading: Bool = false
var errorMessage: String? = nil
var isCodeSent: Bool = false
#if DEBUG
init() {
self.email = "exzero@126.com"
self.verificationCode = ""
self.newPassword = ""
}
#endif
}
enum Action {
case emailChanged(String)
case verificationCodeChanged(String)
case newPasswordChanged(String)
case getVerificationCodeTapped
case getCodeResponse(Result<EmailGetCodeResponse, Error>)
case resetPasswordTapped
case resetPasswordResponse(Result<ResetPasswordResponse, Error>)
case resetState
}
@Dependency(\.apiService) var apiService
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .emailChanged(let email):
state.email = email
state.errorMessage = nil
return .none
case .verificationCodeChanged(let code):
state.verificationCode = code
state.errorMessage = nil
return .none
case .newPasswordChanged(let password):
state.newPassword = password
state.errorMessage = nil
return .none
case .getVerificationCodeTapped:
guard !state.email.isEmpty else {
state.errorMessage = "recover_password.email_required".localized
return .none
}
guard ValidationHelper.isValidEmail(state.email) else {
state.errorMessage = "recover_password.invalid_email".localized
return .none
}
state.isCodeLoading = true
state.isCodeSent = false
state.errorMessage = nil
return .run { [email = state.email] send in
do {
guard let request = RecoverPasswordHelper.createEmailGetCodeRequest(email: email) else {
await send(.getCodeResponse(.failure(APIError.encryptionFailed)))
return
}
let response = try await apiService.request(request)
await send(.getCodeResponse(.success(response)))
} catch {
await send(.getCodeResponse(.failure(error)))
}
}
case .getCodeResponse(.success(let response)):
state.isCodeLoading = false
if response.isSuccess {
state.isCodeSent = true
return .none
} else {
state.errorMessage = response.errorMessage
return .none
}
case .getCodeResponse(.failure(let error)):
state.isCodeLoading = false
if let apiError = error as? APIError {
state.errorMessage = apiError.localizedDescription
} else {
state.errorMessage = "recover_password.code_send_failed".localized
}
return .none
case .resetPasswordTapped:
guard !state.email.isEmpty && !state.verificationCode.isEmpty && !state.newPassword.isEmpty else {
state.errorMessage = "recover_password.fields_required".localized
return .none
}
guard ValidationHelper.isValidEmail(state.email) else {
state.errorMessage = "recover_password.invalid_email".localized
return .none
}
guard ValidationHelper.isValidPassword(state.newPassword) else {
state.errorMessage = "recover_password.invalid_password".localized
return .none
}
state.isResetLoading = true
state.errorMessage = nil
return .run { [email = state.email, code = state.verificationCode, password = state.newPassword] send in
do {
guard let request = RecoverPasswordHelper.createResetPasswordRequest(
email: email,
code: code,
newPassword: password
) else {
await send(.resetPasswordResponse(.failure(APIError.encryptionFailed)))
return
}
let response = try await apiService.request(request)
await send(.resetPasswordResponse(.success(response)))
} catch {
await send(.resetPasswordResponse(.failure(error)))
}
}
case .resetPasswordResponse(.success(let response)):
state.isResetLoading = false
if response.isSuccess {
//
state.errorMessage = nil
return .none
} else {
state.errorMessage = response.errorMessage
return .none
}
case .resetPasswordResponse(.failure(let error)):
state.isResetLoading = false
if let apiError = error as? APIError {
state.errorMessage = apiError.localizedDescription
} else {
state.errorMessage = "recover_password.reset_failed".localized
}
return .none
case .resetState:
state.email = ""
state.verificationCode = ""
state.newPassword = ""
state.isCodeLoading = false
state.isResetLoading = false
state.errorMessage = nil
state.isCodeSent = false
return .none
}
}
}
}
// MARK: - Password Reset API Models
///
struct ResetPasswordResponse: Codable, Equatable {
let status: String?
let message: String?
let code: Int?
let data: String?
///
var isSuccess: Bool {
return code == 200 || status?.lowercased() == "success"
}
///
var errorMessage: String {
return message ?? "recover_password.reset_failed".localized
}
}
///
struct ResetPasswordRequest: APIRequestProtocol {
typealias Response = ResetPasswordResponse
let endpoint = "/password/reset" //
let method: HTTPMethod = .POST
let includeBaseParameters = true
let queryParameters: [String: String]?
let bodyParameters: [String: Any]? = nil
let timeout: TimeInterval = 30.0
///
/// - Parameters:
/// - email: DES
/// - code:
/// - newPassword: DES
init(email: String, code: String, newPassword: String) {
self.queryParameters = [
"email": email,
"code": code,
"newPassword": newPassword
]
}
}
// MARK: - Recover Password Helper
struct RecoverPasswordHelper {
///
/// - Parameter email:
/// - Returns: APInil
static func createEmailGetCodeRequest(email: String) -> EmailGetCodeRequest? {
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else {
print("❌ 邮箱DES加密失败")
return nil
}
print("🔐 密码恢复邮箱DES加密成功")
print(" 原始邮箱: \(email)")
print(" 加密邮箱: \(encryptedEmail)")
// 使type=3
return EmailGetCodeRequest(emailAddress: email, type: 3)
}
///
/// - Parameters:
/// - email:
/// - code:
/// - newPassword:
/// - Returns: APInil
static func createResetPasswordRequest(email: String, code: String, newPassword: String) -> ResetPasswordRequest? {
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey),
let encryptedPassword = DESEncrypt.encryptUseDES(newPassword, key: encryptionKey) else {
print("❌ 密码重置DES加密失败")
return nil
}
print("🔐 密码重置DES加密成功")
print(" 原始邮箱: \(email)")
print(" 加密邮箱: \(encryptedEmail)")
print(" 验证码: \(code)")
print(" 原始新密码: \(newPassword)")
print(" 加密新密码: \(encryptedPassword)")
return ResetPasswordRequest(
email: encryptedEmail,
code: code,
newPassword: encryptedPassword
)
}
}

View File

@@ -56,4 +56,23 @@
"validation.id_required" = "Please enter your ID";
"validation.password_required" = "Please enter your password";
"error.encryption_failed" = "Encryption failed, please try again";
"error.login_failed" = "Login failed, please check your credentials";
"error.login_failed" = "Login failed, please check your credentials";
// MARK: - 密码恢复页面
"recover_password.title" = "Recover Password";
"recover_password.placeholder_email" = "Please enter email";
"recover_password.placeholder_verification_code" = "Please enter verification code";
"recover_password.placeholder_new_password" = "6-16 Digits + English Letters";
"recover_password.get_code" = "Get";
"recover_password.confirm_button" = "Confirm";
"recover_password.email_required" = "Please enter email";
"recover_password.invalid_email" = "Please enter a valid email address";
"recover_password.fields_required" = "Please fill in all fields";
"recover_password.invalid_password" = "Password must be 6-16 characters with digits and letters";
"recover_password.code_send_failed" = "Failed to send verification code";
"recover_password.reset_failed" = "Failed to reset password";
"recover_password.reset_success" = "Password reset successfully";
"recover_password.resetting" = "Resetting...";
// MARK: - 主页
"home.title" = "Enjoy your Life Time";

View File

@@ -56,4 +56,23 @@
"validation.id_required" = "请输入您的ID";
"validation.password_required" = "请输入您的密码";
"error.encryption_failed" = "加密失败,请重试";
"error.login_failed" = "登录失败,请检查您的凭据";
"error.login_failed" = "登录失败,请检查您的凭据";
// MARK: - 密码恢复页面
"recover_password.title" = "找回密码";
"recover_password.placeholder_email" = "请输入邮箱";
"recover_password.placeholder_verification_code" = "请输入验证码";
"recover_password.placeholder_new_password" = "6-16位数字+英文字母";
"recover_password.get_code" = "获取";
"recover_password.confirm_button" = "确认";
"recover_password.email_required" = "请输入邮箱";
"recover_password.invalid_email" = "请输入有效的邮箱地址";
"recover_password.fields_required" = "请填写所有字段";
"recover_password.invalid_password" = "密码必须是6-16位数字和字母";
"recover_password.code_send_failed" = "验证码发送失败";
"recover_password.reset_failed" = "密码重置失败";
"recover_password.reset_success" = "密码重置成功";
"recover_password.resetting" = "重置中...";
// MARK: - 主页
"home.title" = "享受您的生活时光";

View File

@@ -0,0 +1,41 @@
import Foundation
struct ValidationHelper {
///
/// - Parameter email:
/// - Returns:
static func isValidEmail(_ email: String) -> Bool {
let emailRegex = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"
let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex)
return emailPredicate.evaluate(with: email)
}
///
/// - Parameter phoneNumber:
/// - Returns:
static func isValidPhoneNumber(_ phoneNumber: String) -> Bool {
let phoneRegex = "^1[3-9]\\d{9}$"
let phonePredicate = NSPredicate(format: "SELF MATCHES %@", phoneRegex)
return phonePredicate.evaluate(with: phoneNumber)
}
///
/// - Parameter password:
/// - Returns: 6-16
static func isValidPassword(_ password: String) -> Bool {
guard password.count >= 6 && password.count <= 16 else { return false }
let hasLetter = password.rangeOfCharacter(from: .letters) != nil
let hasNumber = password.rangeOfCharacter(from: .decimalDigits) != nil
return hasLetter && hasNumber
}
///
/// - Parameter code:
/// - Returns: 4-6
static func isValidVerificationCode(_ code: String) -> Bool {
let codeRegex = "^\\d{4,6}$"
let codePredicate = NSPredicate(format: "SELF MATCHES %@", codeRegex)
return codePredicate.evaluate(with: code)
}
}

View File

@@ -3,6 +3,7 @@ import ComposableArchitecture
struct AppRootView: View {
@State private var shouldShowMainApp = false
@State private var shouldShowHomePage = false
let splashStore = Store(
initialState: SplashFeature.State()
@@ -16,9 +17,19 @@ struct AppRootView: View {
LoginFeature()
}
let homeStore = Store(
initialState: HomeFeature.State()
) {
HomeFeature()
}
var body: some View {
Group {
if shouldShowMainApp {
if shouldShowHomePage {
//
HomeView(store: homeStore)
.transition(.opacity.animation(.easeInOut(duration: 0.5)))
} else if shouldShowMainApp {
//
LoginView(store: loginStore)
.transition(.opacity.animation(.easeInOut(duration: 0.5)))
@@ -31,11 +42,25 @@ struct AppRootView: View {
}
}
}
.onReceive(NotificationCenter.default.publisher(for: .ticketSuccess)) { _ in
// Ticket
withAnimation(.easeInOut(duration: 0.5)) {
shouldShowHomePage = true
}
}
.onReceive(NotificationCenter.default.publisher(for: .homeLogout)) { _ in
//
withAnimation(.easeInOut(duration: 0.5)) {
shouldShowHomePage = false
shouldShowMainApp = true
}
}
}
}
extension Notification.Name {
static let splashFinished = Notification.Name("splashFinished")
static let ticketSuccess = Notification.Name("ticketSuccess")
}
#Preview {

View File

@@ -8,6 +8,16 @@ struct EMailLoginView: View {
// 使@StateUI
@State private var email: String = ""
@State private var verificationCode: String = ""
@State private var codeCountdown: Int = 0
@State private var timer: Timer?
//
@FocusState private var focusedField: Field?
enum Field {
case email
case verificationCode
}
//
private var isLoginButtonEnabled: Bool {
@@ -18,17 +28,22 @@ struct EMailLoginView: View {
private var getCodeButtonText: String {
if store.isCodeLoading {
return ""
} else if store.codeCountdown > 0 {
return "\(store.codeCountdown)S"
} else if codeCountdown > 0 {
return "\(codeCountdown)S"
} else {
return "email_login.get_code".localized
}
}
//
private var isCodeButtonEnabled: Bool {
return !store.isCodeLoading && codeCountdown == 0
}
var body: some View {
GeometryReader { geometry in
ZStack {
// - 使"bg"
//
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
@@ -83,6 +98,7 @@ struct EMailLoginView: View {
.keyboardType(.emailAddress)
.autocapitalization(.none)
.disableAutocorrection(true)
.focused($focusedField, equals: .email)
}
//
@@ -104,9 +120,13 @@ struct EMailLoginView: View {
.foregroundColor(.white)
.font(.system(size: 16))
.keyboardType(.numberPad)
.focused($focusedField, equals: .verificationCode)
//
Button(action: {
//
startCountdown()
// API
store.send(.getVerificationCodeTapped)
}) {
ZStack {
@@ -123,10 +143,10 @@ struct EMailLoginView: View {
.frame(width: 60, height: 36)
.background(
RoundedRectangle(cornerRadius: 18)
.fill(Color.white.opacity(store.isCodeButtonEnabled && !email.isEmpty ? 0.2 : 0.1))
.fill(Color.white.opacity(isCodeButtonEnabled && !email.isEmpty ? 0.2 : 0.1))
)
}
.disabled(!store.isCodeButtonEnabled || email.isEmpty || store.isCodeLoading)
.disabled(!isCodeButtonEnabled || email.isEmpty || store.isCodeLoading)
}
.padding(.horizontal, 24)
}
@@ -138,7 +158,6 @@ struct EMailLoginView: View {
//
Button(action: {
// action
store.send(.loginButtonTapped(email: email, verificationCode: verificationCode))
}) {
ZStack {
@@ -184,21 +203,59 @@ struct EMailLoginView: View {
}
}
.onAppear {
// TCA
email = store.email
verificationCode = store.verificationCode
//
store.send(.resetState)
email = ""
verificationCode = ""
codeCountdown = 0
stopCountdown()
#if DEBUG
// Debug
if email.isEmpty {
email = "85494536@gmail.com"
}
if verificationCode.isEmpty {
verificationCode = "784544"
}
print("🐛 Debug模式: 默认邮箱=\(email), 默认验证码=\(verificationCode)")
email = "exzero@126.com"
store.send(.emailChanged(email))
#endif
}
.onDisappear {
stopCountdown()
}
.onChange(of: email) { newEmail in
store.send(.emailChanged(newEmail))
}
.onChange(of: verificationCode) { newCode in
store.send(.verificationCodeChanged(newCode))
}
.onChange(of: store.isCodeLoading) { isCodeLoading in
// API
if !isCodeLoading && store.errorMessage == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
focusedField = .verificationCode
}
}
}
}
// MARK: -
private func startCountdown() {
stopCountdown()
//
codeCountdown = 60
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
DispatchQueue.main.async {
if codeCountdown > 0 {
codeCountdown -= 1
} else {
stopCountdown()
}
}
}
}
private func stopCountdown() {
timer?.invalidate()
timer = nil
}
}
@@ -211,4 +268,4 @@ struct EMailLoginView: View {
},
onBack: {}
)
}
}

124
yana/Views/HomeView.swift Normal file
View File

@@ -0,0 +1,124 @@
import SwiftUI
import ComposableArchitecture
struct HomeView: View {
let store: StoreOf<HomeFeature>
@ObservedObject private var localizationManager = LocalizationManager.shared
var body: some View {
WithPerceptionTracking {
GeometryReader { geometry in
ZStack {
// - 使"bg"
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
VStack(spacing: 0) {
// Navigation Bar
Text("home.title".localized)
.font(.custom("PingFang SC-Semibold", size: 16))
.foregroundColor(.white)
.frame(
width: 158,
height: 22,
alignment: .center
) //
.padding(.top, 8)
.padding(.horizontal)
//
VStack(spacing: 32) {
Spacer()
//
VStack(spacing: 16) {
// UserInfo
if let userInfo = store.userInfo, let userName = userInfo.username {
Text("欢迎, \(userName)")
.font(.title2)
.foregroundColor(.white)
} else {
Text("欢迎")
.font(.title2)
.foregroundColor(.white)
}
// ID UserInfo AccountModel
if let userInfo = store.userInfo, let userId = userInfo.userId {
Text("ID: \(userId)")
.font(.caption)
.foregroundColor(.white.opacity(0.8))
} else if let accountModel = store.accountModel, let uid = accountModel.uid {
Text("UID: \(uid)")
.font(.caption)
.foregroundColor(.white.opacity(0.8))
}
// AccountModel
if let accountModel = store.accountModel {
VStack(spacing: 4) {
if accountModel.hasValidSession {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("已登录")
.foregroundColor(.white.opacity(0.9))
}
.font(.caption)
} else if accountModel.hasValidAuthentication {
HStack {
Image(systemName: "clock.circle.fill")
.foregroundColor(.orange)
Text("认证中")
.foregroundColor(.white.opacity(0.9))
}
.font(.caption)
}
}
}
}
.padding()
.background(Color.black.opacity(0.3))
.cornerRadius(12)
.padding(.horizontal, 32)
Spacer()
//
Button(action: {
store.send(.logoutTapped)
}) {
HStack {
Image(systemName: "arrow.right.square")
Text("退出登录")
}
.font(.body)
.foregroundColor(.white)
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(Color.red.opacity(0.7))
.cornerRadius(8)
}
.padding(.bottom, 50)
}
}
}
}
.onAppear {
store.send(.onAppear)
}
}
}
}
#Preview {
HomeView(
store: Store(
initialState: HomeFeature.State()
) {
HomeFeature()
}
)
}

View File

@@ -9,6 +9,7 @@ struct IDLoginView: View {
@State private var userID: String = ""
@State private var password: String = ""
@State private var isPasswordVisible: Bool = false
@State private var showRecoverPassword: Bool = false
//
private var isLoginButtonEnabled: Bool {
@@ -119,7 +120,7 @@ struct IDLoginView: View {
HStack {
Spacer()
Button(action: {
store.send(.forgotPasswordTapped)
showRecoverPassword = true
}) {
Text("id_login.forgot_password".localized)
.font(.system(size: 14))
@@ -177,6 +178,25 @@ struct IDLoginView: View {
Spacer()
}
// NavigationLink -
NavigationLink(
destination: RecoverPasswordView(
store: Store(
initialState: RecoverPasswordFeature.State()
) {
RecoverPasswordFeature()
},
onBack: {
showRecoverPassword = false
}
)
.navigationBarHidden(true),
isActive: $showRecoverPassword
) {
EmptyView()
}
.hidden()
}
}
.onAppear {

View File

@@ -18,6 +18,7 @@ struct LoginView: View {
@State private var showUserAgreement = false
@State private var showPrivacyPolicy = false
@State private var showIDLogin = false // 使SwiftUI@State
@State private var showEmailLogin = false //
var body: some View {
NavigationView {
@@ -87,7 +88,7 @@ struct LoginView: View {
iconColor: .blue,
title: "login.email_login".localized
) {
// TODO: Email
showEmailLogin = true //
}
}.padding(.top, max(0, topImageHeight+140))
}
@@ -130,6 +131,24 @@ struct LoginView: View {
EmptyView()
}
.hidden()
// NavigationLink
NavigationLink(
destination: EMailLoginView(
store: store.scope(
state: \.emailLoginState,
action: \.emailLogin
),
onBack: {
showEmailLogin = false // SwiftUI
}
)
.navigationBarHidden(true),
isActive: $showEmailLogin // 使SwiftUI
) {
EmptyView()
}
.hidden()
}
}
.navigationBarHidden(true)

View File

@@ -0,0 +1,303 @@
import SwiftUI
import ComposableArchitecture
struct RecoverPasswordView: View {
let store: StoreOf<RecoverPasswordFeature>
let onBack: () -> Void
// 使@StateUI
@State private var email: String = ""
@State private var verificationCode: String = ""
@State private var newPassword: String = ""
@State private var isNewPasswordVisible: Bool = false
//
@State private var countdown: Int = 0
@State private var countdownTimer: Timer?
//
private var isConfirmButtonEnabled: Bool {
return !store.isResetLoading && !email.isEmpty && !verificationCode.isEmpty && !newPassword.isEmpty
}
//
private var isGetCodeButtonEnabled: Bool {
return !store.isCodeLoading && !email.isEmpty && countdown == 0
}
//
private var getCodeButtonText: String {
if store.isCodeLoading {
return ""
} else if countdown > 0 {
return "\(countdown)s"
} else {
return "recover_password.get_code".localized
}
}
var body: some View {
GeometryReader { geometry in
ZStack {
//
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
VStack(spacing: 0) {
//
HStack {
Button(action: {
onBack()
}) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.white)
.frame(width: 44, height: 44)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, 8)
Spacer()
.frame(height: 60)
//
Text("recover_password.title".localized)
.font(.system(size: 28, weight: .medium))
.foregroundColor(.white)
.padding(.bottom, 80)
//
VStack(spacing: 24) {
//
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
TextField("", text: $email)
.placeholder(when: email.isEmpty) {
Text("recover_password.placeholder_email".localized)
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
.padding(.horizontal, 24)
.keyboardType(.emailAddress)
.autocapitalization(.none)
}
//
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
HStack {
TextField("", text: $verificationCode)
.placeholder(when: verificationCode.isEmpty) {
Text("recover_password.placeholder_verification_code".localized)
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
.keyboardType(.numberPad)
//
Button(action: {
//
startCountdown()
// API
store.send(.getVerificationCodeTapped)
}) {
ZStack {
if store.isCodeLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.7)
} else {
Text(getCodeButtonText)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
}
}
.frame(width: 60, height: 36)
.background(
RoundedRectangle(cornerRadius: 15)
.fill(Color.white.opacity(isGetCodeButtonEnabled ? 0.2 : 0.1))
)
}
.disabled(!isGetCodeButtonEnabled || store.isCodeLoading)
}
.padding(.horizontal, 24)
}
//
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
HStack {
if isNewPasswordVisible {
TextField("", text: $newPassword)
.placeholder(when: newPassword.isEmpty) {
Text("recover_password.placeholder_new_password".localized)
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
} else {
SecureField("", text: $newPassword)
.placeholder(when: newPassword.isEmpty) {
Text("recover_password.placeholder_new_password".localized)
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
}
Button(action: {
isNewPasswordVisible.toggle()
}) {
Image(systemName: isNewPasswordVisible ? "eye.slash" : "eye")
.foregroundColor(.white.opacity(0.7))
.font(.system(size: 18))
}
}
.padding(.horizontal, 24)
}
}
.padding(.horizontal, 32)
Spacer()
.frame(height: 80)
//
Button(action: {
store.send(.resetPasswordTapped)
}) {
ZStack {
//
LinearGradient(
colors: [
Color(red: 0.85, green: 0.37, blue: 1.0), // #D85EFF
Color(red: 0.54, green: 0.31, blue: 1.0) // #8A4FFF
],
startPoint: .leading,
endPoint: .trailing
)
.clipShape(RoundedRectangle(cornerRadius: 28))
HStack {
if store.isResetLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
}
Text(store.isResetLoading ? "recover_password.resetting".localized : "recover_password.confirm_button".localized)
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
}
}
.frame(height: 56)
}
.disabled(!isConfirmButtonEnabled)
.opacity(isConfirmButtonEnabled ? 1.0 : 0.5)
.padding(.horizontal, 32)
//
if let errorMessage = store.errorMessage {
Text(errorMessage)
.font(.system(size: 14))
.foregroundColor(.red)
.padding(.top, 16)
.padding(.horizontal, 32)
}
Spacer()
}
}
}
.onAppear {
//
store.send(.resetState)
email = ""
verificationCode = ""
newPassword = ""
isNewPasswordVisible = false
countdown = 0
stopCountdown()
#if DEBUG
email = "exzero@126.com"
store.send(.emailChanged(email))
#endif
}
.onDisappear {
stopCountdown()
}
.onChange(of: email) { newEmail in
store.send(.emailChanged(newEmail))
}
.onChange(of: verificationCode) { newCode in
store.send(.verificationCodeChanged(newCode))
}
.onChange(of: newPassword) { newPassword in
store.send(.newPasswordChanged(newPassword))
}
.onChange(of: store.isCodeLoading) { isCodeLoading in
// API
if !isCodeLoading && store.errorMessage == nil {
//
}
}
}
// MARK: - Private Methods
private func startCountdown() {
countdown = 60
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
if countdown > 0 {
countdown -= 1
} else {
stopCountdown()
}
}
}
private func stopCountdown() {
countdownTimer?.invalidate()
countdownTimer = nil
countdown = 0
}
}
#Preview {
RecoverPasswordView(
store: Store(
initialState: RecoverPasswordFeature.State()
) {
RecoverPasswordFeature()
},
onBack: {}
)
}

View File

@@ -73,4 +73,103 @@ final class yanaAPITests: XCTestCase {
XCTAssertTrue(successResponse.isSuccess, "响应应该标记为成功")
XCTAssertEqual(successResponse.data?.accessToken, "test_token", "访问令牌应该正确")
}
func testAccountModelFlow() {
// AccountModel
// 1. oauth/token
let loginData = IDLoginData(
accessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test",
refreshToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh",
tokenType: "bearer",
expiresIn: 2591999,
scope: "read write",
userInfo: nil, // APIuser_info
uid: 3184,
netEaseToken: "6fba51065b5e32ad18a935438517a1a9",
jti: "d3a82ddb-ea6f-4d2f-8dc7-7bdb3d6b9e87"
)
// 2. IDLoginData AccountModel
let accountModel = AccountModel.from(loginData: loginData)
XCTAssertNotNil(accountModel, "应该能从IDLoginData创建AccountModel")
XCTAssertEqual(accountModel?.uid, "3184", "UID应该正确转换为字符串")
XCTAssertEqual(accountModel?.accessToken, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test", "Access Token应该正确")
XCTAssertEqual(accountModel?.tokenType, "bearer", "Token类型应该正确")
XCTAssertEqual(accountModel?.netEaseToken, "6fba51065b5e32ad18a935438517a1a9", "网易云Token应该正确")
XCTAssertNil(accountModel?.ticket, "初始ticket应该为空")
// 3.
XCTAssertTrue(accountModel?.hasValidAuthentication ?? false, "应该有有效的认证")
XCTAssertFalse(accountModel?.hasValidSession ?? true, "没有ticket时不应该有有效会话")
// 4. ticketAccountModel
let ticketString = "eyJhbGciOiJIUzI1NiJ9.ticket"
let updatedAccountModel = accountModel?.withTicket(ticketString)
XCTAssertNotNil(updatedAccountModel, "应该能更新ticket")
XCTAssertEqual(updatedAccountModel?.ticket, ticketString, "Ticket应该正确设置")
XCTAssertTrue(updatedAccountModel?.hasValidSession ?? false, "有ticket时应该有有效会话")
// 5.
if let finalAccountModel = updatedAccountModel {
UserInfoManager.saveAccountModel(finalAccountModel)
let loadedAccountModel = UserInfoManager.getAccountModel()
XCTAssertNotNil(loadedAccountModel, "应该能加载保存的AccountModel")
XCTAssertEqual(loadedAccountModel?.uid, "3184", "加载的UID应该正确")
XCTAssertEqual(loadedAccountModel?.accessToken, finalAccountModel.accessToken, "加载的Access Token应该正确")
XCTAssertEqual(loadedAccountModel?.ticket, ticketString, "加载的Ticket应该正确")
// 6.
let userId = UserInfoManager.getCurrentUserId()
let accessToken = UserInfoManager.getAccessToken()
let ticket = UserInfoManager.getCurrentUserTicket()
XCTAssertEqual(userId, "3184", "向后兼容的用户ID应该正确")
XCTAssertEqual(accessToken, finalAccountModel.accessToken, "向后兼容的Access Token应该正确")
XCTAssertEqual(ticket, ticketString, "向后兼容的Ticket应该正确")
}
// 7.
UserInfoManager.clearAllAuthenticationData()
XCTAssertNil(UserInfoManager.getAccountModel(), "清理后AccountModel应该为空")
XCTAssertNil(UserInfoManager.getCurrentUserId(), "清理后用户ID应该为空")
}
func testAccountModelWithRealAPIData() {
// 使API
let realAPIResponseData: [String: Any] = [
"uid": 3184,
"jti": "d3a82ddb-ea6f-4d2f-8dc7-7bdb3d6b9e87",
"token_type": "bearer",
"scope": "read write",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiIyMzU2ODE0Iiwic2NvcGUiOlsicmVhZCIsIndyaXRlIl0sImF0aSI6ImQzYTgyZGRiLWVhNmYtNGQyZi04ZGM3LTdiZGIzZDZiOWU4NyIsImV4cCI6MTc1NTI0MjY5MiwiYXV0aG9yaXRpZXMiOlsiUk9MRV9hY2NvdW50IiwiUk9MRV9NT0JJTEUiLCJST0xFX1VOSVRZIl0sImp0aSI6ImFiZjhjN2ZjLTllOWEtNDE2Yy04NTk2LTBkMWYxZWQyODU2MiIsImNsaWVudF9pZCI6ImVyYmFuLWNsaWVudCJ9.6i_9FnZvviuWYIoXDv9of7EDRyjRVxNbkiHayNUFxNw",
"netEaseToken": "6fba51065b5e32ad18a935438517a1a9",
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTQ2Mzc4OTIsInVzZXJfbmFtZSI6IjIzNTY4MTQiLCJhdXRob3JpdGllcyI6WyJST0xFX2FjY291bnQiLCJST0xFX01PQklMRSIsIlJPTEVfVU5JVFkiXSwianRpIjoiZDNhODJkZGItZWE2Zi00ZDJmLThkYzctN2JkYjNkNmI5ZTg3IiwiY2xpZW50X2lkIjoiZXJiYW4tY2xpZW50Iiwic2NvcGUiOlsicmVhZCIsIndyaXRlIl19.ynUptBtAoPVXz4J1AO8LbaAhmFRF4UnF4C-Ggj6Izpc",
"expires_in": 2591999
]
// JSON
do {
let jsonData = try JSONSerialization.data(withJSONObject: realAPIResponseData)
let loginData = try JSONDecoder().decode(IDLoginData.self, from: jsonData)
// AccountModel
let accountModel = AccountModel.from(loginData: loginData)
XCTAssertNotNil(accountModel, "应该能从真实API数据创建AccountModel")
XCTAssertEqual(accountModel?.uid, "3184", "真实API数据的UID应该正确")
XCTAssertTrue(accountModel?.hasValidAuthentication ?? false, "真实API数据应该有有效认证")
print("✅ 真实API数据测试通过")
print(" UID: \(accountModel?.uid ?? "nil")")
print(" Access Token存在: \(accountModel?.accessToken != nil)")
print(" Token类型: \(accountModel?.tokenType ?? "nil")")
} catch {
XCTFail("解析真实API数据失败: \(error)")
}
}
}