Initial project import

This commit is contained in:
2026-07-24 23:11:20 +08:00
commit 6396eabb87
372 changed files with 49682 additions and 0 deletions
@@ -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));
}
}