386 lines
16 KiB
C#
386 lines
16 KiB
C#
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(),
|
||
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<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));
|
||
}
|
||
}
|
||
|