- 后端新增 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>
22 KiB
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:
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 用户无需手机号)。RefreshAsync 和 RegisterAsync 中现有的 AddTokens 调用无需修改(传入 string? 的 user.Phone 自动匹配)。Admin 的 AddTokens(AdminId, AdminPhone, "Admin")(AdminPhone 是 string 常量)也无需修改。
- 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 调用(预期返回 40005,token 无效)
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';
}
}
注意: UserInfo 的 phone 字段当前是 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 中操作:
- 选择 Runner target → Signing & Capabilities
- 点击 "+ Capability" → 选择 "Sign in with Apple"
- 确认 .entitlements 文件自动生成了
com.apple.developer.applesigninentitlement
这也会自动在 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
- 打开 App Store Connect
- 进入 App 记录 → App 隐私 → Sign in with Apple
- 确认已为
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 授权界面
端到端验证(模拟器):
- 模拟器上启动 App → 进入登录页
- 点击 Apple 登录按钮 → Apple 授权弹窗出现 → 选择"继续"
- 后端验证 identityToken → 创建新用户或登录已有 → 返回 JWT → App 进入首页
- 二次打开 App → 如果之前 Apple 登录成功 + refresh_token 有效 → 自动恢复登录态
DB 验证:
-- 确认 Apple 用户创建成功
SELECT id, phone, apple_user_id, name, role FROM "Users" WHERE apple_user_id IS NOT NULL;