Files
AI-Health/docs/superpowers/plans/2026-07-10-apple-sign-in.md
sccsbc fe4c81c54f 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>
2026-07-10 13:24:03 +08:00

22 KiB
Raw Blame History

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 返回的 identityTokenJWT查找或创建 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:

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 配置段:

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: 验证编译
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:

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 注册区添加:

builder.Services.AddSingleton<AppleTokenValidator>();
  • Step 3: 验证编译
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:

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

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?

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 改为可空:

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 用户无需手机号)。RefreshAsyncRegisterAsync 中现有的 AddTokens 调用无需修改(传入 string?user.Phone 自动匹配。Admin 的 AddTokens(AdminId, AdminPhone, "Admin")AdminPhonestring 常量)也无需修改。

  • Step 3: 验证编译
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:

在现有端点中添加:

// 在 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

public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);

文件最终结果为:

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: 验证编译
cd backend/src/Health.WebApi && dotnet build

Task 5: 后端 — 重启并测试端点连通性

  • Step 1: 重启后端服务
cd backend/src/Health.WebApi && dotnet run &
  • Step 2: 测试无 token 调用(预期返回 40005token 无效)
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 区添加:

  # Apple 登录
  sign_in_with_apple: ^6.1.0
  • Step 2: 安装依赖
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() 方法之前添加:

  /// 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';
    }
  }

注意: UserInfophone 字段当前是 required String。Apple 用户可能无手机号。user['phone'] 可能是 null。在 login() 方法中 Phone 断言 phone: user['phone'] ?? '' 已有兜底appleLogin 保持一致。

  • Step 2: 编译检查
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

import 'package:sign_in_with_apple/sign_in_with_apple.dart';

_LoginPageState 类中添加 Apple 登录方法(在 _startCountdown 方法之后):

  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 按钮:

  const SizedBox(height: 20),
  // --- Apple 登录按钮 ---
  SignInWithAppleButton(
    onPressed: _appleSignIn,
    style: SignInWithAppleButtonStyle.whiteOutline,
    height: 48,
    cornerRadius: 14,
  ),
  // --- 结束 ---
  const SizedBox(height: 16),

即在 build 方法中 _LoginFormCard child 的末尾(_PrimaryButton 之后、GestureDetector "去注册" 之前):

// ... 上面是 _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: 编译检查

cd health_app && flutter analyze lib/pages/auth/login_page.dart
  • Step 3: 编译 iOS 验证无语法错误
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
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 包含:

<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
  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。


验证计划

后端单元验证:

# 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

前端验证:

# 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 验证:

-- 确认 Apple 用户创建成功
SELECT id, phone, apple_user_id, name, role FROM "Users" WHERE apple_user_id IS NOT NULL;