Initial project import
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user