using System.IO.Compression; using System.Security.Claims; using System.Text; using System.Text.Json; using MiaoJiZhang.Api.Contracts; using MiaoJiZhang.Domain.Entities; using MiaoJiZhang.Api.Services; using MiaoJiZhang.Domain.Enums; using MiaoJiZhang.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace MiaoJiZhang.Api.Controllers; [ApiController] [Authorize] [Route("api/users/me")] public class UsersController( AppDbContext db, JwtService jwt, AiPermissionService aiPermissions) : ControllerBase { private long CurrentUserId => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!); /// 当前用户信息(含 AI 伙伴设置与模式) [HttpGet] public async Task> Me() { var user = await db.Users .Include(u => u.AiCompanion) .Include(u => u.FeaturePermissions) .FirstOrDefaultAsync(u => u.Id == CurrentUserId); if (user is null) return NotFound(new ApiError("USER_NOT_FOUND", "用户不存在")); return Ok(ToProfile(user)); } /// 完成首次引导:选模式 + 选 AI 伙伴(形象/性格/昵称) [HttpPost("onboarding")] public async Task> Onboarding(OnboardingRequest req) { var user = await db.Users .Include(u => u.AiCompanion) .Include(u => u.FeaturePermissions) .FirstOrDefaultAsync(u => u.Id == CurrentUserId); if (user is null) return NotFound(new ApiError("USER_NOT_FOUND", "用户不存在")); if (!await aiPermissions.IsEnabledAsync(CurrentUserId)) { user.AppMode = AppMode.Normal; await db.SaveChangesAsync(); return Ok(ToProfile(user)); } if (!await db.AiAvatars.AnyAsync(a => a.Key == req.AvatarKey && a.IsEnabled)) return BadRequest(new ApiError("AVATAR_INVALID", "形象不存在")); if (!await db.AiPersonas.AnyAsync(p => p.Key == req.PersonaKey && p.IsEnabled)) return BadRequest(new ApiError("PERSONA_INVALID", "性格不存在")); user.AppMode = req.AppMode == "ai" ? AppMode.AiFirst : AppMode.Normal; user.AiCompanion ??= new AiCompanionSetting { UserId = user.Id }; user.AiCompanion.AvatarKey = req.AvatarKey; user.AiCompanion.PersonaKey = req.PersonaKey; user.AiCompanion.CustomName = string.IsNullOrWhiteSpace(req.CustomName) ? null : req.CustomName.Trim(); await db.SaveChangesAsync(); return Ok(ToProfile(user)); } /// 切换 App 模式(决策:双模式可随时切换) [HttpPut("mode")] public async Task> SwitchMode(SwitchModeRequest req) { var user = await db.Users .Include(u => u.AiCompanion) .Include(u => u.FeaturePermissions) .FirstOrDefaultAsync(u => u.Id == CurrentUserId); if (user is null) return NotFound(new ApiError("USER_NOT_FOUND", "用户不存在")); if (req.AppMode == "ai" && !await aiPermissions.IsEnabledAsync(CurrentUserId)) return StatusCode(403, new ApiError("AI_PERMISSION_DENIED", "当前账号未开通 AI 功能")); user.AppMode = req.AppMode == "ai" ? AppMode.AiFirst : AppMode.Normal; await db.SaveChangesAsync(); return Ok(ToProfile(user)); } /// 更新 AI 伙伴设置(性格设置页 P5) [HttpPut("companion")] public async Task> UpdateCompanion(UpdateCompanionRequest req) { var user = await db.Users .Include(u => u.AiCompanion) .Include(u => u.FeaturePermissions) .FirstOrDefaultAsync(u => u.Id == CurrentUserId); if (user is null) return NotFound(new ApiError("USER_NOT_FOUND", "用户不存在")); if (!await aiPermissions.IsEnabledAsync(CurrentUserId)) return StatusCode(403, new ApiError("AI_PERMISSION_DENIED", "当前账号未开通 AI 功能")); if (user.AiCompanion is null) return BadRequest(new ApiError("ONBOARDING_REQUIRED", "请先完成引导")); if (req.AvatarKey != null) { if (!await db.AiAvatars.AnyAsync(a => a.Key == req.AvatarKey && a.IsEnabled)) return BadRequest(new ApiError("AVATAR_INVALID", "形象不存在")); user.AiCompanion.AvatarKey = req.AvatarKey; } if (req.PersonaKey != null) { if (!await db.AiPersonas.AnyAsync(p => p.Key == req.PersonaKey && p.IsEnabled)) return BadRequest(new ApiError("PERSONA_INVALID", "性格不存在")); user.AiCompanion.PersonaKey = req.PersonaKey; } if (req.CustomName != null) user.AiCompanion.CustomName = string.IsNullOrWhiteSpace(req.CustomName) ? null : req.CustomName.Trim(); if (req.RoastLevel is >= 0 and <= 100) user.AiCompanion.RoastLevel = req.RoastLevel.Value; if (req.StickerFrequency is >= 0 and <= 100) user.AiCompanion.StickerFrequency = req.StickerFrequency.Value; if (req.ProactiveLevel is >= 0 and <= 100) user.AiCompanion.ProactiveLevel = req.ProactiveLevel.Value; await db.SaveChangesAsync(); return Ok(ToProfile(user)); } [HttpPut("profile")] public async Task> UpdateProfile(UpdateProfileRequest req) { var user = await db.Users.Include(u => u.AiCompanion).Include(u => u.FeaturePermissions) .FirstOrDefaultAsync(u => u.Id == CurrentUserId); if (user is null) return NotFound(new ApiError("USER_NOT_FOUND", "用户不存在")); var nickname = req.Nickname?.Trim(); if (nickname?.Length > 32) return BadRequest(new ApiError("NICKNAME_INVALID", "昵称最多 32 个字")); user.Nickname = string.IsNullOrWhiteSpace(nickname) ? null : nickname; await db.SaveChangesAsync(); return Ok(ToProfile(user)); } [HttpPut("password")] public async Task> ChangePassword(ChangePasswordRequest req) { if (req.NewPassword.Length < 6) return BadRequest(new ApiError("PASSWORD_TOO_SHORT", "新密码至少 6 位")); var user = await db.Users.FirstOrDefaultAsync(u => u.Id == CurrentUserId); if (user is null) return NotFound(new ApiError("USER_NOT_FOUND", "用户不存在")); if (!BCrypt.Net.BCrypt.Verify(req.CurrentPassword, user.PasswordHash)) return Unauthorized(new ApiError("BAD_CREDENTIALS", "当前密码错误")); user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword); user.AuthVersion++; await db.SaveChangesAsync(); var (token, expires) = jwt.Issue(user); return Ok(new AuthResponse(user.Id, user.Username, token, expires)); } [HttpGet("export")] public async Task Export() { var userId = CurrentUserId; var user = await db.Users.Include(u => u.AiCompanion).Include(u => u.FeaturePermissions) .FirstOrDefaultAsync(u => u.Id == userId); if (user is null) return NotFound(new ApiError("USER_NOT_FOUND", "用户不存在")); var ledgers = await db.Ledgers.Where(l => l.OwnerId == userId) .OrderBy(l => l.Id).ToListAsync(); var transactions = await db.Transactions.IgnoreQueryFilters() .Include(t => t.Category) .Where(t => t.UserId == userId) .OrderBy(t => t.Id).ToListAsync(); var budgets = await db.Budgets.Where(b => b.UserId == userId) .OrderBy(b => b.Id).ToListAsync(); var categories = await db.Categories .Where(c => c.UserId == null || c.UserId == userId) .OrderBy(c => c.Type).ThenBy(c => c.SortOrder).ToListAsync(); var messages = await db.ChatMessages.Where(m => m.UserId == userId) .OrderBy(m => m.Id).ToListAsync(); var snapshot = new { formatVersion = 1, exportedAt = DateTime.UtcNow, timezone = "Asia/Shanghai", user = new { user.Id, user.Username, user.Nickname, appMode = user.AppMode.ToString(), user.CreatedAt, companion = user.AiCompanion is null ? null : new { user.AiCompanion.AvatarKey, user.AiCompanion.CustomName, user.AiCompanion.PersonaKey, user.AiCompanion.RoastLevel, user.AiCompanion.StickerFrequency, }, }, ledgers = ledgers.Select(l => new { l.Id, l.Name, l.IconKey, l.IsDefault, l.CreatedAt, }), categories = categories.Select(c => new { c.Id, c.UserId, type = c.Type.ToString().ToLowerInvariant(), c.Name, c.IconKey, c.ColorKey, c.SortOrder, c.IsDeleted, }), transactions = transactions.Select(t => new { t.Id, t.LedgerId, t.CategoryId, type = t.Type.ToString().ToLowerInvariant(), transferDirection = t.TransferDirection.ToWire(), t.Counterparty, t.Amount, t.Note, t.PaymentMethod, t.OccurredAt, t.Provider, t.ProviderTransactionId, t.RecognitionOccurrenceId, t.EvidenceFingerprint, t.RecognitionConfidence, source = t.Source.ToString(), t.SourceText, t.IsDeleted, t.DeletedAt, t.CreatedAt, t.UpdatedAt, }), budgets = budgets.Select(b => new { b.Id, b.LedgerId, b.CategoryId, b.Period, b.Amount, }), chatMessages = messages.Select(m => new { m.Id, role = m.Role.ToString(), type = m.Type.ToString(), m.Content, m.TransactionId, m.CreatedAt, }), }; var ledgerNames = ledgers.ToDictionary(l => l.Id, l => l.Name); var transactionCsv = new StringBuilder( "ID,账本,类型,转账方向,对方,金额,分类,备注,支付方式,发生时间,来源,已删除\r\n"); foreach (var tx in transactions) { transactionCsv.AppendJoin(',', new[] { Csv(tx.Id), Csv(ledgerNames.GetValueOrDefault(tx.LedgerId, "")), Csv(tx.Type switch { TransactionType.Income => "收入", TransactionType.Transfer => "转账", _ => "支出", }), Csv(tx.TransferDirection switch { TransferDirection.In => "转入", TransferDirection.Out => "转出", _ => "", }), Csv(tx.Counterparty), Csv(tx.Amount), Csv(tx.Category.Name), Csv(tx.Note), Csv(tx.PaymentMethod), Csv(ChinaClock.ToLocal(tx.OccurredAt).ToString("yyyy-MM-dd HH:mm:ss")), Csv(tx.Source.ToString()), Csv(tx.IsDeleted ? "是" : "否"), }).Append("\r\n"); } var categoryNames = categories.ToDictionary(c => c.Id, c => c.Name); var budgetCsv = new StringBuilder("ID,账本,周期,分类,金额\r\n"); foreach (var budget in budgets) { budgetCsv.AppendJoin(',', new[] { Csv(budget.Id), Csv(ledgerNames.GetValueOrDefault(budget.LedgerId, "")), Csv(budget.Period == 0 ? "周期预算" : budget.Period.ToString()), Csv(budget.CategoryId.HasValue ? categoryNames.GetValueOrDefault(budget.CategoryId.Value, "") : "总预算"), Csv(budget.Amount), }).Append("\r\n"); } await using var output = new MemoryStream(); using (var archive = new ZipArchive(output, ZipArchiveMode.Create, true)) { await WriteEntry(archive, "transactions.csv", transactionCsv.ToString(), new UTF8Encoding(true)); await WriteEntry(archive, "budgets.csv", budgetCsv.ToString(), new UTF8Encoding(true)); var json = JsonSerializer.Serialize( snapshot, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true }); await WriteEntry(archive, "backup.json", json, new UTF8Encoding(false)); } return File( output.ToArray(), "application/zip", $"miaoji-export-{ChinaClock.Now:yyyyMMdd-HHmmss}.zip"); } [HttpPost("closure")] public Task RequestAccountClosure( DeleteAccountRequest request) => ScheduleAccountClosure(request, requireConfirmation: true); // 兼容旧客户端:不再立即物理删除,统一进入 15 天注销等待期。 [HttpDelete] public Task DeleteAccount(DeleteAccountRequest request) => ScheduleAccountClosure(request, requireConfirmation: false); private async Task ScheduleAccountClosure( DeleteAccountRequest request, bool requireConfirmation) { var user = await db.Users.FirstOrDefaultAsync( item => item.Id == CurrentUserId); if (user is null) return NotFound(new ApiError("USER_NOT_FOUND", "用户不存在")); if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) return Unauthorized(new ApiError("BAD_CREDENTIALS", "密码错误")); if (requireConfirmation && request.ConfirmationText?.Trim() != "注销账号") return BadRequest(new ApiError( "CONFIRMATION_INVALID", "请输入“注销账号”完成确认")); if (!user.AccountClosureScheduledAt.HasValue) { var now = DateTime.UtcNow; user.AccountClosureRequestedAt = now; user.AccountClosureScheduledAt = now.AddDays(15); user.AuthVersion++; await db.SaveChangesAsync(); } return Accepted(new AccountClosureResponse( user.AccountClosureRequestedAt!.Value, user.AccountClosureScheduledAt.Value)); } private static async Task WriteEntry( ZipArchive archive, string name, string content, Encoding encoding) { var entry = archive.CreateEntry(name, CompressionLevel.Fastest); await using var stream = entry.Open(); await using var writer = new StreamWriter(stream, encoding); await writer.WriteAsync(content); } private static string Csv(object? value) { var text = value switch { null => "", IFormattable formattable => formattable.ToString(null, System.Globalization.CultureInfo.InvariantCulture), _ => value.ToString() ?? "", }; if (text.Length > 0 && "=+-@".Contains(text[0])) text = "'" + text; return "\"" + text.Replace("\"", "\"\"") + "\""; } private static UserProfileResponse ToProfile(User u) { var aiEnabled = u.FeaturePermissions .FirstOrDefault(x => x.PermissionKey == FeaturePermissionKeys.Ai)?.IsEnabled ?? true; var quota = AiChatQuotaService.GetStatus(u); return new UserProfileResponse( u.Id, u.Username, u.Nickname, u.AppMode == AppMode.AiFirst ? "ai" : "normal", u.AiCompanion is null ? null : new AiCompanionDto( u.AiCompanion.AvatarKey, u.AiCompanion.PersonaKey, u.AiCompanion.CustomName, u.AiCompanion.RoastLevel, u.AiCompanion.StickerFrequency, u.AiCompanion.ProactiveLevel), u.AiCompanion is not null || !aiEnabled, new UserPermissionsDto(aiEnabled), new AiChatQuotaDto( quota.Limit, quota.Used, quota.Remaining, AiChatQuotaService.PeriodKey(quota.Period), quota.ResetAt)); } }