diff --git a/.gitignore b/.gitignore index 9f53382..8dbb5b3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ *.key *.pfx +# Android release keystore +health_app/android/**/*.jks +health_app/android/key.properties + # .NET build outputs backend/**/bin/ backend/**/obj/ diff --git a/backend/src/Health.Application/Auth/AuthContracts.cs b/backend/src/Health.Application/Auth/AuthContracts.cs index 83e76ce..ec7d355 100644 --- a/backend/src/Health.Application/Auth/AuthContracts.cs +++ b/backend/src/Health.Application/Auth/AuthContracts.cs @@ -2,12 +2,14 @@ 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); } diff --git a/backend/src/Health.Application/Users/UserContracts.cs b/backend/src/Health.Application/Users/UserContracts.cs index 1c39510..d17a8e2 100644 --- a/backend/src/Health.Application/Users/UserContracts.cs +++ b/backend/src/Health.Application/Users/UserContracts.cs @@ -4,7 +4,7 @@ namespace Health.Application.Users; public sealed record UserProfileDto( Guid Id, - string Phone, + string? Phone, // Apple 用户无手机号 string Role, string? Name, string? Gender, diff --git a/backend/src/Health.Domain/Entities/user.cs b/backend/src/Health.Domain/Entities/user.cs index 541495c..8a5dec2 100644 --- a/backend/src/Health.Domain/Entities/user.cs +++ b/backend/src/Health.Domain/Entities/user.cs @@ -6,7 +6,8 @@ namespace Health.Domain.Entities; public sealed class User { public Guid Id { get; set; } - public string Phone { get; set; } = string.Empty; + public string? Phone { get; set; } // Apple 用户无手机号 + public string? AppleUserId { get; set; } // Apple Sign-In 唯一标识 public string Role { get; set; } = "User"; // "User" | "Doctor" | "Admin" public Guid? DoctorId { get; set; } // 患者选择的医生 public string? Name { get; set; } diff --git a/backend/src/Health.Infrastructure/Admin/AdminService.cs b/backend/src/Health.Infrastructure/Admin/AdminService.cs index be4599b..fa16a72 100644 --- a/backend/src/Health.Infrastructure/Admin/AdminService.cs +++ b/backend/src/Health.Infrastructure/Admin/AdminService.cs @@ -77,7 +77,9 @@ public sealed class AdminService(AppDbContext db) : IAdminService pageSize = Math.Clamp(pageSize, 1, 100); var query = _db.Users.AsNoTracking().Where(x => x.Role == "User"); if (!string.IsNullOrWhiteSpace(search)) - query = query.Where(x => (x.Name != null && x.Name.Contains(search)) || x.Phone.Contains(search)); + query = query.Where(x => + (x.Name != null && x.Name.Contains(search)) || + (x.Phone != null && x.Phone.Contains(search))); var total = await query.CountAsync(ct); var patients = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize) .Select(x => new { x.Id, x.Name, x.Phone, x.Gender, x.BirthDate, x.CreatedAt, DoctorName = x.Doctor != null ? x.Doctor.Name : null, DoctorDepartment = x.Doctor != null ? x.Doctor.Department : null }) diff --git a/backend/src/Health.Infrastructure/Auth/AuthService.cs b/backend/src/Health.Infrastructure/Auth/AuthService.cs index f8822fe..134d4d7 100644 --- a/backend/src/Health.Infrastructure/Auth/AuthService.cs +++ b/backend/src/Health.Infrastructure/Auth/AuthService.cs @@ -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 SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct) { @@ -99,13 +105,68 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms await _db.SaveChangesAsync(ct); } + public async Task AppleLoginAsync(AppleLoginCommand command, CancellationToken ct) + { + // 1. 验证 Apple identityToken + string appleUserId; + try + { + appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception) + { + return Error(40005, "Apple 登录验证失败,请重试"); + } + + // 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 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); diff --git a/backend/src/Health.Infrastructure/Data/Migrations/20260710052811_AddAppleSignIn.Designer.cs b/backend/src/Health.Infrastructure/Data/Migrations/20260710052811_AddAppleSignIn.Designer.cs new file mode 100644 index 0000000..387980a --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Migrations/20260710052811_AddAppleSignIn.Designer.cs @@ -0,0 +1,1466 @@ +// +using System; +using System.Collections.Generic; +using Health.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Health.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260710052811_AddAppleSignIn")] + partial class AddAppleSignIn + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Health.Domain.Entities.Consultation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorId") + .HasColumnType("uuid"); + + b.Property("Month") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("UserId"); + + b.ToTable("Consultations"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConsultationMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsultationId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SenderName") + .HasColumnType("text"); + + b.Property("SenderType") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConsultationId", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("ConsultationMessages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Conversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageCount") + .HasColumnType("integer"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UpdatedAt") + .IsDescending(false, true); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConversationMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Intent") + .HasColumnType("text"); + + b.Property("MetadataJson") + .HasColumnType("jsonb"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "CreatedAt"); + + b.ToTable("ConversationMessages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Platform") + .IsRequired() + .HasColumnType("text"); + + b.Property("PushToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietFoodItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Calories") + .HasColumnType("integer"); + + b.Property("CarbsGrams") + .HasColumnType("numeric"); + + b.Property("DietRecordId") + .HasColumnType("uuid"); + + b.Property("FatGrams") + .HasColumnType("numeric"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Portion") + .HasColumnType("text"); + + b.Property("ProteinGrams") + .HasColumnType("numeric"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Warning") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DietRecordId"); + + b.ToTable("DietFoodItems"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HealthScore") + .HasColumnType("integer"); + + b.Property("MealType") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecordedAt") + .HasColumnType("date"); + + b.Property("TotalCalories") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "RecordedAt") + .IsDescending(false, true); + + b.ToTable("DietRecords"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Doctor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasColumnType("text"); + + b.Property("Introduction") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ProfessionalDirection") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Doctors"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DoctorProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasColumnType("text"); + + b.Property("DoctorId") + .HasColumnType("uuid"); + + b.Property("Hospital") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsOnline") + .HasColumnType("boolean"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("DoctorProfiles"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("ReminderTime") + .HasColumnType("time without time zone"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "StartDate", "EndDate"); + + b.ToTable("ExercisePlans"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlanItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationMinutes") + .HasColumnType("integer"); + + b.Property("ExerciseType") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("IsRestDay") + .HasColumnType("boolean"); + + b.Property("PlanId") + .HasColumnType("uuid"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("PlanId", "ScheduledDate") + .IsUnique(); + + b.ToTable("ExercisePlanItems"); + }); + + modelBuilder.Entity("Health.Domain.Entities.FollowUp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasColumnType("text"); + + b.Property("DoctorName") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchive", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Allergies") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection>("ChronicDiseases") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Diagnosis") + .HasColumnType("text"); + + b.PrimitiveCollection>("DietRestrictions") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("FamilyHistory") + .HasColumnType("text"); + + b.Property("SurgeryDate") + .HasColumnType("date"); + + b.Property("SurgeryType") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("HealthArchives"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchiveSurgery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HealthArchiveId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("HealthArchiveId", "SortOrder"); + + b.ToTable("HealthArchiveSurgeries", (string)null); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Diastolic") + .HasColumnType("integer"); + + b.Property("IsAbnormal") + .HasColumnType("boolean"); + + b.Property("MetricType") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("Systolic") + .HasColumnType("integer"); + + b.Property("Unit") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "MetricType"); + + b.HasIndex("UserId", "RecordedAt") + .IsDescending(false, true); + + b.ToTable("HealthRecords"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Medication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Dosage") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.PrimitiveCollection>("TimeOfDay") + .IsRequired() + .HasColumnType("time[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsActive"); + + b.ToTable("Medications"); + }); + + modelBuilder.Entity("Health.Domain.Entities.MedicationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MedicationId") + .HasColumnType("uuid"); + + b.Property("ScheduledTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MedicationId", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("MedicationLogs"); + }); + + modelBuilder.Entity("Health.Domain.Entities.NotificationPreference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AbnormalAlert") + .HasColumnType("boolean"); + + b.Property("DoctorReply") + .HasColumnType("boolean"); + + b.Property("FollowUpReminder") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminder") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderBloodPressure") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderGlucose") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderHeartRate") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderSpO2") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderWeight") + .HasColumnType("boolean"); + + b.Property("MedicationReminder") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("NotificationPreferences"); + }); + + modelBuilder.Entity("Health.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Report", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiIndicators") + .HasColumnType("jsonb"); + + b.Property("AiSummary") + .HasColumnType("text"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorComment") + .HasColumnType("text"); + + b.Property("DoctorName") + .HasColumnType("text"); + + b.Property("DoctorRecommendation") + .HasColumnType("text"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Severity") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("Health.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppleUserId") + .HasColumnType("text"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorId") + .HasColumnType("uuid"); + + b.Property("Gender") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("User"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AppleUserId") + .IsUnique(); + + b.HasIndex("DoctorId"); + + b.HasIndex("Phone") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Health.Domain.Entities.UserNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionTargetId") + .HasColumnType("text"); + + b.Property("ActionType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SourceId") + .IsUnique(); + + b.HasIndex("UserId", "IsDeleted", "IsRead", "CreatedAt"); + + b.ToTable("UserNotifications", (string)null); + }); + + modelBuilder.Entity("Health.Domain.Entities.VerificationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsUsed") + .HasColumnType("boolean"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Phone", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("VerificationCodes"); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.AiWriteCommandRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Arguments") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ToolName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status", "ExpiresAt"); + + b.ToTable("AiWriteCommands", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.DietImageAnalysisTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FilePaths") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("ResultCode") + .HasColumnType("integer"); + + b.Property("ResultData") + .HasColumnType("text"); + + b.Property("ResultMessage") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Status", "AvailableAt"); + + b.ToTable("DietImageAnalysisTasks", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.MedicationReminderTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Dosage") + .HasColumnType("text"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("MedicationId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.Property("ScheduledTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Status", "AvailableAt"); + + b.HasIndex("MedicationId", "ScheduledDate", "ScheduledTime") + .IsUnique(); + + b.ToTable("MedicationReminderTasks", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.NotificationOutboxRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SourceTaskId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SourceTaskId") + .IsUnique(); + + b.HasIndex("Status", "AvailableAt"); + + b.ToTable("NotificationOutbox", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.ReportAnalysisTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("ReportId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ReportId"); + + b.HasIndex("Status", "AvailableAt"); + + b.ToTable("ReportAnalysisTasks", (string)null); + }); + + modelBuilder.Entity("Health.Domain.Entities.Consultation", b => + { + b.HasOne("Health.Domain.Entities.Doctor", "Doctor") + .WithMany("Consultations") + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Consultations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Doctor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConsultationMessage", b => + { + b.HasOne("Health.Domain.Entities.Consultation", "Consultation") + .WithMany("Messages") + .HasForeignKey("ConsultationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Consultation"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Conversation", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Conversations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConversationMessage", b => + { + b.HasOne("Health.Domain.Entities.Conversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DeviceToken", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("DeviceTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietFoodItem", b => + { + b.HasOne("Health.Domain.Entities.DietRecord", "DietRecord") + .WithMany("FoodItems") + .HasForeignKey("DietRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DietRecord"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietRecord", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("DietRecords") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DoctorProfile", b => + { + b.HasOne("Health.Domain.Entities.Doctor", "Doctor") + .WithMany() + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Health.Domain.Entities.User", "User") + .WithOne("DoctorProfile") + .HasForeignKey("Health.Domain.Entities.DoctorProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Doctor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlan", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("ExercisePlans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlanItem", b => + { + b.HasOne("Health.Domain.Entities.ExercisePlan", "Plan") + .WithMany("Items") + .HasForeignKey("PlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Plan"); + }); + + modelBuilder.Entity("Health.Domain.Entities.FollowUp", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("FollowUps") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchive", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithOne("HealthArchive") + .HasForeignKey("Health.Domain.Entities.HealthArchive", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchiveSurgery", b => + { + b.HasOne("Health.Domain.Entities.HealthArchive", "HealthArchive") + .WithMany("Surgeries") + .HasForeignKey("HealthArchiveId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("HealthArchive"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthRecord", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("HealthRecords") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Medication", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Medications") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.MedicationLog", b => + { + b.HasOne("Health.Domain.Entities.Medication", "Medication") + .WithMany("Logs") + .HasForeignKey("MedicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Medication"); + }); + + modelBuilder.Entity("Health.Domain.Entities.NotificationPreference", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithOne("NotificationPreference") + .HasForeignKey("Health.Domain.Entities.NotificationPreference", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Report", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Reports") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.User", b => + { + b.HasOne("Health.Domain.Entities.Doctor", "Doctor") + .WithMany() + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Doctor"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Consultation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Conversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietRecord", b => + { + b.Navigation("FoodItems"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Doctor", b => + { + b.Navigation("Consultations"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlan", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchive", b => + { + b.Navigation("Surgeries"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Medication", b => + { + b.Navigation("Logs"); + }); + + modelBuilder.Entity("Health.Domain.Entities.User", b => + { + b.Navigation("Consultations"); + + b.Navigation("Conversations"); + + b.Navigation("DeviceTokens"); + + b.Navigation("DietRecords"); + + b.Navigation("DoctorProfile"); + + b.Navigation("ExercisePlans"); + + b.Navigation("FollowUps"); + + b.Navigation("HealthArchive"); + + b.Navigation("HealthRecords"); + + b.Navigation("Medications"); + + b.Navigation("NotificationPreference"); + + b.Navigation("Reports"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Health.Infrastructure/Data/Migrations/20260710052811_AddAppleSignIn.cs b/backend/src/Health.Infrastructure/Data/Migrations/20260710052811_AddAppleSignIn.cs new file mode 100644 index 0000000..d89452a --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Migrations/20260710052811_AddAppleSignIn.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Health.Infrastructure.Data.Migrations +{ + /// + public partial class AddAppleSignIn : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Phone", + table: "Users", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AddColumn( + name: "AppleUserId", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_AppleUserId", + table: "Users", + column: "AppleUserId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Users_AppleUserId", + table: "Users"); + + migrationBuilder.DropColumn( + name: "AppleUserId", + table: "Users"); + + migrationBuilder.AlterColumn( + name: "Phone", + table: "Users", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + } + } +} diff --git a/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs index 0b7cb30..5511618 100644 --- a/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -819,6 +819,9 @@ namespace Health.Infrastructure.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("AppleUserId") + .HasColumnType("text"); + b.Property("AvatarUrl") .HasColumnType("text"); @@ -838,7 +841,6 @@ namespace Health.Infrastructure.Data.Migrations .HasColumnType("text"); b.Property("Phone") - .IsRequired() .HasColumnType("text"); b.Property("Role") @@ -853,6 +855,9 @@ namespace Health.Infrastructure.Data.Migrations b.HasKey("Id"); + b.HasIndex("AppleUserId") + .IsUnique(); + b.HasIndex("DoctorId"); b.HasIndex("Phone") diff --git a/backend/src/Health.Infrastructure/Data/app_db_context.cs b/backend/src/Health.Infrastructure/Data/app_db_context.cs index aac842a..298df71 100644 --- a/backend/src/Health.Infrastructure/Data/app_db_context.cs +++ b/backend/src/Health.Infrastructure/Data/app_db_context.cs @@ -50,6 +50,7 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon builder.Entity(e => { e.HasIndex(u => u.Phone).IsUnique(); + e.HasIndex(u => u.AppleUserId).IsUnique(); // Apple Sign-In 唯一标识 e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User"); e.HasOne(u => u.Doctor).WithMany().HasForeignKey(u => u.DoctorId).IsRequired(false).OnDelete(DeleteBehavior.SetNull); }); diff --git a/backend/src/Health.Infrastructure/Services/apple_token_validator.cs b/backend/src/Health.Infrastructure/Services/apple_token_validator.cs new file mode 100644 index 0000000..8c685ce --- /dev/null +++ b/backend/src/Health.Infrastructure/Services/apple_token_validator.cs @@ -0,0 +1,78 @@ +using System.IdentityModel.Tokens.Jwt; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; + +namespace Health.Infrastructure.Services; + +/// +/// Apple IdentityToken 验证:下载 Apple 公钥、验签、检查声明 +/// +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(); + // 先解码获取 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), + }; + + handler.ValidateToken(identityToken, validationParams, out _); + // 从原始 JWT 取 sub(避免 .NET 的 ClaimType 映射把 "sub" 转成 NameIdentifier) + var sub = rawToken.Claims.FirstOrDefault(c => c.Type == "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(); } + } +} diff --git a/backend/src/Health.WebApi/Endpoints/auth_endpoints.cs b/backend/src/Health.WebApi/Endpoints/auth_endpoints.cs index 24f2501..c535a0f 100644 --- a/backend/src/Health.WebApi/Endpoints/auth_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/auth_endpoints.cs @@ -12,6 +12,8 @@ public static class AuthEndpoints 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) => @@ -28,4 +30,5 @@ public static class AuthEndpoints 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); diff --git a/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs b/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs index 45d0447..63db481 100644 --- a/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs @@ -125,7 +125,7 @@ public static class DoctorEndpoints { query = query.Where(u => (u.Name != null && u.Name.Contains(search)) || - u.Phone.Contains(search)); + (u.Phone != null && u.Phone.Contains(search))); } var total = await query.CountAsync(); var patients = await query.OrderByDescending(u => u.CreatedAt) diff --git a/backend/src/Health.WebApi/Program.cs b/backend/src/Health.WebApi/Program.cs index a20f27e..c0c90a3 100644 --- a/backend/src/Health.WebApi/Program.cs +++ b/backend/src/Health.WebApi/Program.cs @@ -108,6 +108,7 @@ builder.Services.AddAuthorization(); // ---- 业务服务 ---- builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Apple Sign-In JWT 验证 builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/backend/src/Health.WebApi/appsettings.Development.json b/backend/src/Health.WebApi/appsettings.Development.json index 0c208ae..bcdd3fc 100644 --- a/backend/src/Health.WebApi/appsettings.Development.json +++ b/backend/src/Health.WebApi/appsettings.Development.json @@ -1,7 +1,7 @@ { "Logging": { "LogLevel": { - "Default": "Information", + "Default": "Warning", "Microsoft.AspNetCore": "Warning" } } diff --git a/docs/superpowers/plans/2026-07-10-apple-sign-in.md b/docs/superpowers/plans/2026-07-10-apple-sign-in.md new file mode 100644 index 0000000..186eccb --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-apple-sign-in.md @@ -0,0 +1,688 @@ +# 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; +``` diff --git a/health_app/android/app/build.gradle.kts b/health_app/android/app/build.gradle.kts index b69a7ca..6169d34 100644 --- a/health_app/android/app/build.gradle.kts +++ b/health_app/android/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") @@ -5,6 +7,13 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +// 加载正式签名配置 +val keystorePropertiesFile = rootProject.file("key.properties") +val keystoreProperties = Properties() +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(keystorePropertiesFile.inputStream()) +} + android { namespace = "com.datalumina.YYA" compileSdk = flutter.compileSdkVersion @@ -20,21 +29,30 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.datalumina.YYA" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } + // 正式签名配置 + signingConfigs { + create("release") { + if (keystorePropertiesFile.exists()) { + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + storeFile = keystoreProperties.getProperty("storeFile")?.let { path -> + file(path) + } + storePassword = keystoreProperties.getProperty("storePassword") + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + signingConfig = signingConfigs.getByName("release") } } } diff --git a/health_app/android/app/src/main/AndroidManifest.xml b/health_app/android/app/src/main/AndroidManifest.xml index 618008f..b3b64a0 100644 --- a/health_app/android/app/src/main/AndroidManifest.xml +++ b/health_app/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + diff --git a/health_app/appinfo b/health_app/appinfo new file mode 100644 index 0000000..a72f553 --- /dev/null +++ b/health_app/appinfo @@ -0,0 +1,12 @@ +IOS: +SHA1: +B1:17:1D:FD:7E:CA:CE:61:65:C2:2D:8B:A0:7C:57:5A:EB:EF:20:5E +SHA256: +D0:1E:AF:24:40:06:42:1B:FA:86:5E:3D:90:ED:FA:D7:3F:2D:E2:99:52:60:58:F4:85:4F:18:95:1E:B9:CC:96 +MD5: +15:3B:89:ED:6B:C2:1B:6A:F9:6D:E0:71:44:54:CB:0F + +Android: +SHA1: 48:48:22:D2:DA:B5:0D:4B:3F:D6:CA:8C:77:B1:36:B5:B5:8F:47:D1 +SHA256: 0F:10:E7:B5:D0:8A:1E:4B:A6:67:EB:D4:D4:7F:00:C1:08:7E:EB:46:72:51:73:B2:AC:30:DB:69:18:DD:15:A2 +MD5:D9:75:8F:17:66:17:3F:83:49:1A:82:34:59:9A:08:25 \ No newline at end of file diff --git a/health_app/ios/Flutter/AppFrameworkInfo.plist b/health_app/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf7..391a902 100644 --- a/health_app/ios/Flutter/AppFrameworkInfo.plist +++ b/health_app/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/health_app/ios/Flutter/Debug.xcconfig b/health_app/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/health_app/ios/Flutter/Debug.xcconfig +++ b/health_app/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/health_app/ios/Flutter/Release.xcconfig b/health_app/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/health_app/ios/Flutter/Release.xcconfig +++ b/health_app/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/health_app/ios/Podfile b/health_app/ios/Podfile new file mode 100644 index 0000000..131d5f5 --- /dev/null +++ b/health_app/ios/Podfile @@ -0,0 +1,43 @@ +# Apple Sign-In requires CocoaPods (plugin not yet on SPM) +platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/health_app/ios/Podfile.lock b/health_app/ios/Podfile.lock new file mode 100644 index 0000000..7f1f694 --- /dev/null +++ b/health_app/ios/Podfile.lock @@ -0,0 +1,22 @@ +PODS: + - Flutter (1.0.0) + - sign_in_with_apple (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + sign_in_with_apple: + :path: ".symlinks/plugins/sign_in_with_apple/ios" + +SPEC CHECKSUMS: + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 + +PODFILE CHECKSUM: 261798c42a06ff769f68b1209723cd96e5586217 + +COCOAPODS: 1.16.2 diff --git a/health_app/ios/Runner.xcodeproj/project.pbxproj b/health_app/ios/Runner.xcodeproj/project.pbxproj index 7f2721f..5acccbc 100644 --- a/health_app/ios/Runner.xcodeproj/project.pbxproj +++ b/health_app/ios/Runner.xcodeproj/project.pbxproj @@ -11,9 +11,12 @@ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B79095E86F02076D8BE3225F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C5A7F45AA4D485688D46789 /* Pods_Runner.framework */; }; + D33A34F4E410B032A1D843E3 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C6D436F0A8387C8ED95A60B0 /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -42,12 +45,18 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 2C5A7F45AA4D485688D46789 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 5D74CE3B9D4E625B6EC3403A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 6331745AE8BC5EE4BCD290B9 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 65BAE750C7D318CC27513494 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 93E70FBCBD3EC11A08FAD800 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -55,19 +64,46 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C52233F0D1C275E97BE00A91 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + C6D436F0A8387C8ED95A60B0 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + ECE998D63CAE2AC9FF401968 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 5B6028AD445E1E5F91C5C300 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D33A34F4E410B032A1D843E3 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + B79095E86F02076D8BE3225F /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 04069A07808CEF12713E8A9C /* Pods */ = { + isa = PBXGroup; + children = ( + ECE998D63CAE2AC9FF401968 /* Pods-Runner.debug.xcconfig */, + 6331745AE8BC5EE4BCD290B9 /* Pods-Runner.release.xcconfig */, + 65BAE750C7D318CC27513494 /* Pods-Runner.profile.xcconfig */, + 5D74CE3B9D4E625B6EC3403A /* Pods-RunnerTests.debug.xcconfig */, + 93E70FBCBD3EC11A08FAD800 /* Pods-RunnerTests.release.xcconfig */, + C52233F0D1C275E97BE00A91 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -76,9 +112,19 @@ path = RunnerTests; sourceTree = ""; }; + 789F6ED1EF63DAEA6E803F1D /* Frameworks */ = { + isa = PBXGroup; + children = ( + 2C5A7F45AA4D485688D46789 /* Pods_Runner.framework */, + C6D436F0A8387C8ED95A60B0 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -94,6 +140,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + 04069A07808CEF12713E8A9C /* Pods */, + 789F6ED1EF63DAEA6E803F1D /* Frameworks */, ); sourceTree = ""; }; @@ -128,8 +176,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + C41A5F4922F3BF2ACAF8E553 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 5B6028AD445E1E5F91C5C300 /* Frameworks */, ); buildRules = ( ); @@ -145,18 +195,23 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 0A8FFE29BE998F8F679CF732 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + B60F509C791DA959BCD8E827 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -190,6 +245,9 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -222,6 +280,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 0A8FFE29BE998F8F679CF732 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -253,6 +333,45 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + B60F509C791DA959BCD8E827 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + C41A5F4922F3BF2ACAF8E553 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -348,8 +467,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; @@ -361,14 +479,16 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = KBM2RZ74VR; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp; + PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -378,13 +498,14 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 5D74CE3B9D4E625B6EC3403A /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -395,13 +516,14 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 93E70FBCBD3EC11A08FAD800 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -410,13 +532,14 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = C52233F0D1C275E97BE00A91 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -475,7 +598,6 @@ IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -525,8 +647,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; @@ -540,14 +661,16 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = KBM2RZ74VR; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp; + PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -562,14 +685,16 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = KBM2RZ74VR; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp; + PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -611,6 +736,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/health_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/health_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..4d7193e --- /dev/null +++ b/health_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,59 @@ +{ + "pins" : [ + { + "identity" : "dkcamera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKCamera", + "state" : { + "branch" : "master", + "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" + } + }, + { + "identity" : "dkimagepickercontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKImagePickerController", + "state" : { + "branch" : "4.3.9", + "revision" : "0bdfeacefa308545adde07bef86e349186335915" + } + }, + { + "identity" : "dkphotogallery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKPhotoGallery", + "state" : { + "branch" : "master", + "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif.git", + "state" : { + "revision" : "4430cbc148baa3907651d40562d96325426f409a", + "version" : "5.4.5" + } + }, + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController", + "state" : { + "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", + "version" : "2.8.0" + } + } + ], + "version" : 2 +} diff --git a/health_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/health_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d4..95d6e55 100644 --- a/health_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/health_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,10 +1,28 @@ + version = "1.7"> + + + + + + + + + + + + diff --git a/health_app/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/health_app/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..4d7193e --- /dev/null +++ b/health_app/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,59 @@ +{ + "pins" : [ + { + "identity" : "dkcamera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKCamera", + "state" : { + "branch" : "master", + "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" + } + }, + { + "identity" : "dkimagepickercontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKImagePickerController", + "state" : { + "branch" : "4.3.9", + "revision" : "0bdfeacefa308545adde07bef86e349186335915" + } + }, + { + "identity" : "dkphotogallery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKPhotoGallery", + "state" : { + "branch" : "master", + "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif.git", + "state" : { + "revision" : "4430cbc148baa3907651d40562d96325426f409a", + "version" : "5.4.5" + } + }, + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController", + "state" : { + "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", + "version" : "2.8.0" + } + } + ], + "version" : 2 +} diff --git a/health_app/ios/Runner/AppDelegate.swift b/health_app/ios/Runner/AppDelegate.swift index 6266644..c30b367 100644 --- a/health_app/ios/Runner/AppDelegate.swift +++ b/health_app/ios/Runner/AppDelegate.swift @@ -2,12 +2,15 @@ import Flutter import UIKit @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/health_app/ios/Runner/Info.plist b/health_app/ios/Runner/Info.plist index 25d462c..a6f4a5c 100644 --- a/health_app/ios/Runner/Info.plist +++ b/health_app/ios/Runner/Info.plist @@ -2,10 +2,12 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Health App + 小脉健康 CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -13,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - health_app + 小脉健康 CFBundlePackageType APPL CFBundleShortVersionString @@ -24,6 +26,39 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSBluetoothAlwaysUsageDescription + 需要使用蓝牙连接欧姆龙血压计以同步测量数据 + NSBluetoothPeripheralUsageDescription + 需要使用蓝牙连接血压计 + NSCameraUsageDescription + 需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录 + NSPhotoLibraryAddUsageDescription + 需要保存图片到相册 + NSPhotoLibraryUsageDescription + 需要访问相册以选取饮食照片、体检报告或个人头像 + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,13 +76,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - NSBluetoothAlwaysUsageDescription - 需要使用蓝牙连接欧姆龙血压计以同步测量数据 - NSBluetoothPeripheralUsageDescription - 需要使用蓝牙连接血压计 - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/health_app/ios/Runner/Runner.entitlements b/health_app/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..a812db5 --- /dev/null +++ b/health_app/ios/Runner/Runner.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.applesignin + + Default + + + diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart index 4a12f10..87e3cb3 100644 --- a/health_app/lib/core/api_client.dart +++ b/health_app/lib/core/api_client.dart @@ -1,12 +1,15 @@ import 'dart:developer'; import 'dart:io'; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart' show kReleaseMode; import 'local_database.dart'; -/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。 +/// API 基础地址(生产模式)。本地开发可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。 const String baseUrl = String.fromEnvironment( 'API_BASE_URL', - defaultValue: 'http://10.4.237.12:5000', + defaultValue: kReleaseMode + ? 'https://erpapi.datalumina.cn/xiaomai' + : 'http://10.4.237.12:5000', ); class ApiException implements Exception { diff --git a/health_app/lib/pages/auth/login_page.dart b/health_app/lib/pages/auth/login_page.dart index ec990f4..eb8a767 100644 --- a/health_app/lib/pages/auth/login_page.dart +++ b/health_app/lib/pages/auth/login_page.dart @@ -1,7 +1,10 @@ -import 'package:flutter/material.dart'; +import 'dart:io' show Platform; + import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:sign_in_with_apple/sign_in_with_apple.dart'; import '../../core/app_colors.dart'; import '../../core/app_design_tokens.dart'; import '../../core/navigation_provider.dart'; @@ -73,6 +76,66 @@ class _LoginPageState extends ConsumerState { } } + // Apple 登录 + Future _appleSignIn() async { + if (_loading) return; + if (!_agreed) { + final accepted = await _showAgreementDialog(); + if (!mounted || accepted != true) return; + setState(() => _agreed = true); + } + setState(() { + _loading = true; + _error = null; + _successMsg = null; + }); + + try { + final credential = await SignInWithApple.getAppleIDCredential( + scopes: [AppleIDAuthorizationScopes.fullName], + ); + + final identityToken = credential.identityToken; + if (identityToken == null) { + if (mounted) { + setState(() { + _loading = false; + _error = 'Apple 登录未返回身份信息,请重试'; + }); + } + return; + } + + final name = '${credential.familyName ?? ''}${credential.givenName ?? ''}' + .trim(); + + final err = await ref + .read(authProvider.notifier) + .appleLogin( + identityToken, + credential.authorizationCode, + name.isEmpty ? null : name, + ); + if (mounted) { + setState(() => _loading = false); + if (err != null) { + setState(() => _error = err); + } + } + } catch (e) { + if (mounted) { + setState(() { + _loading = false; + if (e is SignInWithAppleAuthorizationException) { + _error = 'Apple 登录已取消'; + } else { + _error = 'Apple 登录失败,请稍后重试'; + } + }); + } + } + } + Future _submit() async { if (_phoneCtrl.text.trim().isEmpty || _codeCtrl.text.trim().isEmpty) { setState(() => _error = '请输入手机号和验证码'); @@ -500,6 +563,20 @@ class _LoginPageState extends ConsumerState { label: _isLogin ? '登录' : '注册', onTap: _loading ? null : _submit, ), + if (_isLogin && + (Platform.isIOS || + Platform.isMacOS)) ...[ + const SizedBox(height: 20), + SignInWithAppleButton( + onPressed: _loading + ? () {} + : _appleSignIn, + style: SignInWithAppleButtonStyle + .whiteOutlined, + height: 48, + text: '通过 Apple 登录', + ), + ], const SizedBox(height: 16), GestureDetector( onTap: () => setState(() { diff --git a/health_app/lib/providers/auth_provider.dart b/health_app/lib/providers/auth_provider.dart index 938dd62..07a68f8 100644 --- a/health_app/lib/providers/auth_provider.dart +++ b/health_app/lib/providers/auth_provider.dart @@ -213,6 +213,44 @@ class AuthNotifier extends Notifier { } } + /// 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, + 'authorizationCode': authorizationCode, + '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 (_) { + return 'Apple 登录失败,请稍后重试'; + } + } + /// 登出 Future logout() async { final api = ref.read(apiClientProvider); diff --git a/health_app/pubspec.lock b/health_app/pubspec.lock index 2a7670a..0432735 100644 --- a/health_app/pubspec.lock +++ b/health_app/pubspec.lock @@ -901,6 +901,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + sign_in_with_apple: + dependency: "direct main" + description: + name: sign_in_with_apple + sha256: e84a62e17b7e463abf0a64ce826c2cd1f0b72dff07b7b275e32d5302d76fb4c5 + url: "https://pub.dev" + source: hosted + version: "6.1.4" + sign_in_with_apple_platform_interface: + dependency: transitive + description: + name: sign_in_with_apple_platform_interface + sha256: c2ef2ce6273fce0c61acd7e9ff5be7181e33d7aa2b66508b39418b786cca2119 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + sign_in_with_apple_web: + dependency: transitive + description: + name: sign_in_with_apple_web + sha256: "2f7c38368f49e3f2043bca4b46a4a61aaae568c140a79aa0675dc59ad0ca49bc" + url: "https://pub.dev" + source: hosted + version: "2.1.1" signalr_netcore: dependency: "direct main" description: diff --git a/health_app/pubspec.yaml b/health_app/pubspec.yaml index c2992c2..cb41e2c 100644 --- a/health_app/pubspec.yaml +++ b/health_app/pubspec.yaml @@ -39,6 +39,9 @@ dependencies: flutter_blue_plus: ^1.34.0 permission_handler: ^11.3.0 + # Apple 登录 + sign_in_with_apple: ^6.1.0 + # 基础图标 cupertino_icons: ^1.0.8 signalr_netcore: ^1.4.4 diff --git a/health_app/test/secondary_page_visuals_test.dart b/health_app/test/secondary_page_visuals_test.dart index bfb6231..d97907d 100644 --- a/health_app/test/secondary_page_visuals_test.dart +++ b/health_app/test/secondary_page_visuals_test.dart @@ -85,7 +85,9 @@ void main() { test( 'login and registration keep the image background with neutral forms', () { - final login = File('lib/pages/auth/login_page.dart').readAsStringSync(); + final login = File( + 'lib/pages/auth/login_page.dart', + ).readAsStringSync().replaceAll('\r\n', '\n'); expect(login, contains("Image.asset(\n _loginBg")); expect(login, contains('Colors.white.withValues(alpha: 0.88)'));