merge: integrate sccsbc release preparation safely
This commit is contained in:
@@ -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<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
|
||||
Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct);
|
||||
Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct);
|
||||
Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct);
|
||||
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct);
|
||||
Task LogoutAsync(string refreshToken, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -3,7 +3,12 @@ using Health.Infrastructure.Services;
|
||||
|
||||
namespace Health.Infrastructure.Auth;
|
||||
|
||||
public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms) : IAuthService
|
||||
public sealed class AuthService(
|
||||
AppDbContext db,
|
||||
JwtProvider jwt,
|
||||
SmsService sms,
|
||||
AppleTokenValidator appleValidator // Apple Sign-In JWT 验证
|
||||
) : IAuthService
|
||||
{
|
||||
private const string AdminPhone = "12345678910";
|
||||
private const string AdminSmsCode = "000000";
|
||||
@@ -11,6 +16,7 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
|
||||
private readonly AppDbContext _db = db;
|
||||
private readonly JwtProvider _jwt = jwt;
|
||||
private readonly SmsService _sms = sms;
|
||||
private readonly AppleTokenValidator _appleValidator = appleValidator;
|
||||
|
||||
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
|
||||
{
|
||||
@@ -99,13 +105,68 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct)
|
||||
{
|
||||
// 1. 验证 Apple identityToken
|
||||
string appleUserId;
|
||||
try
|
||||
{
|
||||
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
|
||||
}
|
||||
catch (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<VerificationCode?> TakeCodeAsync(string phone, string code, CancellationToken ct) =>
|
||||
_db.VerificationCodes.Where(x => x.Phone == phone && x.Code == code && x.ExpiresAt > DateTime.UtcNow && !x.IsUsed)
|
||||
.OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct);
|
||||
|
||||
private (string accessToken, string refreshToken) AddTokens(Guid userId, string phone, string role)
|
||||
private (string accessToken, string refreshToken) AddTokens(Guid userId, string? phone, string role)
|
||||
{
|
||||
var access = _jwt.GenerateAccessToken(userId, phone, role);
|
||||
var access = _jwt.GenerateAccessToken(userId, phone ?? "", role);
|
||||
var refresh = _jwt.GenerateRefreshToken();
|
||||
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
|
||||
return (access, refresh);
|
||||
|
||||
1466
backend/src/Health.Infrastructure/Data/Migrations/20260710052811_AddAppleSignIn.Designer.cs
generated
Normal file
1466
backend/src/Health.Infrastructure/Data/Migrations/20260710052811_AddAppleSignIn.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Health.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAppleSignIn : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Phone",
|
||||
table: "Users",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AppleUserId",
|
||||
table: "Users",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_AppleUserId",
|
||||
table: "Users",
|
||||
column: "AppleUserId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Users_AppleUserId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AppleUserId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Phone",
|
||||
table: "Users",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -819,6 +819,9 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AppleUserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -838,7 +841,6 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Role")
|
||||
@@ -853,6 +855,9 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AppleUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("DoctorId");
|
||||
|
||||
b.HasIndex("Phone")
|
||||
|
||||
@@ -50,6 +50,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
builder.Entity<User>(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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Health.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Apple IdentityToken 验证:下载 Apple 公钥、验签、检查声明
|
||||
/// </summary>
|
||||
public sealed class AppleTokenValidator
|
||||
{
|
||||
private const string AppleKeysUrl = "https://appleid.apple.com/auth/keys";
|
||||
private const string AppleIssuer = "https://appleid.apple.com";
|
||||
private static readonly HttpClient _http = new();
|
||||
private static List<JsonWebKey>? _cachedKeys;
|
||||
private static DateTime _keysExpiry = DateTime.MinValue;
|
||||
private static readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
private readonly string _clientId;
|
||||
|
||||
public AppleTokenValidator(IConfiguration config)
|
||||
{
|
||||
_clientId = config["APPLE_CLIENT_ID"] ?? "com.datalumina.YYA";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 Apple identityToken,返回 sub(用户唯一标识)
|
||||
/// </summary>
|
||||
public async Task<string> ValidateAsync(string identityToken, CancellationToken ct)
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
// 先解码获取 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<List<JsonWebKey>> GetKeysAsync(CancellationToken ct)
|
||||
{
|
||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
||||
|
||||
await _lock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
||||
|
||||
var response = await _http.GetStringAsync(AppleKeysUrl, ct);
|
||||
var jwks = new JsonWebKeySet(response);
|
||||
_cachedKeys = jwks.Keys.ToList();
|
||||
_keysExpiry = DateTime.UtcNow.AddHours(6); // 缓存 6 小时
|
||||
return _cachedKeys;
|
||||
}
|
||||
finally { _lock.Release(); }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -108,6 +108,7 @@ builder.Services.AddAuthorization();
|
||||
// ---- 业务服务 ----
|
||||
builder.Services.AddSingleton<JwtProvider>();
|
||||
builder.Services.AddSingleton<SmsService>();
|
||||
builder.Services.AddSingleton<AppleTokenValidator>(); // Apple Sign-In JWT 验证
|
||||
builder.Services.AddSingleton<PromptManager>();
|
||||
builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>();
|
||||
builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Default": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user