merge: integrate sccsbc release preparation safely

This commit is contained in:
MingNian
2026-07-16 00:41:52 +08:00
38 changed files with 2974 additions and 45 deletions

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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; }

View File

@@ -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 })

View File

@@ -3,7 +3,12 @@ using Health.Infrastructure.Services;
namespace Health.Infrastructure.Auth;
public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms) : IAuthService
public sealed class AuthService(
AppDbContext db,
JwtProvider jwt,
SmsService sms,
AppleTokenValidator appleValidator // Apple Sign-In JWT 验证
) : IAuthService
{
private const string AdminPhone = "12345678910";
private const string AdminSmsCode = "000000";
@@ -11,6 +16,7 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
private readonly AppDbContext _db = db;
private readonly JwtProvider _jwt = jwt;
private readonly SmsService _sms = sms;
private readonly AppleTokenValidator _appleValidator = appleValidator;
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
{
@@ -99,13 +105,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);

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}
}
}

View File

@@ -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")

View File

@@ -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);
});

View File

@@ -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(); }
}
}

View File

@@ -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);

View File

@@ -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)

View File

@@ -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>();

View File

@@ -1,7 +1,7 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Default": "Warning",
"Microsoft.AspNetCore": "Warning"
}
}