feat: 集成 Apple Sign-In + iOS 上线准备
- 后端新增 Apple 登录端点 /api/auth/apple-login - 新增 AppleTokenValidator 验证 IdentityToken - User 实体添加 AppleUserId 字段,Phone 改为可空 - 前端添加 sign_in_with_apple 依赖和 Apple 登录按钮 - 统一 Bundle ID 为 com.datalumina.YYA,显示名称为小脉健康 - 配置 DEVELOPMENT_TEAM 和 Sign in with Apple Entitlements - 补充 NSCamera/NSPhotoLibrary 权限描述 - 生产 API 地址改为 https://erpapi.datalumina.cn/xiaomai - Flutter 升级至 3.44.6 / Dart 3.12.2 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
688
docs/superpowers/plans/2026-07-10-apple-sign-in.md
Normal file
688
docs/superpowers/plans/2026-07-10-apple-sign-in.md
Normal file
@@ -0,0 +1,688 @@
|
||||
# Apple Sign-In 集成实施方案
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为健康管家 App 集成 Sign in with Apple,让 iOS 用户可以通过 Apple ID 一键登录/注册。
|
||||
|
||||
**Architecture:** 后端新增 `POST /api/auth/apple-login` 端点,验证 Apple 返回的 identityToken(JWT),查找或创建 User 记录(无需手机号),返回 JWT token。前端添加 `sign_in_with_apple` Flutter 包,在登录页放置 Apple 登录按钮。
|
||||
|
||||
**Tech Stack:** Flutter 3.44 + Dart 3.12 | C# .NET 10 + EF Core + PostgreSQL | sign_in_with_apple 包 | Apple RSA 公钥验证
|
||||
|
||||
**设计决策:**
|
||||
- User.Phone 改为可空(Apple 用户无手机号),PostgreSQL 对多个 NULL 值不违反唯一索引
|
||||
- 新增 `AppleUserId` 字段(可空字符串 + 唯一索引)标识 Apple 用户
|
||||
- Apple 登录首次无需绑定医生(doctorId 可为空),用户可在个人中心后续设置
|
||||
- Apple identityToken 验证:缓存 Apple 公钥,本地验签,验证 audience/issuer/有效期
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端 — User 实体 + DB 迁移
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/Health.Domain/Entities/user.cs`
|
||||
- Modify: `backend/src/Health.Infrastructure/Data/app_db_context.cs`
|
||||
|
||||
- [ ] **Step 1: User 实体添加 AppleUserId 字段,Phone 改为可空**
|
||||
|
||||
`backend/src/Health.Domain/Entities/user.cs`:
|
||||
|
||||
```csharp
|
||||
namespace Health.Domain.Entities;
|
||||
|
||||
public sealed class User
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string? Phone { get; set; } // 改为可空,Apple 用户无手机号
|
||||
public string? AppleUserId { get; set; } // Apple 唯一标识
|
||||
public string Role { get; set; } = "User";
|
||||
public Guid? DoctorId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
// ... 其余字段不变
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: AppDbContext 添加 AppleUserId 配置**
|
||||
|
||||
`backend/src/Health.Infrastructure/Data/app_db_context.cs` 的 User 配置段:
|
||||
|
||||
```csharp
|
||||
builder.Entity<User>(e =>
|
||||
{
|
||||
e.HasIndex(u => u.Phone).IsUnique();
|
||||
e.HasIndex(u => u.AppleUserId).IsUnique(); // 新增
|
||||
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
```bash
|
||||
cd backend/src/Health.WebApi && dotnet build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 后端 — Apple JWT 验证服务
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/src/Health.Infrastructure/Services/apple_token_validator.cs`
|
||||
|
||||
- [ ] **Step 1: 创建 AppleTokenValidator**
|
||||
|
||||
`backend/src/Health.Infrastructure/Services/apple_token_validator.cs`:
|
||||
|
||||
```csharp
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Health.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Apple IdentityToken 验证:下载公钥、验签、检查声明
|
||||
/// </summary>
|
||||
public sealed class AppleTokenValidator
|
||||
{
|
||||
private const string AppleKeysUrl = "https://appleid.apple.com/auth/keys";
|
||||
private const string AppleIssuer = "https://appleid.apple.com";
|
||||
private static readonly HttpClient _http = new();
|
||||
private static List<JsonWebKey>? _cachedKeys;
|
||||
private static DateTime _keysExpiry = DateTime.MinValue;
|
||||
private static readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
private readonly string _clientId;
|
||||
|
||||
public AppleTokenValidator(IConfiguration config)
|
||||
{
|
||||
_clientId = config["APPLE_CLIENT_ID"] ?? "com.datalumina.YYA";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 Apple identityToken,返回 sub(用户唯一标识)
|
||||
/// </summary>
|
||||
public async Task<string> ValidateAsync(string identityToken, CancellationToken ct)
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
// 先解码 header 获取 kid
|
||||
var rawToken = handler.ReadJwtToken(identityToken);
|
||||
var kid = rawToken.Header.Kid;
|
||||
|
||||
var keys = await GetKeysAsync(ct);
|
||||
var key = keys.FirstOrDefault(k => k.KeyId == kid)
|
||||
?? throw new SecurityTokenException($"No matching key for kid: {kid}");
|
||||
|
||||
var validationParams = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = AppleIssuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = [_clientId, "com.datalumina.YYA.signin"],
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.FromMinutes(1),
|
||||
};
|
||||
|
||||
var principal = handler.ValidateToken(identityToken, validationParams, out _);
|
||||
var sub = principal.FindFirst("sub")?.Value;
|
||||
if (string.IsNullOrWhiteSpace(sub))
|
||||
throw new SecurityTokenException("Missing 'sub' claim in Apple identityToken");
|
||||
|
||||
return sub;
|
||||
}
|
||||
|
||||
private static async Task<List<JsonWebKey>> GetKeysAsync(CancellationToken ct)
|
||||
{
|
||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
||||
|
||||
await _lock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
||||
|
||||
var response = await _http.GetStringAsync(AppleKeysUrl, ct);
|
||||
var jwks = new JsonWebKeySet(response);
|
||||
_cachedKeys = jwks.Keys.ToList();
|
||||
_keysExpiry = DateTime.UtcNow.AddHours(6); // 缓存 6 小时
|
||||
return _cachedKeys;
|
||||
}
|
||||
finally { _lock.Release(); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 注册服务到 DI**
|
||||
|
||||
在 `backend/src/Health.WebApi/Program.cs` 的 services 注册区添加:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddSingleton<AppleTokenValidator>();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
```bash
|
||||
cd backend/src/Health.WebApi && dotnet build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 后端 — AuthService 添加 Apple 登录
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/Health.Application/Auth/AuthContracts.cs`
|
||||
- Modify: `backend/src/Health.Infrastructure/Auth/AuthService.cs`
|
||||
|
||||
- [ ] **Step 1: 添加 AppleLoginCommand DTO 和接口方法**
|
||||
|
||||
`backend/src/Health.Application/Auth/AuthContracts.cs`:
|
||||
|
||||
```csharp
|
||||
namespace Health.Application.Auth;
|
||||
|
||||
public sealed record AuthResult(int Code, object? Data, string? Message = null);
|
||||
public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||
public sealed record AppleLoginCommand(string IdentityToken, string? AuthorizationCode, string? Name); // 新增
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
|
||||
Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct);
|
||||
Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct);
|
||||
Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct); // 新增
|
||||
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct);
|
||||
Task LogoutAsync(string refreshToken, CancellationToken ct);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: AuthService 实现 AppleLoginAsync**
|
||||
|
||||
`backend/src/Health.Infrastructure/Auth/AuthService.cs`:
|
||||
|
||||
在类的顶部依赖注入区添加 `AppleTokenValidator`:
|
||||
|
||||
```csharp
|
||||
public sealed class AuthService(
|
||||
AppDbContext db,
|
||||
JwtProvider jwt,
|
||||
SmsService sms,
|
||||
AppleTokenValidator appleValidator // 新增
|
||||
) : IAuthService
|
||||
{
|
||||
private readonly AppDbContext _db = db;
|
||||
private readonly JwtProvider _jwt = jwt;
|
||||
private readonly SmsService _sms = sms;
|
||||
private readonly AppleTokenValidator _appleValidator = appleValidator;
|
||||
// ... 现有代码不变
|
||||
```
|
||||
|
||||
在 `LogoutAsync` 方法之后添加新方法,并修改 `AddTokens` 的 phone 参数类型为 `string?`:
|
||||
|
||||
```csharp
|
||||
public async Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct)
|
||||
{
|
||||
// 1. 验证 Apple identityToken
|
||||
string appleUserId;
|
||||
try
|
||||
{
|
||||
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Error(40005, $"Apple 登录验证失败: {ex.Message}");
|
||||
}
|
||||
|
||||
// 2. 查找已有用户
|
||||
var existingUser = await _db.Users.FirstOrDefaultAsync(x => x.AppleUserId == appleUserId, ct);
|
||||
if (existingUser != null)
|
||||
{
|
||||
// 已有用户 → 直接登录
|
||||
existingUser.UpdatedAt = DateTime.UtcNow;
|
||||
if (!string.IsNullOrWhiteSpace(command.Name)) existingUser.Name ??= command.Name;
|
||||
var tokens = AddTokens(existingUser.Id, existingUser.Phone, existingUser.Role);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new AuthResult(0, new
|
||||
{
|
||||
tokens.accessToken, tokens.refreshToken,
|
||||
user = new { existingUser.Id, existingUser.Phone, existingUser.Role, existingUser.Name, existingUser.Gender, existingUser.AvatarUrl, isNew = false }
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 新用户 → 创建账户(无需手机号、无需选医生)
|
||||
var newUser = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Phone = null, // Apple 用户未绑定手机
|
||||
AppleUserId = appleUserId,
|
||||
Role = "User",
|
||||
Name = command.Name,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_db.Users.Add(newUser);
|
||||
_db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = newUser.Id });
|
||||
_db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = newUser.Id });
|
||||
var newTokens = AddTokens(newUser.Id, null, newUser.Role);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new AuthResult(0, new
|
||||
{
|
||||
newTokens.accessToken, newTokens.refreshToken,
|
||||
user = new { newUser.Id, Phone = (string?)null, newUser.Role, newUser.Name, isNew = true }
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
同时修改 `AddTokens` 方法签名,phone 改为可空:
|
||||
|
||||
```csharp
|
||||
private (string accessToken, string refreshToken) AddTokens(Guid userId, string? phone, string role)
|
||||
{
|
||||
var access = _jwt.GenerateAccessToken(userId, phone ?? "", role);
|
||||
var refresh = _jwt.GenerateRefreshToken();
|
||||
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
|
||||
return (access, refresh);
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: `AddTokens` 的 phone 参数改为 `string?`,`GenerateAccessToken` 接收到空字符串(Apple 用户无需手机号)。`RefreshAsync` 和 `RegisterAsync` 中现有的 `AddTokens` 调用无需修改(传入 `string?` 的 `user.Phone` 自动匹配)。Admin 的 `AddTokens(AdminId, AdminPhone, "Admin")`(`AdminPhone` 是 `string` 常量)也无需修改。
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
```bash
|
||||
cd backend/src/Health.WebApi && dotnet build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 后端 — 添加 Apple 登录端点
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/Health.WebApi/Endpoints/auth_endpoints.cs`
|
||||
|
||||
- [ ] **Step 1: 添加 AppleLoginRequest 和端点**
|
||||
|
||||
`backend/src/Health.WebApi/Endpoints/auth_endpoints.cs`:
|
||||
|
||||
在现有端点中添加:
|
||||
|
||||
```csharp
|
||||
// 在 MapAuthEndpoints 方法内、logout 端点之后添加:
|
||||
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
|
||||
```
|
||||
|
||||
在文件末尾添加 DTO:
|
||||
|
||||
```csharp
|
||||
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
|
||||
```
|
||||
|
||||
文件最终结果为:
|
||||
|
||||
```csharp
|
||||
using Health.Application.Auth;
|
||||
|
||||
namespace Health.WebApi.Endpoints;
|
||||
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
public static void MapAuthEndpoints(this WebApplication app)
|
||||
{
|
||||
app.MapPost("/api/auth/send-sms", async (SendSmsRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.SendCodeAsync(request.Phone, app.Environment.IsDevelopment(), ct)));
|
||||
app.MapPost("/api/auth/register", async (RegisterRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct)));
|
||||
app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct)));
|
||||
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
|
||||
app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.RefreshAsync(request.RefreshToken, ct)));
|
||||
app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
{
|
||||
await auth.LogoutAsync(request.RefreshToken, ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult ToResult(AuthResult result) =>
|
||||
Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
|
||||
}
|
||||
|
||||
public sealed record SendSmsRequest(string Phone);
|
||||
public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||
public sealed record LoginRequest(string Phone, string SmsCode);
|
||||
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
|
||||
public sealed record RefreshRequest(string RefreshToken);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译**
|
||||
|
||||
```bash
|
||||
cd backend/src/Health.WebApi && dotnet build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 后端 — 重启并测试端点连通性
|
||||
|
||||
- [ ] **Step 1: 重启后端服务**
|
||||
|
||||
```bash
|
||||
cd backend/src/Health.WebApi && dotnet run &
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 测试无 token 调用(预期返回 40005,token 无效)**
|
||||
|
||||
```bash
|
||||
curl -s -w "\nHTTP %{http_code}" -X POST "http://localhost:5000/api/auth/apple-login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"identityToken":"invalid","name":"test"}'
|
||||
```
|
||||
|
||||
Expected: HTTP 200, code=40005, message 包含"验证失败"
|
||||
|
||||
- [ ] **Step 3: 测试真实场景**
|
||||
|
||||
此时无法用 curl 直接测(需要真实的 Apple identityToken)。稍后在 Task 8 中从 App 端联合验证。
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 前端 — 添加 sign_in_with_apple 依赖
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/pubspec.yaml`
|
||||
|
||||
- [ ] **Step 1: 添加依赖**
|
||||
|
||||
`health_app/pubspec.yaml`,在 dependencies 区添加:
|
||||
|
||||
```yaml
|
||||
# Apple 登录
|
||||
sign_in_with_apple: ^6.1.0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 安装依赖**
|
||||
|
||||
```bash
|
||||
cd health_app && flutter pub get
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 前端 — AuthProvider 添加 Apple 登录方法
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/lib/providers/auth_provider.dart`
|
||||
|
||||
- [ ] **Step 1: 添加 appleLogin 方法**
|
||||
|
||||
`health_app/lib/providers/auth_provider.dart`,在 `login()` 方法之后、`logout()` 方法之前添加:
|
||||
|
||||
```dart
|
||||
/// Apple 登录
|
||||
Future<String?> appleLogin(String identityToken, String? authorizationCode, String? name) async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final response = await api.post(
|
||||
'/api/auth/apple-login',
|
||||
data: {
|
||||
'identityToken': identityToken,
|
||||
if (authorizationCode != null) 'authorizationCode': authorizationCode,
|
||||
if (name != null && name.isNotEmpty) 'name': name,
|
||||
},
|
||||
);
|
||||
final data = response.data['data'];
|
||||
if (data == null) return response.data['message'] ?? 'Apple 登录失败';
|
||||
|
||||
await api.saveTokens(data['accessToken'], data['refreshToken']);
|
||||
final user = data['user'];
|
||||
state = AuthState(
|
||||
isLoggedIn: true,
|
||||
isLoading: false,
|
||||
user: UserInfo(
|
||||
id: user['id'] ?? '',
|
||||
phone: user['phone'] ?? '',
|
||||
role: user['role'] ?? 'User',
|
||||
name: user['name'],
|
||||
avatarUrl: user['avatarUrl'],
|
||||
),
|
||||
);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return 'Apple 登录失败: $e';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**: `UserInfo` 的 `phone` 字段当前是 `required String`。Apple 用户可能无手机号。`user['phone']` 可能是 null。在 login() 方法中 Phone 断言 `phone: user['phone'] ?? ''` 已有兜底,appleLogin 保持一致。
|
||||
|
||||
- [ ] **Step 2: 编译检查**
|
||||
|
||||
```bash
|
||||
cd health_app && flutter analyze lib/providers/auth_provider.dart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 前端 — 登录页添加 Apple 登录按钮
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/lib/pages/auth/login_page.dart`
|
||||
|
||||
- [ ] **Step 1: 添加 Apple 登录按钮组件和点击逻辑**
|
||||
|
||||
`health_app/lib/pages/auth/login_page.dart`:
|
||||
|
||||
顶部添加 import:
|
||||
|
||||
```dart
|
||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
||||
```
|
||||
|
||||
在 `_LoginPageState` 类中添加 Apple 登录方法(在 `_startCountdown` 方法之后):
|
||||
|
||||
```dart
|
||||
Future<void> _appleSignIn() async {
|
||||
if (_loading) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
_successMsg = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final credential = await SignInWithApple.getAppleIDCredential(
|
||||
scopes: [
|
||||
AppleIDAuthorizationScopes.fullName,
|
||||
],
|
||||
webAuthenticationOptions: WebAuthenticationOptions(
|
||||
clientId: 'com.datalumina.YYA.signin',
|
||||
redirectUri: Uri.parse('https://erpapi.datalumina.cn/xiaomai/api/auth/apple-login'),
|
||||
),
|
||||
);
|
||||
|
||||
final identityToken = credential.identityToken;
|
||||
if (identityToken == null) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = 'Apple 登录未返回身份信息,请重试';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final name = credential.givenName != null && credential.familyName != null
|
||||
? '${credential.familyName}${credential.givenName}'
|
||||
: null;
|
||||
|
||||
final err = await ref.read(authProvider.notifier).appleLogin(
|
||||
identityToken,
|
||||
credential.authorizationCode,
|
||||
name,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
if (err != null) {
|
||||
setState(() => _error = err);
|
||||
} else {
|
||||
_goHome();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
if (e is SignInWithAppleAuthorizationException) {
|
||||
_error = 'Apple 登录已取消';
|
||||
} else {
|
||||
_error = 'Apple 登录失败: $e';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在 `_LoginFormCard` 的 children 中,在 `_PrimaryButton` 和"去注册"链接之间(`_PrimaryButton` 之后,`const SizedBox(height: 16)` 之前)插入 Apple 按钮:
|
||||
|
||||
```dart
|
||||
const SizedBox(height: 20),
|
||||
// --- Apple 登录按钮 ---
|
||||
SignInWithAppleButton(
|
||||
onPressed: _appleSignIn,
|
||||
style: SignInWithAppleButtonStyle.whiteOutline,
|
||||
height: 48,
|
||||
cornerRadius: 14,
|
||||
),
|
||||
// --- 结束 ---
|
||||
const SizedBox(height: 16),
|
||||
```
|
||||
|
||||
即在 build 方法中 `_LoginFormCard` child 的末尾(`_PrimaryButton` 之后、`GestureDetector` "去注册" 之前):
|
||||
|
||||
```dart
|
||||
// ... 上面是 _PrimaryButton 的 const SizedBox(height: 22) 和 _PrimaryButton 本身
|
||||
const SizedBox(height: 20),
|
||||
SignInWithAppleButton(
|
||||
onPressed: _appleSignIn,
|
||||
style: SignInWithAppleButtonStyle.whiteOutline,
|
||||
height: 48,
|
||||
cornerRadius: 14,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
_isLogin = !_isLogin;
|
||||
_error = null;
|
||||
_successMsg = null;
|
||||
}),
|
||||
// ... 去注册/去登录 链接
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 1 补充**: `_submit()` 方法中也需要加 `_loading` 检查,确保 Apple 按钮点击期间短信登录不响应。现有 `_submit` 已有 `setState(() => _loading = true)`,问题不大。
|
||||
|
||||
- [ ] **Step 2: 编译检查**
|
||||
|
||||
```bash
|
||||
cd health_app && flutter analyze lib/pages/auth/login_page.dart
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编译 iOS 验证无语法错误**
|
||||
|
||||
```bash
|
||||
cd health_app && flutter build ios --debug --no-codesign
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: iOS — Xcode 配置 Sign in with Apple
|
||||
|
||||
- [ ] **Step 1: 在 Xcode 中启用 Sign in with Apple Capability**
|
||||
|
||||
```bash
|
||||
open health_app/ios/Runner.xcworkspace
|
||||
```
|
||||
|
||||
在 Xcode 中操作:
|
||||
1. 选择 Runner target → Signing & Capabilities
|
||||
2. 点击 "+ Capability" → 选择 "Sign in with Apple"
|
||||
3. 确认 .entitlements 文件自动生成了 `com.apple.developer.applesignin` entitlement
|
||||
|
||||
这也会自动在 `project.pbxproj` 中写入相关配置,后续 git diff 可以看到变化。
|
||||
|
||||
- [ ] **Step 2: 验证 .entitlements 文件**
|
||||
|
||||
确认 `health_app/ios/Runner/Runner.entitlements` 包含:
|
||||
|
||||
```xml
|
||||
<key>com.apple.developer.applesignin</key>
|
||||
<array>
|
||||
<string>Default</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: App Store Connect 配置
|
||||
|
||||
- [ ] **Step 1: 在 App Store Connect 中启用 Sign in with Apple**
|
||||
|
||||
1. 打开 [App Store Connect](https://appstoreconnect.apple.com)
|
||||
2. 进入 App 记录 → App 隐私 → Sign in with Apple
|
||||
3. 确认已为 `com.datalumina.YYA` 这个 App ID 启用了 Sign in with Apple capability
|
||||
|
||||
如果还未创建 App Store Connect 上的 App 记录,需要在 Certificates, Identifiers & Profiles 中为 App ID 手动勾选 "Sign in with Apple" capability。
|
||||
|
||||
---
|
||||
|
||||
### 验证计划
|
||||
|
||||
**后端单元验证:**
|
||||
|
||||
```bash
|
||||
# 1. 后端编译通过
|
||||
cd backend/src/Health.WebApi && dotnet build
|
||||
|
||||
# 2. 启动后端
|
||||
cd backend/src/Health.WebApi && dotnet run
|
||||
|
||||
# 3. 测试 Apple 登录端点(无 token 场景)
|
||||
curl -s -X POST "http://localhost:5000/api/auth/apple-login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"identityToken":"test","name":"测试"}'
|
||||
# 预期: HTTP 200, body 包含 code=40005
|
||||
|
||||
# 4. 测试现有发送短信端点未被影响
|
||||
curl -s -X POST "http://localhost:5000/api/auth/send-sms" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"phone":"13800138000"}'
|
||||
# 预期: HTTP 200, code=0
|
||||
```
|
||||
|
||||
**前端验证:**
|
||||
|
||||
```bash
|
||||
# 1. 编译检查
|
||||
cd health_app && flutter analyze
|
||||
|
||||
# 2. iOS 构建通过
|
||||
flutter build ios --debug --no-codesign
|
||||
|
||||
# 3. 在模拟器上运行
|
||||
flutter run -d "iPhone 17 Pro"
|
||||
# 验证:登录页显示 Apple 登录按钮;点击后弹出 Apple ID 授权界面
|
||||
```
|
||||
|
||||
**端到端验证(模拟器):**
|
||||
1. 模拟器上启动 App → 进入登录页
|
||||
2. 点击 Apple 登录按钮 → Apple 授权弹窗出现 → 选择"继续"
|
||||
3. 后端验证 identityToken → 创建新用户或登录已有 → 返回 JWT → App 进入首页
|
||||
4. 二次打开 App → 如果之前 Apple 登录成功 + refresh_token 有效 → 自动恢复登录态
|
||||
|
||||
**DB 验证:**
|
||||
```sql
|
||||
-- 确认 Apple 用户创建成功
|
||||
SELECT id, phone, apple_user_id, name, role FROM "Users" WHERE apple_user_id IS NOT NULL;
|
||||
```
|
||||
Reference in New Issue
Block a user