# 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;
```
