# 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(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; /// /// Apple IdentityToken 验证:下载公钥、验签、检查声明 /// 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? _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"; } /// /// 验证 Apple identityToken,返回 sub(用户唯一标识) /// public async Task 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> 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(); ``` - [ ] **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 SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct); Task RegisterAsync(RegisterCommand command, CancellationToken ct); Task LoginAsync(string phone, string smsCode, CancellationToken ct); Task AppleLoginAsync(AppleLoginCommand command, CancellationToken ct); // 新增 Task 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 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 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 _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 com.apple.developer.applesignin Default ``` --- ### 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; ```