Files
jizhi/backend/MiaoJiZhang.Api/Controllers/UsersController.cs
T
2026-07-24 23:11:20 +08:00

371 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")!);
/// <summary>当前用户信息(含 AI 伙伴设置与模式)</summary>
[HttpGet]
public async Task<ActionResult<UserProfileResponse>> 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));
}
/// <summary>完成首次引导:选模式 + 选 AI 伙伴(形象/性格/昵称)</summary>
[HttpPost("onboarding")]
public async Task<ActionResult<UserProfileResponse>> 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));
}
/// <summary>切换 App 模式(决策:双模式可随时切换)</summary>
[HttpPut("mode")]
public async Task<ActionResult<UserProfileResponse>> 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));
}
/// <summary>更新 AI 伙伴设置(性格设置页 P5</summary>
[HttpPut("companion")]
public async Task<ActionResult<UserProfileResponse>> 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<ActionResult<UserProfileResponse>> 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<ActionResult<AuthResponse>> 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<IActionResult> 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(),
t.Amount, t.Note, t.PaymentMethod, t.OccurredAt,
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 == TransactionType.Income ? "收入" : "支出"),
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<IActionResult> RequestAccountClosure(
DeleteAccountRequest request) =>
ScheduleAccountClosure(request, requireConfirmation: true);
// 兼容旧客户端:不再立即物理删除,统一进入 15 天注销等待期。
[HttpDelete]
public Task<IActionResult> DeleteAccount(DeleteAccountRequest request) =>
ScheduleAccountClosure(request, requireConfirmation: false);
private async Task<IActionResult> 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));
}
}