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:
sccsbc
2026-07-10 13:24:03 +08:00
parent d82e006cf4
commit fe4c81c54f
27 changed files with 1400 additions and 50 deletions

View File

@@ -3,7 +3,12 @@ using Health.Infrastructure.Services;
namespace Health.Infrastructure.Auth;
public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms) : IAuthService
public sealed class AuthService(
AppDbContext db,
JwtProvider jwt,
SmsService sms,
AppleTokenValidator appleValidator // Apple Sign-In JWT 验证
) : IAuthService
{
private const string AdminPhone = "12345678910";
private const string AdminSmsCode = "000000";
@@ -11,6 +16,7 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
private readonly AppDbContext _db = db;
private readonly JwtProvider _jwt = jwt;
private readonly SmsService _sms = sms;
private readonly AppleTokenValidator _appleValidator = appleValidator;
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
{
@@ -99,13 +105,64 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
await _db.SaveChangesAsync(ct);
}
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,
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 }
});
}
private Task<VerificationCode?> TakeCodeAsync(string phone, string code, CancellationToken ct) =>
_db.VerificationCodes.Where(x => x.Phone == phone && x.Code == code && x.ExpiresAt > DateTime.UtcNow && !x.IsUsed)
.OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct);
private (string accessToken, string refreshToken) AddTokens(Guid userId, string phone, string role)
private (string accessToken, string refreshToken) AddTokens(Guid userId, string? phone, string role)
{
var access = _jwt.GenerateAccessToken(userId, phone, 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);