Initial project import
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
using MiaoJiZhang.Api.Contracts;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Domain.Enums;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AdminAuth]
|
||||
[Route("api/admin")]
|
||||
public class AdminController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("dashboard")] public async Task<IActionResult> Dashboard() { var tu = await db.Users.CountAsync(); var ta = await db.Users.CountAsync(u => u.LastLoginAt.HasValue && u.LastLoginAt.Value >= ChinaClock.ToUtc(ChinaClock.Now.Date) && u.LastLoginAt.Value < ChinaClock.ToUtc(ChinaClock.Now.Date.AddDays(1))); var tt = await db.Transactions.IgnoreQueryFilters().CountAsync(); var ai = await db.Transactions.IgnoreQueryFilters().CountAsync(t => TransactionSourceRules.AiAssisted.Contains(t.Source)); var at = await db.Transactions.IgnoreQueryFilters().Where(t => TransactionSourceRules.AiAssisted.Contains(t.Source)).CountAsync(); var ud = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.IsDeleted && TransactionSourceRules.AiAssisted.Contains(t.Source)); return Ok(new { users = new { total = tu, activeToday = ta }, transactions = new { total = tt, aiBooked = ai }, aiAccuracy = at == 0 ? 0 : Math.Round((1.0 - (double)ud / at) * 100, 1), undoRate = at == 0 ? 0 : Math.Round((double)ud / at * 100, 1), aiMessages = await db.ChatMessages.CountAsync(m => m.Role == ChatRole.Assistant) }); }
|
||||
|
||||
[HttpGet("configs")]
|
||||
public async Task<IActionResult> ListConfigs()
|
||||
{
|
||||
var configs = await db.AppConfigs.OrderBy(c => c.Key).ToListAsync();
|
||||
return Ok(configs.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
c.Key,
|
||||
Value = IsSecret(c.Key) ? "********" : c.Value,
|
||||
IsSecret = IsSecret(c.Key),
|
||||
c.Version,
|
||||
c.UpdatedAt,
|
||||
}));
|
||||
}
|
||||
[HttpPut("configs/{id:long}")]
|
||||
public async Task<IActionResult> UpdateConfig(long id, [FromBody] UpdateConfigRequest req)
|
||||
{
|
||||
var cfg = await db.AppConfigs.FindAsync(id);
|
||||
if (cfg is null) return NotFound();
|
||||
if (IsSecret(cfg.Key))
|
||||
return BadRequest(new { error = "secret_env_only", detail = "密钥只能通过服务端环境变量修改" });
|
||||
cfg.Value = req.Value;
|
||||
cfg.Version++;
|
||||
cfg.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
|
||||
}
|
||||
[HttpPost("configs")]
|
||||
public async Task<IActionResult> CreateConfig([FromBody] CreateConfigRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Key)) return BadRequest(new { error = "key_required" });
|
||||
if (IsSecret(req.Key))
|
||||
return BadRequest(new { error = "secret_env_only", detail = "密钥只能通过服务端环境变量配置" });
|
||||
if (await db.AppConfigs.AnyAsync(c => c.Key == req.Key))
|
||||
return Conflict(new { error = "key_exists", detail = "该配置 Key 已存在,请用 PUT 更新" });
|
||||
var cfg = new AppConfig { Key = req.Key.Trim(), Value = req.Value ?? "", Version = 1, UpdatedAt = DateTime.UtcNow };
|
||||
db.AppConfigs.Add(cfg);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
|
||||
}
|
||||
|
||||
[HttpGet("personas")] public async Task<IActionResult> ListPersonas() => Ok(await db.AiPersonas.OrderBy(p => p.Key).ToListAsync());
|
||||
[HttpPost("personas")] public async Task<IActionResult> CreatePersona([FromBody] UpsertPersonaRequest req) { if (await db.AiPersonas.AnyAsync(p => p.Key == req.Key)) return Conflict(new { error = "key_exists" }); var p = new AiPersona { Key = req.Key, Name = req.Name, Description = req.Description, SampleLine = req.SampleLine, PromptTemplate = req.PromptTemplate, IsEnabled = true, Version = 1 }; db.AiPersonas.Add(p); await db.SaveChangesAsync(); return Ok(p); }
|
||||
[HttpPut("personas/{id:long}")] public async Task<IActionResult> UpdatePersona(long id, [FromBody] UpsertPersonaRequest req) { var p = await db.AiPersonas.FindAsync(id); if (p is null) return NotFound(); p.Key = req.Key; p.Name = req.Name; p.Description = req.Description; p.SampleLine = req.SampleLine; p.PromptTemplate = req.PromptTemplate; p.IsEnabled = req.IsEnabled; p.Version++; await db.SaveChangesAsync(); return Ok(p); }
|
||||
[HttpDelete("personas/{id:long}")] public async Task<IActionResult> DeletePersona(long id) { var p = await db.AiPersonas.FindAsync(id); if (p is null) return NotFound(); db.AiPersonas.Remove(p); await db.SaveChangesAsync(); return NoContent(); }
|
||||
|
||||
[HttpGet("avatars")] public async Task<IActionResult> ListAvatars() => Ok(await db.AiAvatars.OrderBy(a => a.Key).ToListAsync());
|
||||
[HttpPost("avatars")] public async Task<IActionResult> CreateAvatar([FromBody] UpsertAvatarRequest req) { if (await db.AiAvatars.AnyAsync(a => a.Key == req.Key)) return Conflict(new { error = "key_exists" }); var a = new AiAvatar { Key = req.Key, DefaultName = req.Name, SpeechTic = req.SpeechTic, ImageUrl = req.ImageUrl, IsEnabled = true }; db.AiAvatars.Add(a); await db.SaveChangesAsync(); return Ok(a); }
|
||||
[HttpPut("avatars/{id:long}")] public async Task<IActionResult> UpdateAvatar(long id, [FromBody] UpsertAvatarRequest req) { var a = await db.AiAvatars.FindAsync(id); if (a is null) return NotFound(); a.Key = req.Key; a.DefaultName = req.Name; a.SpeechTic = req.SpeechTic; a.ImageUrl = req.ImageUrl; a.IsEnabled = req.IsEnabled; await db.SaveChangesAsync(); return Ok(a); }
|
||||
[HttpDelete("avatars/{id:long}")] public async Task<IActionResult> DeleteAvatar(long id) { var a = await db.AiAvatars.FindAsync(id); if (a is null) return NotFound(); db.AiAvatars.Remove(a); await db.SaveChangesAsync(); return NoContent(); }
|
||||
|
||||
[HttpGet("stickers")] public async Task<IActionResult> ListStickers() => Ok(await db.Stickers.OrderBy(s => s.GroupKey).ThenBy(s => s.Key).ToListAsync());
|
||||
[HttpPost("stickers")] public async Task<IActionResult> CreateSticker([FromBody] UpsertStickerRequest req) { if (await db.Stickers.AnyAsync(s => s.Key == req.Key)) return Conflict(new { error = "key_exists" }); var s = new Sticker { Key = req.Key, Label = req.Label, GroupKey = req.GroupKey, TriggerTags = req.TriggerTags, ImageUrl = req.ImageUrl, IsEnabled = true }; db.Stickers.Add(s); await db.SaveChangesAsync(); return Ok(s); }
|
||||
[HttpPut("stickers/{id:long}")] public async Task<IActionResult> UpdateSticker(long id, [FromBody] UpsertStickerRequest req) { var s = await db.Stickers.FindAsync(id); if (s is null) return NotFound(); s.Key = req.Key; s.Label = req.Label; s.GroupKey = req.GroupKey; s.TriggerTags = req.TriggerTags; s.ImageUrl = req.ImageUrl; s.IsEnabled = req.IsEnabled; await db.SaveChangesAsync(); return Ok(s); }
|
||||
[HttpDelete("stickers/{id:long}")] public async Task<IActionResult> DeleteSticker(long id) { var s = await db.Stickers.FindAsync(id); if (s is null) return NotFound(); db.Stickers.Remove(s); await db.SaveChangesAsync(); return NoContent(); }
|
||||
|
||||
[HttpPost("llm/test")] public async Task<IActionResult> TestLlm([FromServices] ILlmClient llm) { var (ok, error) = await llm.TestConnectionAsync(); return Ok(new { ok, error = ok ? (string?)null : error, detail = ok ? "LLM 连接正常" : error }); }
|
||||
|
||||
[HttpPost("configs/init")] public async Task<IActionResult> InitConfigs() { var now = DateTime.UtcNow; var defaults = new Dictionary<string, string> { ["brand.app_name"] = "记之", ["brand.slogan"] = "会聊天的记账本 · 让 AI 帮你管钱", ["brand.logo_url"] = "", ["llm.protocol"] = "responses", ["llm.base_url"] = "https://api.openai.com/v1", ["llm.model"] = "gpt-4o-mini", ["llm.max_tokens"] = "1024", ["llm.temperature"] = "0.8", ["limit.daily_ai_messages"] = "200", ["limit.daily_ai_messages_per_user"] = "50", ["limit.max_monthly_budget"] = "99999999", ["feature.ocr_enabled"] = "true", ["feature.voice_enabled"] = "true", ["feature.ai_auto_book"] = "true", ["feature.sticker_enabled"] = "true", ["system.default_ledger_name"] = "日常账本", ["system.max_ledgers_per_user"] = "10", ["feature.screenshot_bookkeeping_enabled"] = "true", ["permission.default.ai_enabled"] = "true", ["quota.default_ai_chat_limit"] = "50", ["quota.default_ai_chat_period"] = "day" }; var existing = await db.AppConfigs.Select(c => c.Key).ToListAsync(); var added = 0; foreach (var (key, value) in defaults) { if (!existing.Contains(key)) { db.AppConfigs.Add(new AppConfig { Key = key, Value = value, Version = 1, UpdatedAt = now }); added++; } } if (added > 0) await db.SaveChangesAsync(); return Ok(new { added, total = defaults.Count }); }
|
||||
|
||||
[HttpGet("categories")] public async Task<IActionResult> ListSystemCategories() => Ok(await db.Categories.Where(c => c.UserId == null && !c.IsDeleted).OrderBy(c => c.Type).ThenBy(c => c.SortOrder).ToListAsync());
|
||||
[HttpPost("categories")]
|
||||
public async Task<IActionResult> CreateSystemCategory([FromBody] UpsertCategoryRequest req)
|
||||
{
|
||||
if (req.Type is not ("income" or "expense"))
|
||||
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
|
||||
var iconKey = CategoriesController.NormalizeIcon(req.IconKey);
|
||||
if (!CategoriesController.AllowedIcons.Contains(iconKey))
|
||||
return BadRequest(new ApiError("ICON_INVALID", "分类图标不存在"));
|
||||
var type = req.Type == "income" ? TransactionType.Income : TransactionType.Expense;
|
||||
var maxSort = await db.Categories
|
||||
.Where(category => category.UserId == null && category.Type == type)
|
||||
.MaxAsync(category => (int?)category.SortOrder) ?? 0;
|
||||
var category = new Category
|
||||
{
|
||||
Type = type,
|
||||
Name = req.Name,
|
||||
IconKey = iconKey,
|
||||
SortOrder = maxSort + 1,
|
||||
};
|
||||
db.Categories.Add(category);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(category);
|
||||
}
|
||||
[HttpPut("categories/{id:long}")]
|
||||
public async Task<IActionResult> UpdateSystemCategory(long id, [FromBody] UpsertCategoryRequest req)
|
||||
{
|
||||
if (req.Type is not ("income" or "expense"))
|
||||
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
|
||||
var iconKey = CategoriesController.NormalizeIcon(req.IconKey);
|
||||
if (!CategoriesController.AllowedIcons.Contains(iconKey))
|
||||
return BadRequest(new ApiError("ICON_INVALID", "分类图标不存在"));
|
||||
var category = await db.Categories.FirstOrDefaultAsync(
|
||||
item => item.Id == id && item.UserId == null);
|
||||
if (category is null) return NotFound();
|
||||
category.Name = req.Name;
|
||||
category.IconKey = iconKey;
|
||||
category.Type = req.Type == "income"
|
||||
? TransactionType.Income
|
||||
: TransactionType.Expense;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(category);
|
||||
}
|
||||
[HttpDelete("categories/{id:long}")] public async Task<IActionResult> DeleteSystemCategory(long id) { var cat = await db.Categories.FirstOrDefaultAsync(c => c.Id == id && c.UserId == null); if (cat is null) return NotFound(); cat.IsDeleted = true; await db.SaveChangesAsync(); return NoContent(); }
|
||||
|
||||
[HttpGet("users")]
|
||||
public async Task<IActionResult> ListUsers(
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] bool? banned,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int limit = 20)
|
||||
{
|
||||
var query = db.Users
|
||||
.Include(user => user.AiCompanion)
|
||||
.Include(user => user.FeaturePermissions)
|
||||
.AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
query = query.Where(user => user.Username.Contains(search));
|
||||
if (banned.HasValue)
|
||||
query = query.Where(user => user.IsBanned == banned.Value);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var users = await query
|
||||
.OrderByDescending(user => user.CreatedAt)
|
||||
.Skip((Math.Max(1, page) - 1) * Math.Clamp(limit, 1, 100))
|
||||
.Take(Math.Clamp(limit, 1, 100))
|
||||
.ToListAsync();
|
||||
var ids = users.Select(user => user.Id).ToArray();
|
||||
var transactionCounts = await db.Transactions
|
||||
.IgnoreQueryFilters()
|
||||
.Where(transaction => ids.Contains(transaction.UserId))
|
||||
.GroupBy(transaction => transaction.UserId)
|
||||
.Select(group => new { UserId = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(item => item.UserId, item => item.Count);
|
||||
var aiTransactionCounts = await db.Transactions
|
||||
.IgnoreQueryFilters()
|
||||
.Where(transaction =>
|
||||
ids.Contains(transaction.UserId) &&
|
||||
TransactionSourceRules.AiAssisted.Contains(transaction.Source))
|
||||
.GroupBy(transaction => transaction.UserId)
|
||||
.Select(group => new { UserId = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(item => item.UserId, item => item.Count);
|
||||
|
||||
var list = users.Select(user =>
|
||||
{
|
||||
var quota = AiChatQuotaService.GetStatus(user);
|
||||
return new
|
||||
{
|
||||
user.Id,
|
||||
user.Username,
|
||||
user.Nickname,
|
||||
AppMode = user.AppMode == AppMode.AiFirst ? "ai" : "normal",
|
||||
user.IsBanned,
|
||||
user.CreatedAt,
|
||||
user.LastLoginAt,
|
||||
user.AccountClosureRequestedAt,
|
||||
user.AccountClosureScheduledAt,
|
||||
AiEnabled = user.FeaturePermissions.Any(permission =>
|
||||
permission.PermissionKey == FeaturePermissionKeys.Ai &&
|
||||
permission.IsEnabled),
|
||||
AiChatLimit = quota.Limit,
|
||||
AiChatUsed = quota.Used,
|
||||
AiChatRemaining = quota.Remaining,
|
||||
AiChatPeriod = AiChatQuotaService.PeriodKey(quota.Period),
|
||||
AiChatResetAt = quota.ResetAt,
|
||||
Companion = user.AiCompanion == null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
user.AiCompanion.AvatarKey,
|
||||
user.AiCompanion.PersonaKey,
|
||||
user.AiCompanion.CustomName,
|
||||
},
|
||||
TxCount = transactionCounts.GetValueOrDefault(user.Id),
|
||||
AiTxCount = aiTransactionCounts.GetValueOrDefault(user.Id),
|
||||
};
|
||||
}).ToList();
|
||||
return Ok(new { total, page, list });
|
||||
}
|
||||
[HttpPut("users/{id:long}/ban")] public async Task<IActionResult> ToggleBan(long id) { var u = await db.Users.FindAsync(id); if (u is null) return NotFound(); u.IsBanned = !u.IsBanned; u.AuthVersion++; await db.SaveChangesAsync(); return Ok(new { u.Id, u.Username, u.IsBanned }); }
|
||||
[HttpPut("users/{id:long}/cancel-closure")] public async Task<IActionResult> CancelClosure(long id) { var u = await db.Users.FindAsync(id); if (u is null) return NotFound(); if (!u.AccountClosureScheduledAt.HasValue) return Conflict(new ApiError("CLOSURE_NOT_PENDING", "账号未处于注销流程")); u.AccountClosureRequestedAt = null; u.AccountClosureScheduledAt = null; u.AuthVersion++; await db.SaveChangesAsync(); return Ok(new { u.Id, u.Username, cancelled = true }); }
|
||||
[HttpPut("users/{id:long}/permissions")]
|
||||
public async Task<IActionResult> UpdatePermissions(long id, UpdateUserPermissionsRequest req)
|
||||
{
|
||||
var user = await db.Users.FindAsync(id);
|
||||
if (user is null) return NotFound();
|
||||
var permission = await db.UserFeaturePermissions.FirstOrDefaultAsync(p =>
|
||||
p.UserId == id && p.PermissionKey == FeaturePermissionKeys.Ai);
|
||||
var now = DateTime.UtcNow;
|
||||
if (permission is null)
|
||||
{
|
||||
permission = new UserFeaturePermission
|
||||
{
|
||||
UserId = id,
|
||||
PermissionKey = FeaturePermissionKeys.Ai,
|
||||
CreatedAt = now,
|
||||
};
|
||||
db.UserFeaturePermissions.Add(permission);
|
||||
}
|
||||
permission.IsEnabled = req.Ai;
|
||||
permission.UpdatedAt = now;
|
||||
if (!req.Ai) user.AppMode = AppMode.Normal;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { user.Id, permissions = new { ai = permission.IsEnabled } });
|
||||
}
|
||||
|
||||
[HttpPut("users/{id:long}/ai-quota")]
|
||||
public async Task<IActionResult> UpdateAiChatQuota(
|
||||
long id,
|
||||
UpdateAiChatQuotaRequest req)
|
||||
{
|
||||
if (req.Limit is < 0 or > 1_000_000)
|
||||
return BadRequest(new ApiError(
|
||||
"AI_QUOTA_LIMIT_INVALID",
|
||||
"AI 对话次数必须在 0 到 1000000 之间,0 表示不限次数"));
|
||||
if (!AiChatQuotaService.TryParsePeriod(req.Period, out var period))
|
||||
return BadRequest(new ApiError(
|
||||
"AI_QUOTA_PERIOD_INVALID",
|
||||
"周期仅支持 day、week 或 month"));
|
||||
|
||||
var user = await db.Users.FindAsync(id);
|
||||
if (user is null) return NotFound();
|
||||
var periodChanged = user.AiChatPeriod != period;
|
||||
user.AiChatLimit = req.Limit;
|
||||
user.AiChatPeriod = period;
|
||||
if (periodChanged || req.ResetUsage)
|
||||
{
|
||||
var (windowStart, _) = AiChatQuotaService.CurrentWindow(period);
|
||||
user.AiChatWindowStartedAt = windowStart;
|
||||
user.AiChatUsed = 0;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
var quota = AiChatQuotaService.GetStatus(user);
|
||||
return Ok(new
|
||||
{
|
||||
user.Id,
|
||||
aiChatQuota = new
|
||||
{
|
||||
quota.Limit,
|
||||
quota.Used,
|
||||
quota.Remaining,
|
||||
period = AiChatQuotaService.PeriodKey(quota.Period),
|
||||
quota.ResetAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("users/{id:long}/stats")] public async Task<IActionResult> UserStats(long id) { var u = await db.Users.FindAsync(id); if (u is null) return NotFound(); var txCount = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.UserId == id); var aiCount = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.UserId == id && TransactionSourceRules.AiAssisted.Contains(t.Source)); var undone = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.UserId == id && t.IsDeleted && TransactionSourceRules.AiAssisted.Contains(t.Source)); return Ok(new { totalTransactions = txCount, aiBooked = aiCount, aiAccuracy = aiCount == 0 ? 0 : Math.Round((1.0 - (double)undone / aiCount) * 100, 1) }); }
|
||||
|
||||
private static bool IsSecret(string key) =>
|
||||
key.Equals("llm.api_key", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("secret", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("password", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.EndsWith("token", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public record UpdateConfigRequest(string Value);
|
||||
public record CreateConfigRequest(string Key, string Value);
|
||||
public record UpsertPersonaRequest(string Key, string Name, string Description, string SampleLine, string PromptTemplate, bool IsEnabled = true);
|
||||
public record UpsertAvatarRequest(string Key, string Name, string SpeechTic, string? ImageUrl, bool IsEnabled = true);
|
||||
public record UpsertStickerRequest(string Key, string Label, string GroupKey, string? TriggerTags, string? ImageUrl, bool IsEnabled = true);
|
||||
public record UpsertCategoryRequest(string Name, string IconKey, string Type);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using MiaoJiZhang.Api.Contracts;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Domain.Enums;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[EnableRateLimiting("auth")]
|
||||
[Route("api/auth")]
|
||||
public class AuthController(AppDbContext db, JwtService jwt) : ControllerBase
|
||||
{
|
||||
[HttpPost("register")]
|
||||
public async Task<ActionResult<AuthResponse>> Register(RegisterRequest req)
|
||||
{
|
||||
if (!req.AgreedToTerms)
|
||||
return BadRequest(new ApiError("TERMS_NOT_ACCEPTED", "请先阅读并同意用户协议与隐私政策"));
|
||||
if (req.Username.Length is < 3 or > 32) return BadRequest(new ApiError("USERNAME_INVALID", "用户名长度需在 3-32 之间"));
|
||||
if (req.Password.Length < 6) return BadRequest(new ApiError("PASSWORD_TOO_SHORT", "密码至少 6 位"));
|
||||
if (await db.Users.AnyAsync(u => u.Username == req.Username)) return Conflict(new ApiError("USERNAME_TAKEN", "用户名已被占用"));
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var defaultAiEnabled = !string.Equals(
|
||||
(await db.AppConfigs.FirstOrDefaultAsync(c => c.Key == "permission.default.ai_enabled"))?.Value,
|
||||
"false",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
var quotaConfig = await db.AppConfigs
|
||||
.Where(c => c.Key == "quota.default_ai_chat_limit" ||
|
||||
c.Key == "quota.default_ai_chat_period")
|
||||
.ToDictionaryAsync(c => c.Key, c => c.Value);
|
||||
var defaultAiChatLimit =
|
||||
quotaConfig.TryGetValue("quota.default_ai_chat_limit", out var limitValue) &&
|
||||
int.TryParse(limitValue, out var parsedLimit)
|
||||
? Math.Clamp(parsedLimit, 0, 1_000_000)
|
||||
: 50;
|
||||
var periodValue = quotaConfig.GetValueOrDefault(
|
||||
"quota.default_ai_chat_period",
|
||||
"day");
|
||||
if (!AiChatQuotaService.TryParsePeriod(periodValue, out var defaultAiChatPeriod))
|
||||
defaultAiChatPeriod = AiQuotaPeriod.Day;
|
||||
var (quotaWindowStart, _) =
|
||||
AiChatQuotaService.CurrentWindow(defaultAiChatPeriod);
|
||||
var user = new User
|
||||
{
|
||||
Username = req.Username,
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password),
|
||||
AiChatLimit = defaultAiChatLimit,
|
||||
AiChatPeriod = defaultAiChatPeriod,
|
||||
AiChatWindowStartedAt = quotaWindowStart,
|
||||
CreatedAt = now,
|
||||
};
|
||||
user.FeaturePermissions.Add(new UserFeaturePermission
|
||||
{
|
||||
PermissionKey = FeaturePermissionKeys.Ai,
|
||||
IsEnabled = defaultAiEnabled,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
var defaultLedgerName = (await db.AppConfigs.FirstOrDefaultAsync(c => c.Key == "system.default_ledger_name"))?.Value ?? "日常账本";
|
||||
user.Ledgers.Add(new Ledger { Name = defaultLedgerName, IsDefault = true, CreatedAt = DateTime.UtcNow });
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var (token, expires) = jwt.Issue(user);
|
||||
return Ok(new AuthResponse(user.Id, user.Username, token, expires));
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<ActionResult<AuthResponse>> Login(LoginRequest req)
|
||||
{
|
||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == req.Username);
|
||||
if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash)) return Unauthorized(new ApiError("BAD_CREDENTIALS", "用户名或密码错误"));
|
||||
if (user.IsBanned)
|
||||
return Unauthorized(new ApiError("USER_BANNED", "账号已被封禁"));
|
||||
|
||||
var closureCancelled = false;
|
||||
if (user.AccountClosureScheduledAt.HasValue)
|
||||
{
|
||||
if (user.AccountClosureScheduledAt <= DateTime.UtcNow)
|
||||
return Unauthorized(new ApiError(
|
||||
"ACCOUNT_CLOSED",
|
||||
"账号注销等待期已结束,无法恢复"));
|
||||
user.AccountClosureRequestedAt = null;
|
||||
user.AccountClosureScheduledAt = null;
|
||||
user.AuthVersion++;
|
||||
closureCancelled = true;
|
||||
}
|
||||
|
||||
user.LastLoginAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
var (token, expires) = jwt.Issue(user);
|
||||
return Ok(new AuthResponse(
|
||||
user.Id,
|
||||
user.Username,
|
||||
token,
|
||||
expires,
|
||||
closureCancelled));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
using System.Security.Claims;
|
||||
using MiaoJiZhang.Api.Contracts;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Domain.Enums;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/budgets")]
|
||||
public class BudgetsController(
|
||||
AppDbContext db,
|
||||
LedgerResolver ledgers,
|
||||
BudgetRecommendationService recommendations) : ControllerBase
|
||||
{
|
||||
private long Uid => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<BudgetsResponse>> Get(
|
||||
[FromQuery] int year,
|
||||
[FromQuery] int month,
|
||||
[FromQuery] long? ledgerId = null)
|
||||
{
|
||||
if (month is < 1 or > 12) return BadRequest(new ApiError("MONTH_INVALID", "月份无效"));
|
||||
var period = year * 100 + month;
|
||||
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
|
||||
if (!resolvedLedgerId.HasValue)
|
||||
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
|
||||
var targetLedgerId = resolvedLedgerId.Value;
|
||||
|
||||
var rows = await db.Budgets
|
||||
.Where(b => b.UserId == Uid && b.LedgerId == targetLedgerId && (b.Period == period || b.Period == 0))
|
||||
.ToListAsync();
|
||||
var budgets = rows
|
||||
.GroupBy(b => b.CategoryId)
|
||||
.Select(g => g.FirstOrDefault(b => b.Period == period) ?? g.First(b => b.Period == 0))
|
||||
.ToList();
|
||||
|
||||
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
|
||||
var spentByCat = await db.Transactions
|
||||
.Where(t => t.UserId == Uid && t.LedgerId == targetLedgerId &&
|
||||
t.Type == TransactionType.Expense && t.OccurredAt >= start && t.OccurredAt < end)
|
||||
.GroupBy(t => t.CategoryId)
|
||||
.Select(g => new { g.Key, Amount = g.Sum(t => t.Amount) })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Amount);
|
||||
var totalSpent = spentByCat.Values.Sum();
|
||||
var cats = await db.Categories
|
||||
.Where(c => !c.IsDeleted && (c.UserId == null || c.UserId == Uid))
|
||||
.ToDictionaryAsync(c => c.Id);
|
||||
|
||||
BudgetItemDto? total = null;
|
||||
var items = new List<BudgetItemDto>();
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
if (budget.CategoryId is null)
|
||||
{
|
||||
total = new BudgetItemDto(null, null, null, budget.Amount, totalSpent, budget.Period == 0);
|
||||
}
|
||||
else if (cats.TryGetValue(budget.CategoryId.Value, out var category))
|
||||
{
|
||||
items.Add(new BudgetItemDto(
|
||||
category.Id,
|
||||
category.Name,
|
||||
category.IconKey,
|
||||
budget.Amount,
|
||||
spentByCat.GetValueOrDefault(category.Id, 0),
|
||||
budget.Period == 0,
|
||||
category.ColorKey));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new BudgetsResponse(
|
||||
year,
|
||||
month,
|
||||
total,
|
||||
items.OrderByDescending(i => i.Amount <= 0 ? 0 : i.Spent / i.Amount).ToList()));
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
public async Task<IActionResult> Upsert(
|
||||
UpsertBudgetRequest req,
|
||||
[FromQuery] int year,
|
||||
[FromQuery] int month,
|
||||
[FromQuery(Name = "ledgerId")] long? requestedLedgerId = null)
|
||||
{
|
||||
if (month is < 1 or > 12) return BadRequest(new ApiError("MONTH_INVALID", "月份无效"));
|
||||
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, requestedLedgerId);
|
||||
if (!resolvedLedgerId.HasValue)
|
||||
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
|
||||
var ledgerId = resolvedLedgerId.Value;
|
||||
if (req.Recurring)
|
||||
{
|
||||
var overrideRow = await db.Budgets.FirstOrDefaultAsync(b =>
|
||||
b.UserId == Uid && b.LedgerId == ledgerId &&
|
||||
b.Period == year * 100 + month && b.CategoryId == req.CategoryId);
|
||||
if (overrideRow is not null) db.Budgets.Remove(overrideRow);
|
||||
}
|
||||
await UpsertCore(ledgerId, req.Recurring ? 0 : year * 100 + month, req.CategoryId, req.Amount);
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPut("batch")]
|
||||
public async Task<IActionResult> Batch(
|
||||
ApplyBudgetBatchRequest req,
|
||||
[FromQuery] int year,
|
||||
[FromQuery] int month,
|
||||
[FromQuery(Name = "ledgerId")] long? requestedLedgerId = null)
|
||||
{
|
||||
if (month is < 1 or > 12) return BadRequest(new ApiError("MONTH_INVALID", "月份无效"));
|
||||
if (req.Items.Count == 0) return BadRequest(new ApiError("BUDGET_EMPTY", "没有可应用的预算"));
|
||||
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, requestedLedgerId);
|
||||
if (!resolvedLedgerId.HasValue)
|
||||
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
|
||||
var ledgerId = resolvedLedgerId.Value;
|
||||
var targetPeriod = req.Recurring ? 0 : year * 100 + month;
|
||||
if (req.Recurring)
|
||||
{
|
||||
var affectedCategories = req.Items.Select(i => i.CategoryId).ToHashSet();
|
||||
var overrides = await db.Budgets
|
||||
.Where(b => b.UserId == Uid && b.LedgerId == ledgerId &&
|
||||
b.Period == year * 100 + month)
|
||||
.ToListAsync();
|
||||
db.Budgets.RemoveRange(overrides.Where(b => affectedCategories.Contains(b.CategoryId)));
|
||||
}
|
||||
foreach (var item in req.Items)
|
||||
await UpsertCore(ledgerId, targetPeriod, item.CategoryId, item.Amount);
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("recommendations")]
|
||||
[RequireAiPermission]
|
||||
public async Task<ActionResult<BudgetRecommendationResponse>> Recommendations(
|
||||
[FromQuery] int year,
|
||||
[FromQuery] int month,
|
||||
[FromQuery(Name = "ledgerId")] long? requestedLedgerId = null)
|
||||
{
|
||||
if (month is < 1 or > 12) return BadRequest(new ApiError("MONTH_INVALID", "月份无效"));
|
||||
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, requestedLedgerId);
|
||||
if (!resolvedLedgerId.HasValue)
|
||||
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
|
||||
var ledgerId = resolvedLedgerId.Value;
|
||||
var (currentStart, currentEnd) = ChinaClock.MonthRangeUtc(year, month);
|
||||
var historyStart = currentStart.AddMonths(-3);
|
||||
|
||||
var transactions = await db.Transactions
|
||||
.Where(t => t.UserId == Uid && t.LedgerId == ledgerId &&
|
||||
t.Type == TransactionType.Expense &&
|
||||
t.OccurredAt >= historyStart && t.OccurredAt < currentEnd)
|
||||
.Select(t => new { t.CategoryId, t.Amount, t.OccurredAt })
|
||||
.ToListAsync();
|
||||
var categories = await db.Categories
|
||||
.Where(c => !c.IsDeleted && c.Type == TransactionType.Expense &&
|
||||
(c.UserId == null || c.UserId == Uid))
|
||||
.ToDictionaryAsync(c => c.Id);
|
||||
|
||||
var currentTransactions = transactions
|
||||
.Where(t => t.OccurredAt >= currentStart && t.OccurredAt < currentEnd)
|
||||
.ToList();
|
||||
var currentByCategory = currentTransactions
|
||||
.GroupBy(t => t.CategoryId)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(t => t.Amount));
|
||||
var localNow = ChinaClock.Now;
|
||||
var projectionAllowed = localNow.Year == year && localNow.Month == month &&
|
||||
localNow.Day >= 14 && currentTransactions.Count >= 10;
|
||||
|
||||
var categoryIds = transactions.Select(t => t.CategoryId).Distinct().ToList();
|
||||
var suggestions = new List<BudgetRecommendationItemDto>();
|
||||
foreach (var categoryId in categoryIds)
|
||||
{
|
||||
if (!categories.TryGetValue(categoryId, out var category)) continue;
|
||||
var history = Enumerable.Range(1, 3)
|
||||
.Select(offset =>
|
||||
{
|
||||
var start = currentStart.AddMonths(-offset);
|
||||
var end = start.AddMonths(1);
|
||||
return transactions
|
||||
.Where(t => t.CategoryId == categoryId && t.OccurredAt >= start && t.OccurredAt < end)
|
||||
.Sum(t => t.Amount);
|
||||
})
|
||||
.Where(value => value > 0)
|
||||
.OrderBy(value => value)
|
||||
.ToList();
|
||||
|
||||
var currentSpent = currentByCategory.GetValueOrDefault(categoryId, 0);
|
||||
decimal baseline;
|
||||
string confidence;
|
||||
if (history.Count > 0)
|
||||
{
|
||||
baseline = Median(history);
|
||||
confidence = history.Count >= 3 ? "high" : "medium";
|
||||
if (projectionAllowed && currentSpent > 0)
|
||||
{
|
||||
var projection = currentSpent / localNow.Day * DateTime.DaysInMonth(year, month);
|
||||
baseline = baseline * 0.8m + projection * 0.2m;
|
||||
}
|
||||
}
|
||||
else if (projectionAllowed && currentSpent > 0)
|
||||
{
|
||||
baseline = currentSpent / localNow.Day * DateTime.DaysInMonth(year, month);
|
||||
confidence = "low";
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var p75 = history.Count == 0
|
||||
? baseline
|
||||
: history[(int)Math.Ceiling(history.Count * 0.75) - 1];
|
||||
var target = baseline * 1.05m;
|
||||
var cap = p75 * 1.2m;
|
||||
target = Math.Min(target, cap);
|
||||
if (currentSpent >= target) target = currentSpent * 1.05m;
|
||||
target = RoundFriendly(target);
|
||||
|
||||
suggestions.Add(new BudgetRecommendationItemDto(
|
||||
categoryId,
|
||||
category.Name,
|
||||
category.IconKey,
|
||||
target,
|
||||
currentSpent,
|
||||
history.Count == 0 ? baseline : history.Average(),
|
||||
confidence,
|
||||
category.ColorKey));
|
||||
}
|
||||
|
||||
var message = suggestions.Count == 0
|
||||
? "历史数据不足。至少记录 1 个完整月,或本月满 14 天且达到 10 笔支出后再试。"
|
||||
: "建议基于最近 3 个完整月,并确保不低于本月已发生支出。";
|
||||
var currentSpentTotal = currentTransactions.Sum(t => t.Amount);
|
||||
return Ok(new BudgetRecommendationResponse(
|
||||
year,
|
||||
month,
|
||||
Math.Max(suggestions.Sum(s => s.SuggestedAmount), currentSpentTotal),
|
||||
message,
|
||||
suggestions.OrderByDescending(s => s.SuggestedAmount).ToList()));
|
||||
}
|
||||
|
||||
[HttpPost("recommendations/refine")]
|
||||
[RequireAiPermission]
|
||||
[EnableRateLimiting("ai")]
|
||||
public async Task<ActionResult<BudgetRecommendationResponse>> RefineRecommendation(
|
||||
RefineBudgetRecommendationRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (request.Year is < 2000 or > 2200 || request.Month is < 1 or > 12)
|
||||
return BadRequest(new ApiError("MONTH_INVALID", "月份无效"));
|
||||
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, request.LedgerId);
|
||||
if (!resolvedLedgerId.HasValue)
|
||||
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
|
||||
|
||||
try
|
||||
{
|
||||
return Ok(await recommendations.RefineAsync(
|
||||
Uid,
|
||||
resolvedLedgerId.Value,
|
||||
request,
|
||||
ct));
|
||||
}
|
||||
catch (ArgumentException exception)
|
||||
{
|
||||
return BadRequest(new ApiError("BUDGET_DRAFT_INVALID", exception.Message));
|
||||
}
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
return StatusCode(503, new ApiError("BUDGET_AI_UNAVAILABLE", exception.Message));
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
return StatusCode(502, new ApiError("BUDGET_AI_FAILED", exception.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpsertCore(long ledgerId, int period, long? categoryId, decimal amount)
|
||||
{
|
||||
if (categoryId.HasValue && !await db.Categories.AnyAsync(c =>
|
||||
c.Id == categoryId && !c.IsDeleted && c.Type == TransactionType.Expense &&
|
||||
(c.UserId == null || c.UserId == Uid)))
|
||||
throw new ArgumentException("预算分类无效");
|
||||
|
||||
var existing = await db.Budgets.FirstOrDefaultAsync(b =>
|
||||
b.UserId == Uid && b.LedgerId == ledgerId &&
|
||||
b.Period == period && b.CategoryId == categoryId);
|
||||
if (amount <= 0)
|
||||
{
|
||||
if (existing != null) db.Budgets.Remove(existing);
|
||||
}
|
||||
else if (existing != null)
|
||||
{
|
||||
existing.Amount = amount;
|
||||
}
|
||||
else
|
||||
{
|
||||
db.Budgets.Add(new Budget
|
||||
{
|
||||
UserId = Uid,
|
||||
LedgerId = ledgerId,
|
||||
Period = period,
|
||||
CategoryId = categoryId,
|
||||
Amount = amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static decimal Median(IReadOnlyList<decimal> values)
|
||||
{
|
||||
if (values.Count == 0) return 0;
|
||||
var middle = values.Count / 2;
|
||||
return values.Count % 2 == 0
|
||||
? (values[middle - 1] + values[middle]) / 2
|
||||
: values[middle];
|
||||
}
|
||||
|
||||
private static decimal RoundFriendly(decimal value)
|
||||
{
|
||||
var unit = value < 100 ? 10m : value < 1000 ? 50m : 100m;
|
||||
return Math.Ceiling(value / unit) * unit;
|
||||
}
|
||||
|
||||
private async Task<long> DefaultLedgerId() =>
|
||||
await db.Ledgers.Where(l => l.OwnerId == Uid && l.IsDefault).Select(l => l.Id).FirstAsync();
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/stickers")]
|
||||
public class StickersController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<StickerDto>>> List()
|
||||
{
|
||||
var enabled = await db.AppConfigs
|
||||
.Where(config => config.Key == "feature.sticker_enabled")
|
||||
.Select(config => config.Value)
|
||||
.FirstOrDefaultAsync();
|
||||
if (enabled?.Equals("false", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return Ok(new List<StickerDto>());
|
||||
return Ok(await db.Stickers.Where(s => s.IsEnabled)
|
||||
.Select(s => new StickerDto(s.Key, s.Label, s.GroupKey)).ToListAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using MiaoJiZhang.Api.Contracts;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Domain.Enums;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/chat")]
|
||||
[RequireAiPermission]
|
||||
public class ChatController(
|
||||
AppDbContext db,
|
||||
ILlmClient llm,
|
||||
LedgerResolver ledgers,
|
||||
AgentService agent,
|
||||
AiChatQuotaService quotas,
|
||||
ILogger<ChatController> logger) : ControllerBase
|
||||
{
|
||||
private long Uid => long.Parse(
|
||||
User.FindFirstValue(ClaimTypes.NameIdentifier) ??
|
||||
User.FindFirstValue("sub")!);
|
||||
|
||||
[HttpGet("messages")]
|
||||
public async Task<ActionResult<List<ChatMessageDto>>> History(
|
||||
[FromQuery] int limit = 50,
|
||||
[FromQuery] long? beforeId = null)
|
||||
{
|
||||
var boundaryId = await CurrentBoundaryId();
|
||||
var query = db.ChatMessages.Where(m =>
|
||||
m.UserId == Uid &&
|
||||
m.Id > boundaryId &&
|
||||
m.Type != ChatMessageType.ContextBoundary);
|
||||
if (beforeId.HasValue)
|
||||
query = query.Where(m => m.Id < beforeId.Value);
|
||||
|
||||
var list = await query
|
||||
.OrderByDescending(m => m.Id)
|
||||
.Take(Math.Min(limit, 100))
|
||||
.ToListAsync();
|
||||
list.Reverse();
|
||||
|
||||
var transactionIds = list
|
||||
.Where(m => m.TransactionId.HasValue)
|
||||
.Select(m => m.TransactionId!.Value)
|
||||
.ToList();
|
||||
var transactions = await db.Transactions
|
||||
.Include(t => t.Category)
|
||||
.IgnoreQueryFilters()
|
||||
.Where(t => t.UserId == Uid && transactionIds.Contains(t.Id))
|
||||
.ToDictionaryAsync(t => t.Id);
|
||||
return Ok(list.Select(m => ToDto(m, transactions)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("context/clear")]
|
||||
public async Task<IActionResult> ClearContext()
|
||||
{
|
||||
db.ChatMessages.Add(new ChatMessage
|
||||
{
|
||||
UserId = Uid,
|
||||
Role = ChatRole.System,
|
||||
Type = ChatMessageType.ContextBoundary,
|
||||
Content = "新会话",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("messages")]
|
||||
[EnableRateLimiting("ai")]
|
||||
public async Task<ActionResult<SendChatResponse>> Send(
|
||||
SendChatRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return await SendWithAgent(req, ct);
|
||||
}
|
||||
|
||||
[HttpPost("messages/stream")]
|
||||
[EnableRateLimiting("ai")]
|
||||
public async Task StreamSend(SendChatRequest req, CancellationToken ct)
|
||||
{
|
||||
await StreamWithAgent(req, ct);
|
||||
}
|
||||
|
||||
private async Task<ActionResult<SendChatResponse>> SendWithAgent(
|
||||
SendChatRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Content))
|
||||
return BadRequest(new ApiError("CONTENT_EMPTY", "内容不能为空"));
|
||||
var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId, ct);
|
||||
if (!ledgerId.HasValue)
|
||||
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
|
||||
if (req.Type == "sticker" && !await FeatureEnabled("feature.sticker_enabled", ct))
|
||||
return StatusCode(403, new ApiError("FEATURE_DISABLED", "表情包功能已由后台关闭"));
|
||||
var allowAutoBook = await FeatureEnabled("feature.ai_auto_book", ct);
|
||||
var quota = await quotas.TryConsumeAsync(Uid, ct);
|
||||
if (!quota.Allowed)
|
||||
return StatusCode(
|
||||
StatusCodes.Status429TooManyRequests,
|
||||
QuotaExceededPayload(quota));
|
||||
|
||||
var content = req.Content.Trim();
|
||||
var now = DateTime.UtcNow;
|
||||
var userMessage = new ChatMessage
|
||||
{
|
||||
UserId = Uid,
|
||||
Role = ChatRole.User,
|
||||
Type = req.Type == "sticker"
|
||||
? ChatMessageType.Sticker
|
||||
: ChatMessageType.Text,
|
||||
Content = content,
|
||||
CreatedAt = now,
|
||||
};
|
||||
db.ChatMessages.Add(userMessage);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var (_, _, tic, personaPrompt) = await LoadUserConfig(ct);
|
||||
var replies = new List<ChatMessage>();
|
||||
var transactions = new List<Transaction>();
|
||||
|
||||
if (userMessage.Type == ChatMessageType.Sticker)
|
||||
{
|
||||
var (text, sticker) = await StickerLlmAsync(content, tic, ct);
|
||||
replies.Add(AssistantText(text, now.AddMilliseconds(1)));
|
||||
if (sticker is not null)
|
||||
{
|
||||
replies.Add(new ChatMessage
|
||||
{
|
||||
UserId = Uid,
|
||||
Role = ChatRole.Assistant,
|
||||
Type = ChatMessageType.Sticker,
|
||||
Content = sticker,
|
||||
CreatedAt = now.AddMilliseconds(2),
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await agent.RunAsync(
|
||||
Uid,
|
||||
ledgerId.Value,
|
||||
allowAutoBook,
|
||||
content,
|
||||
userMessage,
|
||||
MakeSys(personaPrompt, tic, ""),
|
||||
await LoadConversationContext(userMessage.Id, ct),
|
||||
ct: ct);
|
||||
transactions.AddRange(result.Transactions);
|
||||
var reply = string.IsNullOrWhiteSpace(result.Reply)
|
||||
? AgentFallback(transactions, tic)
|
||||
: result.Reply;
|
||||
replies.Add(AssistantText(reply, now.AddMilliseconds(1)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Agent chat failed for user {UserId}", Uid);
|
||||
transactions.AddRange(await TransactionsForMessage(
|
||||
userMessage.Id,
|
||||
ct));
|
||||
var reply = transactions.Count > 0
|
||||
? AgentFallback(transactions, tic)
|
||||
: "AI 暂时无法完成这次操作,没有写入任何账单,请稍后重试";
|
||||
replies.Add(AssistantText(reply, now.AddMilliseconds(1)));
|
||||
}
|
||||
|
||||
for (var index = 0; index < transactions.Count; index++)
|
||||
{
|
||||
replies.Add(BillCard(
|
||||
transactions[index].Id,
|
||||
now.AddMilliseconds(index + 2)));
|
||||
}
|
||||
}
|
||||
|
||||
db.ChatMessages.AddRange(replies);
|
||||
await db.SaveChangesAsync(ct);
|
||||
var transactionMap = transactions.ToDictionary(t => t.Id);
|
||||
return Ok(new SendChatResponse(
|
||||
new[] { userMessage }
|
||||
.Concat(replies)
|
||||
.Select(message => ToDto(message, transactionMap))
|
||||
.ToList()));
|
||||
}
|
||||
|
||||
private async Task StreamWithAgent(
|
||||
SendChatRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Content))
|
||||
{
|
||||
HttpContext.Response.StatusCode = 400;
|
||||
return;
|
||||
}
|
||||
var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId, ct);
|
||||
if (!ledgerId.HasValue)
|
||||
{
|
||||
HttpContext.Response.StatusCode = 400;
|
||||
await HttpContext.Response.WriteAsJsonAsync(
|
||||
new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var quota = await quotas.TryConsumeAsync(Uid, ct);
|
||||
if (!quota.Allowed)
|
||||
{
|
||||
HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
await HttpContext.Response.WriteAsJsonAsync(
|
||||
QuotaExceededPayload(quota),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var allowAutoBook = await FeatureEnabled("feature.ai_auto_book", ct);
|
||||
var content = req.Content.Trim();
|
||||
var now = DateTime.UtcNow;
|
||||
var userMessage = new ChatMessage
|
||||
{
|
||||
UserId = Uid,
|
||||
Role = ChatRole.User,
|
||||
Type = ChatMessageType.Text,
|
||||
Content = content,
|
||||
CreatedAt = now,
|
||||
};
|
||||
db.ChatMessages.Add(userMessage);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
HttpContext.Response.ContentType = "text/event-stream; charset=utf-8";
|
||||
HttpContext.Response.Headers["Cache-Control"] = "no-cache, no-transform";
|
||||
HttpContext.Response.Headers["Connection"] = "keep-alive";
|
||||
HttpContext.Response.Headers["X-Accel-Buffering"] = "no";
|
||||
await HttpContext.Response.Body.FlushAsync(ct);
|
||||
await WriteSsePayload(new { status = "thinking" }, ct);
|
||||
|
||||
var (_, _, tic, personaPrompt) = await LoadUserConfig(ct);
|
||||
var fullReply = new StringBuilder();
|
||||
var transactions = new List<Transaction>();
|
||||
var responseDisconnected = false;
|
||||
try
|
||||
{
|
||||
var result = await agent.RunAsync(
|
||||
Uid,
|
||||
ledgerId.Value,
|
||||
allowAutoBook,
|
||||
content,
|
||||
userMessage,
|
||||
MakeSys(personaPrompt, tic, ""),
|
||||
await LoadConversationContext(userMessage.Id, ct),
|
||||
async (token, tokenCt) =>
|
||||
{
|
||||
fullReply.Append(token);
|
||||
await WriteSse("t", token, tokenCt);
|
||||
},
|
||||
(tool, toolCt) => WriteSsePayload(
|
||||
new { status = "tool_running", tool },
|
||||
toolCt),
|
||||
ct);
|
||||
transactions.AddRange(result.Transactions);
|
||||
if (fullReply.Length == 0 && !string.IsNullOrWhiteSpace(result.Reply))
|
||||
fullReply.Append(result.Reply);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
responseDisconnected = true;
|
||||
fullReply.Clear();
|
||||
transactions.AddRange(await TransactionsForMessage(
|
||||
userMessage.Id,
|
||||
CancellationToken.None));
|
||||
if (transactions.Count == 0) return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Streaming agent chat failed for user {UserId}", Uid);
|
||||
transactions.AddRange(await TransactionsForMessage(
|
||||
userMessage.Id,
|
||||
CancellationToken.None));
|
||||
}
|
||||
|
||||
if (responseDisconnected || fullReply.Length == 0)
|
||||
{
|
||||
var fallback = transactions.Count > 0
|
||||
? AgentFallback(transactions, tic)
|
||||
: "AI 暂时无法完成这次操作,没有写入任何账单,请稍后重试";
|
||||
fullReply.Clear();
|
||||
fullReply.Append(fallback);
|
||||
if (!responseDisconnected) await WriteSse("t", fallback, ct);
|
||||
}
|
||||
|
||||
var replies = new List<ChatMessage>
|
||||
{
|
||||
AssistantText(fullReply.ToString(), now.AddMilliseconds(1)),
|
||||
};
|
||||
for (var index = 0; index < transactions.Count; index++)
|
||||
{
|
||||
replies.Add(BillCard(
|
||||
transactions[index].Id,
|
||||
now.AddMilliseconds(index + 2)));
|
||||
}
|
||||
|
||||
db.ChatMessages.AddRange(replies);
|
||||
await db.SaveChangesAsync(
|
||||
responseDisconnected ? CancellationToken.None : ct);
|
||||
if (responseDisconnected) return;
|
||||
var transactionMap = transactions.ToDictionary(t => t.Id);
|
||||
await WriteSsePayload(
|
||||
new
|
||||
{
|
||||
done = true,
|
||||
messages = replies
|
||||
.Select(message => ToDto(message, transactionMap))
|
||||
.ToList(),
|
||||
},
|
||||
ct);
|
||||
}
|
||||
|
||||
private static object QuotaExceededPayload(AiChatQuotaStatus quota) => new
|
||||
{
|
||||
code = "AI_CHAT_QUOTA_EXCEEDED",
|
||||
message = $"本周期 AI 对话次数已用完,将于 {ChinaClock.ToLocal(quota.ResetAt):MM月dd日 HH:mm} 重置",
|
||||
quota = new
|
||||
{
|
||||
quota.Limit,
|
||||
quota.Used,
|
||||
quota.Remaining,
|
||||
period = AiChatQuotaService.PeriodKey(quota.Period),
|
||||
quota.ResetAt,
|
||||
},
|
||||
};
|
||||
|
||||
private async Task<List<Transaction>> TransactionsForMessage(
|
||||
long messageId,
|
||||
CancellationToken ct) =>
|
||||
await db.Transactions
|
||||
.Include(transaction => transaction.Category)
|
||||
.Where(transaction =>
|
||||
transaction.UserId == Uid &&
|
||||
transaction.SourceChatMessageId == messageId)
|
||||
.OrderBy(transaction => transaction.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
private static string AgentFallback(
|
||||
IReadOnlyList<Transaction> transactions,
|
||||
string tic)
|
||||
{
|
||||
if (transactions.Count == 0)
|
||||
return "这句话里的金额或收支方向还不够明确,可以再说具体一点";
|
||||
if (transactions.Count == 1)
|
||||
{
|
||||
var transaction = transactions[0];
|
||||
var type = transaction.Type == TransactionType.Income
|
||||
? "收入"
|
||||
: "支出";
|
||||
return $"已记录{type}:{transaction.Category.Name} ¥{transaction.Amount:F2}{tic}";
|
||||
}
|
||||
|
||||
var income = transactions
|
||||
.Where(t => t.Type == TransactionType.Income)
|
||||
.Sum(t => t.Amount);
|
||||
var expense = transactions
|
||||
.Where(t => t.Type == TransactionType.Expense)
|
||||
.Sum(t => t.Amount);
|
||||
return $"已记录 {transactions.Count} 笔,其中收入 ¥{income:F2}、支出 ¥{expense:F2}{tic}";
|
||||
}
|
||||
|
||||
private ChatMessage AssistantText(string content, DateTime createdAt) => new()
|
||||
{
|
||||
UserId = Uid,
|
||||
Role = ChatRole.Assistant,
|
||||
Type = ChatMessageType.Text,
|
||||
Content = content,
|
||||
CreatedAt = createdAt,
|
||||
};
|
||||
|
||||
private ChatMessage BillCard(long transactionId, DateTime createdAt) => new()
|
||||
{
|
||||
UserId = Uid,
|
||||
Role = ChatRole.Assistant,
|
||||
Type = ChatMessageType.BillCard,
|
||||
Content = "",
|
||||
TransactionId = transactionId,
|
||||
CreatedAt = createdAt,
|
||||
};
|
||||
|
||||
private async Task<long> CurrentBoundaryId(CancellationToken ct = default) =>
|
||||
await db.ChatMessages
|
||||
.Where(m =>
|
||||
m.UserId == Uid &&
|
||||
m.Type == ChatMessageType.ContextBoundary)
|
||||
.MaxAsync(m => (long?)m.Id, ct) ?? 0;
|
||||
|
||||
private async Task<string> LoadConversationContext(
|
||||
long beforeMessageId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var boundaryId = await CurrentBoundaryId(ct);
|
||||
var messages = await db.ChatMessages
|
||||
.Where(m =>
|
||||
m.UserId == Uid &&
|
||||
m.Id > boundaryId &&
|
||||
m.Id < beforeMessageId &&
|
||||
m.Type == ChatMessageType.Text &&
|
||||
m.Role != ChatRole.System)
|
||||
.OrderByDescending(m => m.Id)
|
||||
.Take(12)
|
||||
.Select(m => new { m.Role, m.Content })
|
||||
.ToListAsync(ct);
|
||||
if (messages.Count == 0) return string.Empty;
|
||||
|
||||
messages.Reverse();
|
||||
var lines = messages.Select(m =>
|
||||
(m.Role == ChatRole.User ? "用户:" : "助手:") +
|
||||
(m.Content.Length > 240 ? m.Content[..240] : m.Content));
|
||||
return "\n当前会话最近消息(仅用于保持上下文,不要重复):\n" +
|
||||
string.Join("\n", lines);
|
||||
}
|
||||
|
||||
private async Task WriteSse(
|
||||
string key,
|
||||
string value,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(
|
||||
new Dictionary<string, string> { [key] = value },
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
await HttpContext.Response.WriteAsync($"data: {json}\n\n", ct);
|
||||
await HttpContext.Response.Body.FlushAsync(ct);
|
||||
}
|
||||
|
||||
private async Task WriteSsePayload(object payload, CancellationToken ct)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(
|
||||
payload,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
await HttpContext.Response.WriteAsync($"data: {json}\n\n", ct);
|
||||
await HttpContext.Response.Body.FlushAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<bool> FeatureEnabled(string key, CancellationToken ct)
|
||||
{
|
||||
var value = await db.AppConfigs
|
||||
.Where(config => config.Key == key)
|
||||
.Select(config => config.Value)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
return value is null || !value.Equals("false", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private async Task<(string PersonaKey, string AvatarKey, string Tic, string? PersonaPrompt)>
|
||||
LoadUserConfig(CancellationToken ct)
|
||||
{
|
||||
var config = await db.AiCompanionSettings
|
||||
.FirstOrDefaultAsync(s => s.UserId == Uid, ct);
|
||||
var personaKey = config?.PersonaKey ?? "sassy_cat";
|
||||
var avatarKey = config?.AvatarKey ?? "cat";
|
||||
var tic = await db.AiAvatars
|
||||
.Where(a => a.Key == avatarKey)
|
||||
.Select(a => a.SpeechTic)
|
||||
.FirstOrDefaultAsync(ct) ?? "";
|
||||
var personaPrompt = await db.AiPersonas
|
||||
.Where(p => p.Key == personaKey && p.IsEnabled)
|
||||
.Select(p => p.PromptTemplate)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
return (personaKey, avatarKey, tic, personaPrompt);
|
||||
}
|
||||
|
||||
private async Task<(string Text, string? Sticker)> StickerLlmAsync(
|
||||
string stickerKey,
|
||||
string tic,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var prompt = stickerKey == "salary"
|
||||
? "用户发了发工资的表情。问他发了多少。口癖: " + tic
|
||||
: "用户发了表情包。简短回复1句话。口癖: " + tic;
|
||||
var reply = llm.IsEnabled
|
||||
? await llm.TryGenerateReplyAsync(prompt, stickerKey, ct)
|
||||
: null;
|
||||
return (
|
||||
reply ?? (stickerKey == "salary"
|
||||
? "发工资了?多少" + tic
|
||||
: "收到" + tic),
|
||||
stickerKey == "salary" ? null : "happy");
|
||||
}
|
||||
|
||||
private static string MakeSys(
|
||||
string? personaPrompt,
|
||||
string tic,
|
||||
string context)
|
||||
{
|
||||
var basePrompt = !string.IsNullOrWhiteSpace(personaPrompt)
|
||||
? personaPrompt.Replace("{tic}", tic).Trim()
|
||||
: "你是记之 AI 助手。回复简短风趣。口癖: " + tic;
|
||||
return basePrompt + "\n" + context;
|
||||
}
|
||||
|
||||
private static ChatMessageDto ToDto(
|
||||
ChatMessage message,
|
||||
IReadOnlyDictionary<long, Transaction> transactions)
|
||||
{
|
||||
TransactionDto? transaction = null;
|
||||
if (message.TransactionId.HasValue &&
|
||||
transactions.TryGetValue(
|
||||
message.TransactionId.Value,
|
||||
out var value))
|
||||
{
|
||||
transaction = TransactionsController.ToDto(
|
||||
value,
|
||||
value.Category);
|
||||
}
|
||||
|
||||
return new ChatMessageDto(
|
||||
message.Id,
|
||||
message.Role == ChatRole.User ? "user" : "assistant",
|
||||
message.Type switch
|
||||
{
|
||||
ChatMessageType.Sticker => "sticker",
|
||||
ChatMessageType.BillCard => "bill_card",
|
||||
_ => "text",
|
||||
},
|
||||
message.Content,
|
||||
transaction,
|
||||
message.CreatedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
using System.Security.Claims;
|
||||
using MiaoJiZhang.Api.Contracts;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
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/ledgers")]
|
||||
public class LedgersController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
private long Uid => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List() =>
|
||||
Ok(await db.Ledgers.Where(l => l.OwnerId == Uid)
|
||||
.OrderByDescending(l => l.IsDefault).ThenBy(l => l.CreatedAt)
|
||||
.Select(l => new { l.Id, l.Name, l.IconKey, l.IsDefault, TxCount = db.Transactions.IgnoreQueryFilters().Count(t => t.LedgerId == l.Id && t.UserId == Uid) })
|
||||
.ToListAsync());
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateLedgerReq req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Name) || req.Name.Length > 12)
|
||||
return BadRequest(new ApiError("NAME_INVALID", "账本名 1-12 个字"));
|
||||
var maxCount = int.TryParse((await db.AppConfigs.FirstOrDefaultAsync(c => c.Key == "system.max_ledgers_per_user"))?.Value, out var v) ? v : 10;
|
||||
if (await db.Ledgers.CountAsync(l => l.OwnerId == Uid) >= maxCount)
|
||||
return BadRequest(new ApiError("LIMIT_REACHED", $"每人最多 {maxCount} 个账本"));
|
||||
var ledger = new Ledger { OwnerId = Uid, Name = req.Name.Trim(), IconKey = req.IconKey ?? "wallet" };
|
||||
db.Ledgers.Add(ledger);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { ledger.Id, ledger.Name, ledger.IconKey, ledger.IsDefault, TxCount = 0 });
|
||||
}
|
||||
|
||||
[HttpPut("{id:long}/default")]
|
||||
public async Task<IActionResult> SetDefault(long id)
|
||||
{
|
||||
var target = await db.Ledgers.FirstOrDefaultAsync(l => l.Id == id && l.OwnerId == Uid);
|
||||
if (target is null) return NotFound(new ApiError("LEDGER_NOT_FOUND", "账本不存在"));
|
||||
await db.Ledgers.Where(l => l.OwnerId == Uid && l.IsDefault).ExecuteUpdateAsync(s => s.SetProperty(l => l.IsDefault, false));
|
||||
target.IsDefault = true;
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPut("{id:long}")]
|
||||
public async Task<IActionResult> Rename(long id, UpdateLedgerReq req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Name) || req.Name.Trim().Length > 12)
|
||||
return BadRequest(new ApiError("NAME_INVALID", "账本名 1-12 个字"));
|
||||
var ledger = await db.Ledgers.FirstOrDefaultAsync(l => l.Id == id && l.OwnerId == Uid);
|
||||
if (ledger is null) return NotFound(new ApiError("LEDGER_NOT_FOUND", "账本不存在"));
|
||||
ledger.Name = req.Name.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(req.IconKey)) ledger.IconKey = req.IconKey.Trim();
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new LedgerDto(ledger.Id, ledger.Name, ledger.IconKey, ledger.IsDefault));
|
||||
}
|
||||
|
||||
[HttpPut("reorder")]
|
||||
public async Task<IActionResult> Reorder(ReorderCategoriesRequest req)
|
||||
{
|
||||
if (req.Type is not ("income" or "expense"))
|
||||
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
|
||||
if (req.CategoryIds.Count != req.CategoryIds.Distinct().Count())
|
||||
return BadRequest(new ApiError("ORDER_INVALID", "分类排序中存在重复项"));
|
||||
|
||||
var type = req.Type == "income"
|
||||
? TransactionType.Income
|
||||
: TransactionType.Expense;
|
||||
var categories = await db.Categories
|
||||
.Where(c => c.UserId == Uid && c.Type == type && !c.IsDeleted)
|
||||
.ToListAsync();
|
||||
if (categories.Count != req.CategoryIds.Count ||
|
||||
categories.Select(c => c.Id).ToHashSet()
|
||||
.SetEquals(req.CategoryIds) is false)
|
||||
return BadRequest(new ApiError("ORDER_INVALID", "请提交完整的自定义分类顺序"));
|
||||
|
||||
var byId = categories.ToDictionary(c => c.Id);
|
||||
for (var index = 0; index < req.CategoryIds.Count; index++)
|
||||
byId[req.CategoryIds[index]].SortOrder = 1000 + index;
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:long}")]
|
||||
public async Task<IActionResult> Delete(long id)
|
||||
{
|
||||
var ledger = await db.Ledgers.FirstOrDefaultAsync(l => l.Id == id && l.OwnerId == Uid);
|
||||
if (ledger is null) return NotFound(new ApiError("LEDGER_NOT_FOUND", "账本不存在"));
|
||||
if (ledger.IsDefault)
|
||||
return Conflict(new ApiError("DEFAULT_LEDGER", "默认账本不能删除,请先切换到其他账本"));
|
||||
var hasTransactions = await db.Transactions.IgnoreQueryFilters()
|
||||
.AnyAsync(t => t.UserId == Uid && t.LedgerId == id);
|
||||
var hasBudgets = await db.Budgets.AnyAsync(b => b.UserId == Uid && b.LedgerId == id);
|
||||
if (hasTransactions || hasBudgets)
|
||||
return Conflict(new ApiError("LEDGER_NOT_EMPTY", "仅可删除没有账单和预算的账本"));
|
||||
db.Ledgers.Remove(ledger);
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateLedgerReq(string Name, string? IconKey);
|
||||
public record UpdateLedgerReq(string Name, string? IconKey);
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/categories")]
|
||||
public class CategoriesController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
internal static readonly HashSet<string> AllowedIcons =
|
||||
[
|
||||
"food", "cup", "cart", "house", "rent", "metro", "car", "plane",
|
||||
"phone", "wifi", "shirt", "beauty", "pill", "sport", "book", "baby",
|
||||
"pet", "game", "gift", "wallet", "money", "briefcase", "chart",
|
||||
"interest", "refund", "card", "insurance", "tax", "receipt",
|
||||
"camera", "target", "sparkle", "tag",
|
||||
];
|
||||
|
||||
internal static readonly HashSet<string> AllowedColors =
|
||||
[
|
||||
"mint", "teal", "aqua", "cyan", "sky", "blue",
|
||||
"navy", "indigo", "violet", "plum", "orchid", "rose",
|
||||
"coral", "red", "orange", "amber", "peach", "sand",
|
||||
"lime", "olive", "forest", "slate", "cocoa", "graphite",
|
||||
];
|
||||
|
||||
private long Uid => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!);
|
||||
|
||||
internal static string NormalizeIcon(string? iconKey) => iconKey?.Trim() switch
|
||||
{
|
||||
"shopping" => "cart",
|
||||
"transport" => "metro",
|
||||
"home" => "house",
|
||||
"salary" => "money",
|
||||
{ Length: > 0 } value => value,
|
||||
_ => "tag",
|
||||
};
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<CategoryDto>>> List([FromQuery] string type = "expense")
|
||||
{
|
||||
if (type is not ("income" or "expense"))
|
||||
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
|
||||
var t = type == "income" ? TransactionType.Income : TransactionType.Expense;
|
||||
var list = await db.Categories
|
||||
.Where(c => !c.IsDeleted && c.Type == t && (c.UserId == null || c.UserId == Uid))
|
||||
.OrderBy(c => c.SortOrder)
|
||||
.Select(c => new CategoryDto(
|
||||
c.Id, c.Name, c.IconKey, type,
|
||||
c.SortOrder, c.UserId != null, c.ColorKey))
|
||||
.ToListAsync();
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<CategoryDto>> Create(CreateCategoryRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Name) || req.Name.Length > 8)
|
||||
return BadRequest(new ApiError("NAME_INVALID", "分类名 1-8 个字"));
|
||||
if (req.Type is not ("income" or "expense"))
|
||||
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
|
||||
var t = req.Type == "income" ? TransactionType.Income : TransactionType.Expense;
|
||||
var iconKey = NormalizeIcon(req.IconKey);
|
||||
if (!AllowedIcons.Contains(iconKey))
|
||||
return BadRequest(new ApiError("ICON_INVALID", "分类图标不存在"));
|
||||
var colorKey = string.IsNullOrWhiteSpace(req.ColorKey)
|
||||
? "mint"
|
||||
: req.ColorKey.Trim();
|
||||
if (!AllowedColors.Contains(colorKey))
|
||||
return BadRequest(new ApiError("COLOR_INVALID", "分类颜色不存在"));
|
||||
if (await db.Categories.AnyAsync(c => !c.IsDeleted && c.Type == t && c.Name == req.Name && (c.UserId == null || c.UserId == Uid)))
|
||||
return Conflict(new ApiError("NAME_TAKEN", "分类已存在"));
|
||||
var maxSort = await db.Categories
|
||||
.Where(c => c.Type == t && (c.UserId == null || c.UserId == Uid))
|
||||
.MaxAsync(c => (int?)c.SortOrder) ?? 0;
|
||||
var cat = new Category
|
||||
{
|
||||
UserId = Uid,
|
||||
Type = t,
|
||||
Name = req.Name.Trim(),
|
||||
IconKey = iconKey,
|
||||
ColorKey = colorKey,
|
||||
SortOrder = Math.Max(999, maxSort) + 1,
|
||||
};
|
||||
db.Categories.Add(cat);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new CategoryDto(
|
||||
cat.Id, cat.Name, cat.IconKey, req.Type,
|
||||
cat.SortOrder, true, cat.ColorKey));
|
||||
}
|
||||
|
||||
[HttpPut("{id:long}")]
|
||||
public async Task<ActionResult<CategoryDto>> Update(long id, UpdateCategoryRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Name) || req.Name.Trim().Length > 8)
|
||||
return BadRequest(new ApiError("NAME_INVALID", "分类名 1-8 个字"));
|
||||
var category = await db.Categories.FirstOrDefaultAsync(c =>
|
||||
c.Id == id && c.UserId == Uid && !c.IsDeleted);
|
||||
if (category is null)
|
||||
return NotFound(new ApiError("CATEGORY_NOT_FOUND", "分类不存在或非自定义分类"));
|
||||
var name = req.Name.Trim();
|
||||
if (await db.Categories.AnyAsync(c => c.Id != id && !c.IsDeleted &&
|
||||
c.Type == category.Type && c.Name == name &&
|
||||
(c.UserId == null || c.UserId == Uid)))
|
||||
return Conflict(new ApiError("NAME_TAKEN", "分类已存在"));
|
||||
var iconKey = NormalizeIcon(req.IconKey);
|
||||
if (!AllowedIcons.Contains(iconKey))
|
||||
return BadRequest(new ApiError("ICON_INVALID", "分类图标不存在"));
|
||||
var colorKey = req.ColorKey is null
|
||||
? category.ColorKey
|
||||
: req.ColorKey.Trim();
|
||||
if (!AllowedColors.Contains(colorKey))
|
||||
return BadRequest(new ApiError("COLOR_INVALID", "分类颜色不存在"));
|
||||
category.Name = name;
|
||||
category.IconKey = iconKey;
|
||||
category.ColorKey = colorKey;
|
||||
category.SortOrder = Math.Max(1000, req.SortOrder);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new CategoryDto(
|
||||
category.Id, category.Name, category.IconKey,
|
||||
category.Type == TransactionType.Income ? "income" : "expense",
|
||||
category.SortOrder, true, category.ColorKey));
|
||||
}
|
||||
|
||||
[HttpPut("reorder")]
|
||||
public async Task<IActionResult> Reorder(ReorderCategoriesRequest req)
|
||||
{
|
||||
if (req.Type is not ("income" or "expense"))
|
||||
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
|
||||
if (req.CategoryIds.Count != req.CategoryIds.Distinct().Count())
|
||||
return BadRequest(new ApiError("ORDER_INVALID", "分类排序中存在重复项"));
|
||||
|
||||
var type = req.Type == "income"
|
||||
? TransactionType.Income
|
||||
: TransactionType.Expense;
|
||||
var categories = await db.Categories
|
||||
.Where(c => c.UserId == Uid && c.Type == type && !c.IsDeleted)
|
||||
.ToListAsync();
|
||||
if (categories.Count != req.CategoryIds.Count ||
|
||||
categories.Select(c => c.Id).ToHashSet()
|
||||
.SetEquals(req.CategoryIds) is false)
|
||||
return BadRequest(new ApiError("ORDER_INVALID", "请提交完整的自定义分类顺序"));
|
||||
|
||||
var byId = categories.ToDictionary(c => c.Id);
|
||||
for (var index = 0; index < req.CategoryIds.Count; index++)
|
||||
byId[req.CategoryIds[index]].SortOrder = 1000 + index;
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:long}")]
|
||||
public async Task<IActionResult> Delete(long id)
|
||||
{
|
||||
var cat = await db.Categories.FirstOrDefaultAsync(c => c.Id == id && c.UserId == Uid);
|
||||
if (cat is null) return NotFound(new ApiError("CATEGORY_NOT_FOUND", "分类不存在或非自定义分类"));
|
||||
cat.IsDeleted = true;
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
public record ReorderCategoriesRequest(string Type, List<long> CategoryIds);
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
using System.Security.Claims;
|
||||
using MiaoJiZhang.Api.Contracts;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Domain.Enums;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// OCR/语音/图片辅助解析。客户端确认后再调用交易接口正式入账。
|
||||
/// 图片模式由后端多模态模型提取一笔或多笔账单草稿。
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/parse")]
|
||||
[RequireAiPermission]
|
||||
public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent) : ControllerBase
|
||||
{
|
||||
private long Uid => long.Parse(
|
||||
User.FindFirstValue(ClaimTypes.NameIdentifier) ??
|
||||
User.FindFirstValue("sub")!);
|
||||
|
||||
[HttpPost]
|
||||
[EnableRateLimiting("ai")]
|
||||
public async Task<ActionResult<OcrParseResponse>> Parse(OcrParseRequest req)
|
||||
{
|
||||
return await ParseWithAgent(req, HttpContext.RequestAborted);
|
||||
}
|
||||
|
||||
private async Task<ActionResult<OcrParseResponse>> ParseWithAgent(
|
||||
OcrParseRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Text))
|
||||
return BadRequest(new ApiError("TEXT_EMPTY", "内容不能为空"));
|
||||
var featureKey = req.Source == "voice"
|
||||
? "feature.voice_enabled"
|
||||
: "feature.ocr_enabled";
|
||||
if (!await FeatureEnabled(featureKey))
|
||||
return StatusCode(403, new ApiError("FEATURE_DISABLED", "该解析功能已由后台关闭"));
|
||||
|
||||
IReadOnlyList<ParsedBill> drafts;
|
||||
try
|
||||
{
|
||||
drafts = await agent.ParseDraftsAsync(Uid, req.Text.Trim(), ct);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return StatusCode(
|
||||
503,
|
||||
new ApiError("AGENT_PARSE_UNAVAILABLE", ex.Message));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return StatusCode(
|
||||
502,
|
||||
new ApiError(
|
||||
"AGENT_PARSE_FAILED",
|
||||
"AI 暂时无法解析,没有生成账单草稿"));
|
||||
}
|
||||
|
||||
if (drafts.Count == 0)
|
||||
{
|
||||
var fallback = await db.Categories.FirstAsync(
|
||||
c => c.Name == "其他" &&
|
||||
c.Type == TransactionType.Expense &&
|
||||
c.UserId == null,
|
||||
ct);
|
||||
return Ok(new OcrParseResponse(
|
||||
false,
|
||||
fallback.Id,
|
||||
fallback.Name,
|
||||
fallback.IconKey,
|
||||
0,
|
||||
null,
|
||||
req.Text,
|
||||
"expense"));
|
||||
}
|
||||
|
||||
var bill = drafts[0];
|
||||
var category = await db.Categories.FirstAsync(
|
||||
c => c.Id == bill.CategoryId && c.Type == bill.Type,
|
||||
ct);
|
||||
return Ok(new OcrParseResponse(
|
||||
true,
|
||||
category.Id,
|
||||
category.Name,
|
||||
category.IconKey,
|
||||
bill.Amount,
|
||||
bill.PaymentMethod,
|
||||
bill.Note,
|
||||
bill.Type == TransactionType.Income ? "income" : "expense"));
|
||||
}
|
||||
|
||||
/// <summary>上传截屏或小票,提取其中全部独立交易。</summary>
|
||||
[HttpPost("image")]
|
||||
[EnableRateLimiting("upload")]
|
||||
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 10 * 1024 * 1024)]
|
||||
public async Task<ActionResult<ImageParseResponse>> ParseImage(
|
||||
IFormFile file,
|
||||
[FromQuery] string source = "image",
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var featureKey = source == "screenshot"
|
||||
? "feature.screenshot_bookkeeping_enabled"
|
||||
: "feature.ocr_enabled";
|
||||
if (!await FeatureEnabled(featureKey))
|
||||
return StatusCode(403, new ApiError("FEATURE_DISABLED", "该图片识别功能已由后台关闭"));
|
||||
if (file is null || file.Length == 0)
|
||||
return BadRequest(new ApiError("FILE_EMPTY", "请上传图片"));
|
||||
if (!file.ContentType.StartsWith("image/"))
|
||||
return BadRequest(new ApiError("NOT_IMAGE", "仅支持图片文件"));
|
||||
if (file.Length > 10 * 1024 * 1024)
|
||||
return BadRequest(new ApiError("FILE_TOO_LARGE", "图片不能超过 10MB"));
|
||||
|
||||
if (!llm.IsEnabled)
|
||||
{
|
||||
return StatusCode(
|
||||
503,
|
||||
new ApiError(
|
||||
"LLM_NOT_CONFIGURED",
|
||||
"AI 图片解析未配置,请在管理后台设置 LLM API Key"));
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
await file.CopyToAsync(stream, ct);
|
||||
|
||||
IReadOnlyList<ImageParseResult>? results;
|
||||
try
|
||||
{
|
||||
results = await llm.AnalyzeImageAsync(
|
||||
stream.ToArray(),
|
||||
file.ContentType,
|
||||
ct);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return StatusCode(502, new ApiError("LLM_IMAGE_ERROR", ex.Message));
|
||||
}
|
||||
|
||||
if (results is null)
|
||||
return StatusCode(500, new ApiError("LLM_ERROR", "AI 解析失败,请稍后重试"));
|
||||
|
||||
var categories = await db.Categories
|
||||
.Where(c => !c.IsDeleted && (c.UserId == null || c.UserId == Uid))
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (results.Count == 0)
|
||||
{
|
||||
return Ok(new ImageParseResponse(
|
||||
false,
|
||||
0,
|
||||
"待确认",
|
||||
"tag",
|
||||
0,
|
||||
null,
|
||||
"",
|
||||
"unknown",
|
||||
[],
|
||||
null));
|
||||
}
|
||||
|
||||
var items = new List<ImageParseItemResponse>();
|
||||
foreach (var result in results.Take(20))
|
||||
{
|
||||
var normalizedType = result.Type.Trim().ToLowerInvariant();
|
||||
if (normalizedType is not ("income" or "expense"))
|
||||
{
|
||||
items.Add(new ImageParseItemResponse(
|
||||
false, 0, "待确认", "tag", "unknown",
|
||||
result.Amount, result.PaymentMethod, result.Note,
|
||||
result.OccurredAt));
|
||||
continue;
|
||||
}
|
||||
var type = normalizedType == "income"
|
||||
? TransactionType.Income
|
||||
: TransactionType.Expense;
|
||||
var category = FindCategory(categories, type, result.CategoryName);
|
||||
items.Add(new ImageParseItemResponse(
|
||||
true,
|
||||
category.Id,
|
||||
category.Name,
|
||||
category.IconKey,
|
||||
normalizedType,
|
||||
result.Amount,
|
||||
result.PaymentMethod,
|
||||
result.Note,
|
||||
result.OccurredAt));
|
||||
}
|
||||
|
||||
var first = items[0];
|
||||
return Ok(new ImageParseResponse(
|
||||
true,
|
||||
first.CategoryId,
|
||||
first.CategoryName,
|
||||
first.CategoryIcon,
|
||||
first.Amount,
|
||||
first.PaymentMethod,
|
||||
first.Note,
|
||||
first.Type,
|
||||
items,
|
||||
first.OccurredAt));
|
||||
}
|
||||
|
||||
private async Task<bool> FeatureEnabled(string key)
|
||||
{
|
||||
var value = await db.AppConfigs
|
||||
.Where(config => config.Key == key)
|
||||
.Select(config => config.Value)
|
||||
.FirstOrDefaultAsync();
|
||||
return value is null || !value.Equals("false", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private Category FindCategory(
|
||||
List<Category> categories,
|
||||
TransactionType type,
|
||||
string requestedName)
|
||||
{
|
||||
if (type == TransactionType.Income &&
|
||||
requestedName is "退款" or "返现" or "退回")
|
||||
{
|
||||
requestedName = "报销";
|
||||
}
|
||||
return categories
|
||||
.Where(c => c.Type == type && c.Name == requestedName)
|
||||
.OrderByDescending(c => c.UserId == Uid)
|
||||
.FirstOrDefault()
|
||||
?? categories.First(
|
||||
c => c.Type == type &&
|
||||
c.Name == "其他" &&
|
||||
c.UserId == null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Controllers;
|
||||
|
||||
/// <summary>公开配置:前端拉取品牌信息、可用形象/性格列表(无需管理员权限)</summary>
|
||||
[ApiController]
|
||||
[Route("api/public")]
|
||||
public class PublicConfigController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
/// <summary>品牌配置(App 名称、Slogan、Logo)</summary>
|
||||
[HttpGet("brand")]
|
||||
public async Task<IActionResult> Brand()
|
||||
{
|
||||
var configs = await db.AppConfigs
|
||||
.Where(c => c.Key.StartsWith("brand.") || c.Key.StartsWith("feature."))
|
||||
.ToDictionaryAsync(c => c.Key, c => c.Value);
|
||||
bool Enabled(string key) =>
|
||||
!configs.TryGetValue(key, out var value) ||
|
||||
!value.Equals("false", StringComparison.OrdinalIgnoreCase);
|
||||
return Ok(new
|
||||
{
|
||||
appName = configs.GetValueOrDefault("brand.app_name", "记之"),
|
||||
slogan = configs.GetValueOrDefault("brand.slogan", ""),
|
||||
logoUrl = configs.GetValueOrDefault("brand.logo_url", ""),
|
||||
features = new
|
||||
{
|
||||
voice = Enabled("feature.voice_enabled"),
|
||||
image = Enabled("feature.ocr_enabled"),
|
||||
screenshot = Enabled("feature.screenshot_bookkeeping_enabled"),
|
||||
stickers = Enabled("feature.sticker_enabled"),
|
||||
aiAutoBook = Enabled("feature.ai_auto_book"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>可用的 AI 形象列表(按 Key 排序)</summary>
|
||||
[HttpGet("avatars")]
|
||||
public async Task<IActionResult> Avatars() =>
|
||||
Ok(await db.AiAvatars.Where(a => a.IsEnabled).OrderBy(a => a.Key)
|
||||
.Select(a => new { a.Key, a.DefaultName, a.SpeechTic, a.ImageUrl }).ToListAsync());
|
||||
|
||||
/// <summary>可用的 AI 性格列表(按 Key 排序)</summary>
|
||||
[HttpGet("personas")]
|
||||
public async Task<IActionResult> Personas() =>
|
||||
Ok(await db.AiPersonas.Where(p => p.IsEnabled).OrderBy(p => p.Key)
|
||||
.Select(p => new { p.Key, p.Name, p.Description, p.SampleLine }).ToListAsync());
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
using System.Security.Claims;
|
||||
using MiaoJiZhang.Api.Contracts;
|
||||
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/reports")]
|
||||
public class ReportsController(
|
||||
AppDbContext db,
|
||||
ReplyService reply,
|
||||
LedgerResolver ledgers,
|
||||
AiPermissionService aiPermissions) : ControllerBase
|
||||
{
|
||||
private long Uid => long.Parse(
|
||||
User.FindFirstValue(ClaimTypes.NameIdentifier) ??
|
||||
User.FindFirstValue("sub")!);
|
||||
|
||||
[HttpGet("weekly")]
|
||||
public async Task<ActionResult<PeriodReportDto>> Weekly(
|
||||
[FromQuery] DateTime? date = null,
|
||||
[FromQuery] long? ledgerId = null)
|
||||
{
|
||||
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
|
||||
if (!resolvedLedgerId.HasValue)
|
||||
return BadRequest(new ApiError(
|
||||
"LEDGER_NOT_FOUND",
|
||||
"账本不存在或无权访问"));
|
||||
|
||||
var selected = DateTime.SpecifyKind(
|
||||
(date ?? ChinaClock.Now).Date,
|
||||
DateTimeKind.Unspecified);
|
||||
var (start, end) = ChinaClock.WeekRangeUtc(selected);
|
||||
var localStart = ChinaClock.ToLocal(start).Date;
|
||||
var localEnd = ChinaClock.ToLocal(end).Date.AddDays(-1);
|
||||
return Ok(await BuildAsync(
|
||||
resolvedLedgerId.Value,
|
||||
start,
|
||||
end,
|
||||
"weekly",
|
||||
$"{localStart:MM月dd日} - {localEnd:MM月dd日}",
|
||||
groupByMonth: false));
|
||||
}
|
||||
|
||||
[HttpGet("monthly")]
|
||||
public async Task<ActionResult<MonthlyReportDto>> Monthly(
|
||||
[FromQuery] int year,
|
||||
[FromQuery] int month,
|
||||
[FromQuery] long? ledgerId = null)
|
||||
{
|
||||
if (month is < 1 or > 12)
|
||||
return BadRequest(new ApiError("MONTH_INVALID", "月份无效"));
|
||||
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
|
||||
if (!resolvedLedgerId.HasValue)
|
||||
return BadRequest(new ApiError(
|
||||
"LEDGER_NOT_FOUND",
|
||||
"账本不存在或无权访问"));
|
||||
|
||||
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
|
||||
var report = await BuildAsync(
|
||||
resolvedLedgerId.Value,
|
||||
start,
|
||||
end,
|
||||
"monthly",
|
||||
$"{year}年{month}月",
|
||||
groupByMonth: false);
|
||||
return Ok(new MonthlyReportDto(
|
||||
year,
|
||||
month,
|
||||
report.Income,
|
||||
report.Expense,
|
||||
report.Balance,
|
||||
report.Count,
|
||||
report.AiRatio,
|
||||
report.PeakLabel,
|
||||
report.PeakAmount,
|
||||
report.PeakNote,
|
||||
report.TopCategory,
|
||||
report.TopCategoryAmount,
|
||||
report.TopCategoryPercent,
|
||||
report.CategoryRanking,
|
||||
report.Commentary));
|
||||
}
|
||||
|
||||
[HttpGet("yearly")]
|
||||
public async Task<ActionResult<PeriodReportDto>> Yearly(
|
||||
[FromQuery] int year,
|
||||
[FromQuery] long? ledgerId = null)
|
||||
{
|
||||
if (year is < 2000 or > 2200)
|
||||
return BadRequest(new ApiError("YEAR_INVALID", "年份无效"));
|
||||
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
|
||||
if (!resolvedLedgerId.HasValue)
|
||||
return BadRequest(new ApiError(
|
||||
"LEDGER_NOT_FOUND",
|
||||
"账本不存在或无权访问"));
|
||||
|
||||
var (start, end) = ChinaClock.YearRangeUtc(year);
|
||||
return Ok(await BuildAsync(
|
||||
resolvedLedgerId.Value,
|
||||
start,
|
||||
end,
|
||||
"yearly",
|
||||
$"{year}年",
|
||||
groupByMonth: true));
|
||||
}
|
||||
|
||||
private async Task<PeriodReportDto> BuildAsync(
|
||||
long ledgerId,
|
||||
DateTime start,
|
||||
DateTime end,
|
||||
string periodType,
|
||||
string periodLabel,
|
||||
bool groupByMonth)
|
||||
{
|
||||
var list = await db.Transactions
|
||||
.Include(transaction => transaction.Category)
|
||||
.Where(transaction =>
|
||||
transaction.UserId == Uid &&
|
||||
transaction.LedgerId == ledgerId &&
|
||||
transaction.OccurredAt >= start &&
|
||||
transaction.OccurredAt < end)
|
||||
.ToListAsync();
|
||||
|
||||
var income = list
|
||||
.Where(transaction => transaction.Type == TransactionType.Income)
|
||||
.Sum(transaction => transaction.Amount);
|
||||
var expenses = list
|
||||
.Where(transaction => transaction.Type == TransactionType.Expense)
|
||||
.ToList();
|
||||
var expense = expenses.Sum(transaction => transaction.Amount);
|
||||
var aiCount = list.Count(
|
||||
transaction => transaction.Source.IsAiAssisted());
|
||||
var aiRatio = list.Count == 0
|
||||
? 0
|
||||
: (double)aiCount / list.Count * 100;
|
||||
|
||||
var peak = expenses
|
||||
.GroupBy(transaction =>
|
||||
{
|
||||
var local = ChinaClock.ToLocal(transaction.OccurredAt);
|
||||
return groupByMonth
|
||||
? new DateTime(local.Year, local.Month, 1)
|
||||
: local.Date;
|
||||
})
|
||||
.OrderByDescending(group => group.Sum(item => item.Amount))
|
||||
.FirstOrDefault();
|
||||
var categoryRanking = expenses
|
||||
.GroupBy(transaction => new
|
||||
{
|
||||
transaction.Category.Name,
|
||||
transaction.Category.IconKey,
|
||||
transaction.Category.ColorKey,
|
||||
})
|
||||
.Select(group => new CategoryRankDto(
|
||||
group.Key.Name,
|
||||
group.Key.IconKey,
|
||||
group.Sum(item => item.Amount),
|
||||
expense == 0
|
||||
? 0
|
||||
: (double)(group.Sum(item => item.Amount) / expense * 100),
|
||||
group.Key.ColorKey))
|
||||
.OrderByDescending(item => item.Amount)
|
||||
.ThenBy(item => item.Name)
|
||||
.Take(5)
|
||||
.ToList();
|
||||
var topCategory = categoryRanking.FirstOrDefault();
|
||||
|
||||
var commentary = await aiPermissions.IsEnabledAsync(Uid, HttpContext.RequestAborted)
|
||||
? await reply.PeriodRoastAsync(
|
||||
Uid,
|
||||
periodType switch
|
||||
{
|
||||
"weekly" => "这周",
|
||||
"yearly" => "这一年",
|
||||
_ => "这个月",
|
||||
},
|
||||
income,
|
||||
expense,
|
||||
topCategory?.Name ?? "无",
|
||||
topCategory?.Amount ?? 0)
|
||||
: string.Empty;
|
||||
|
||||
var localStart = ChinaClock.ToLocal(start).Date;
|
||||
var localEnd = ChinaClock.ToLocal(end).Date;
|
||||
return new PeriodReportDto(
|
||||
periodType,
|
||||
periodLabel,
|
||||
localStart,
|
||||
localEnd,
|
||||
income,
|
||||
expense,
|
||||
income - expense,
|
||||
list.Count,
|
||||
aiRatio,
|
||||
peak is null
|
||||
? null
|
||||
: groupByMonth
|
||||
? $"{peak.Key.Month}月"
|
||||
: $"{peak.Key.Month}月{peak.Key.Day}日",
|
||||
peak?.Sum(item => item.Amount) ?? 0,
|
||||
peak?.OrderByDescending(item => item.Amount).First().Note,
|
||||
topCategory?.Name,
|
||||
topCategory?.Amount ?? 0,
|
||||
expense == 0 || topCategory is null
|
||||
? 0
|
||||
: (double)(topCategory.Amount / expense * 100),
|
||||
categoryRanking,
|
||||
commentary);
|
||||
}
|
||||
}
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/search")]
|
||||
public class SearchController(AppDbContext db, LedgerResolver ledgers) : ControllerBase
|
||||
{
|
||||
private long Uid => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!);
|
||||
|
||||
/// <summary>账单搜索:过滤全部在数据库执行,使用时间和 ID 作为稳定游标。</summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<TransactionDto>>> Search(
|
||||
[FromQuery] string? q,
|
||||
[FromQuery] long? ledgerId,
|
||||
[FromQuery] long? categoryId,
|
||||
[FromQuery] string? type,
|
||||
[FromQuery] decimal? minAmount,
|
||||
[FromQuery] decimal? maxAmount,
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] bool aiOnly = false,
|
||||
[FromQuery] DateTime? beforeOccurredAt = null,
|
||||
[FromQuery] long? beforeId = null,
|
||||
[FromQuery] int limit = 50)
|
||||
{
|
||||
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
|
||||
if (!resolvedLedgerId.HasValue)
|
||||
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
|
||||
if (type is not null && type is not ("income" or "expense"))
|
||||
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
|
||||
|
||||
var query = db.Transactions.Include(t => t.Category)
|
||||
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
var keyword = q.Trim();
|
||||
query = query.Where(t =>
|
||||
(t.Note != null && t.Note.Contains(keyword)) ||
|
||||
t.Category.Name.Contains(keyword) ||
|
||||
(t.SourceText != null && t.SourceText.Contains(keyword)));
|
||||
}
|
||||
if (categoryId.HasValue) query = query.Where(t => t.CategoryId == categoryId.Value);
|
||||
if (type == "income") query = query.Where(t => t.Type == TransactionType.Income);
|
||||
if (type == "expense") query = query.Where(t => t.Type == TransactionType.Expense);
|
||||
if (minAmount.HasValue) query = query.Where(t => t.Amount >= minAmount.Value);
|
||||
if (maxAmount.HasValue) query = query.Where(t => t.Amount <= maxAmount.Value);
|
||||
if (from.HasValue) query = query.Where(t => t.OccurredAt >= NormalizeTime(from.Value));
|
||||
if (to.HasValue) query = query.Where(t => t.OccurredAt < NormalizeTime(to.Value));
|
||||
if (aiOnly) query = query.Where(t => TransactionSourceRules.AiAssisted.Contains(t.Source));
|
||||
if (beforeOccurredAt.HasValue)
|
||||
{
|
||||
var cursorTime = NormalizeTime(beforeOccurredAt.Value);
|
||||
query = query.Where(t =>
|
||||
t.OccurredAt < cursorTime ||
|
||||
(t.OccurredAt == cursorTime && (!beforeId.HasValue || t.Id < beforeId.Value)));
|
||||
}
|
||||
|
||||
var list = await query
|
||||
.OrderByDescending(t => t.OccurredAt)
|
||||
.ThenByDescending(t => t.Id)
|
||||
.Take(Math.Clamp(limit, 1, 100))
|
||||
.ToListAsync();
|
||||
return Ok(list.Select(t => TransactionsController.ToDto(t, t.Category)).ToList());
|
||||
}
|
||||
|
||||
private static DateTime NormalizeTime(DateTime value) =>
|
||||
value.Kind == DateTimeKind.Utc ? value : ChinaClock.ToUtc(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
using System.Security.Claims;
|
||||
using MiaoJiZhang.Api.Contracts;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
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/transactions")]
|
||||
public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : ControllerBase
|
||||
{
|
||||
private long Uid => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!);
|
||||
|
||||
/// <summary>手动记一笔</summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<TransactionDto>> Create(CreateTransactionRequest req)
|
||||
{
|
||||
if (req.Amount <= 0) return BadRequest(new ApiError("AMOUNT_INVALID", "金额必须大于 0"));
|
||||
var type = ParseType(req.Type);
|
||||
if (!type.HasValue)
|
||||
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
|
||||
|
||||
var clientRequestId = string.IsNullOrWhiteSpace(req.ClientRequestId)
|
||||
? null
|
||||
: req.ClientRequestId.Trim();
|
||||
if (clientRequestId?.Length > 64)
|
||||
return BadRequest(new ApiError("CLIENT_REQUEST_ID_INVALID", "幂等标识最长 64 个字符"));
|
||||
if (clientRequestId != null)
|
||||
{
|
||||
var existing = await db.Transactions
|
||||
.Include(t => t.Category)
|
||||
.FirstOrDefaultAsync(t =>
|
||||
t.UserId == Uid && t.ClientRequestId == clientRequestId);
|
||||
if (existing is not null) return Ok(ToDto(existing, existing.Category));
|
||||
}
|
||||
|
||||
var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId);
|
||||
if (!ledgerId.HasValue)
|
||||
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
|
||||
|
||||
var cat = await db.Categories.FirstOrDefaultAsync(c =>
|
||||
c.Id == req.CategoryId && !c.IsDeleted && c.Type == type.Value &&
|
||||
(c.UserId == null || c.UserId == Uid));
|
||||
if (cat is null) return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致"));
|
||||
|
||||
var tx = new Transaction
|
||||
{
|
||||
LedgerId = ledgerId.Value,
|
||||
UserId = Uid,
|
||||
CategoryId = cat.Id,
|
||||
OccurredAt = req.OccurredAt.HasValue
|
||||
? NormalizeOccurredAt(req.OccurredAt.Value)
|
||||
: DateTime.UtcNow,
|
||||
Type = type.Value,
|
||||
Amount = req.Amount,
|
||||
Note = req.Note,
|
||||
PaymentMethod = req.PaymentMethod,
|
||||
Source = req.Source switch
|
||||
{
|
||||
"voice" => TransactionSource.Voice,
|
||||
"ocr" => TransactionSource.ReceiptOcr,
|
||||
"screenshot" => TransactionSource.Screenshot,
|
||||
"accessibility" => TransactionSource.Accessibility,
|
||||
"notification" => TransactionSource.Notification,
|
||||
"recognition_ai" => TransactionSource.RecognitionAi,
|
||||
"local_ocr" => TransactionSource.LocalOcr,
|
||||
_ => TransactionSource.Manual,
|
||||
},
|
||||
SourceText = req.SourceText,
|
||||
ClientRequestId = clientRequestId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.Transactions.Add(tx);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToDto(tx, cat));
|
||||
}
|
||||
catch (DbUpdateException) when (clientRequestId is not null)
|
||||
{
|
||||
// Another channel may have committed the same recognition candidate
|
||||
// after the initial lookup. Resolve the unique-key race as idempotent success.
|
||||
db.Entry(tx).State = EntityState.Detached;
|
||||
var existing = await db.Transactions
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Category)
|
||||
.FirstOrDefaultAsync(t =>
|
||||
t.UserId == Uid && t.ClientRequestId == clientRequestId);
|
||||
if (existing is not null) return Ok(ToDto(existing, existing.Category));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>账单详情(P11:AI 来源追溯、分类、备注、时间)</summary>
|
||||
[HttpGet("{id:long}")]
|
||||
public async Task<ActionResult<TransactionDto>> Detail(long id)
|
||||
{
|
||||
var tx = await db.Transactions.Include(t => t.Category)
|
||||
.FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid);
|
||||
if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "账单不存在"));
|
||||
return Ok(ToDto(tx, tx.Category));
|
||||
}
|
||||
|
||||
[HttpPut("{id:long}")]
|
||||
public async Task<ActionResult<TransactionDto>> Update(long id, UpdateTransactionRequest req)
|
||||
{
|
||||
if (req.Amount <= 0) return BadRequest(new ApiError("AMOUNT_INVALID", "金额必须大于 0"));
|
||||
var type = ParseType(req.Type);
|
||||
if (!type.HasValue)
|
||||
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
|
||||
var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId);
|
||||
if (!ledgerId.HasValue)
|
||||
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
|
||||
var category = await db.Categories.FirstOrDefaultAsync(c =>
|
||||
c.Id == req.CategoryId && !c.IsDeleted && c.Type == type.Value &&
|
||||
(c.UserId == null || c.UserId == Uid));
|
||||
if (category is null) return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致"));
|
||||
var tx = await db.Transactions.FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid);
|
||||
if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "账单不存在"));
|
||||
|
||||
if (HasVersionConflict(tx, req.BaseUpdatedAt))
|
||||
return Conflict(new
|
||||
{
|
||||
code = "SYNC_CONFLICT",
|
||||
message = "账单已在其他设备修改,请选择保留本地或云端版本",
|
||||
server = ToDto(tx, await db.Categories.FindAsync(tx.CategoryId) ?? category),
|
||||
});
|
||||
tx.LedgerId = ledgerId.Value;
|
||||
tx.CategoryId = category.Id;
|
||||
tx.Category = category;
|
||||
tx.Type = type.Value;
|
||||
tx.Amount = req.Amount;
|
||||
tx.Note = req.Note?.Trim();
|
||||
tx.PaymentMethod = req.PaymentMethod?.Trim();
|
||||
tx.OccurredAt = NormalizeOccurredAt(req.OccurredAt);
|
||||
tx.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToDto(tx, category));
|
||||
}
|
||||
|
||||
/// <summary>撤销/删除(软删除,决策 18)</summary>
|
||||
[HttpDelete("{id:long}")]
|
||||
public async Task<IActionResult> Delete(long id, [FromQuery] DateTime? baseUpdatedAt = null)
|
||||
{
|
||||
var tx = await db.Transactions.FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid);
|
||||
if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "账单不存在"));
|
||||
if (HasVersionConflict(tx, baseUpdatedAt))
|
||||
return Conflict(new
|
||||
{
|
||||
code = "SYNC_CONFLICT",
|
||||
message = "账单已在其他设备修改,请确认后再删除",
|
||||
server = ToDto(tx, await db.Categories.FindAsync(tx.CategoryId) ?? tx.Category),
|
||||
}); tx.IsDeleted = true;
|
||||
tx.DeletedAt = DateTime.UtcNow;
|
||||
tx.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("recycle-bin")]
|
||||
public async Task<ActionResult<List<TransactionDto>>> RecycleBin(
|
||||
[FromQuery] long? ledgerId = null,
|
||||
[FromQuery] long? beforeId = null,
|
||||
[FromQuery] int limit = 50)
|
||||
{
|
||||
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
|
||||
if (!resolvedLedgerId.HasValue)
|
||||
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
|
||||
var query = db.Transactions
|
||||
.IgnoreQueryFilters()
|
||||
.Include(t => t.Category)
|
||||
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value && t.IsDeleted);
|
||||
if (beforeId.HasValue) query = query.Where(t => t.Id < beforeId.Value);
|
||||
var items = await query.OrderByDescending(t => t.DeletedAt)
|
||||
.ThenByDescending(t => t.Id)
|
||||
.Take(Math.Clamp(limit, 1, 100))
|
||||
.ToListAsync();
|
||||
return Ok(items.Select(t => ToDto(t, t.Category)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("{id:long}/restore")]
|
||||
public async Task<ActionResult<TransactionDto>> Restore(long id, [FromQuery] DateTime? baseUpdatedAt = null)
|
||||
{
|
||||
var tx = await db.Transactions.IgnoreQueryFilters().Include(t => t.Category)
|
||||
.FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid && t.IsDeleted);
|
||||
if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "回收站中没有这笔账单"));
|
||||
if (HasVersionConflict(tx, baseUpdatedAt))
|
||||
return Conflict(new
|
||||
{
|
||||
code = "SYNC_CONFLICT",
|
||||
message = "账单已在其他设备修改,请确认后再恢复",
|
||||
server = ToDto(tx, tx.Category),
|
||||
}); tx.IsDeleted = false;
|
||||
tx.DeletedAt = null;
|
||||
tx.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToDto(tx, tx.Category));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:long}/permanent")]
|
||||
public async Task<IActionResult> PermanentDelete(long id)
|
||||
{
|
||||
var tx = await db.Transactions.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid && t.IsDeleted);
|
||||
if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "回收站中没有这笔账单"));
|
||||
await PermanentDeleteCore(tx);
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("recycle-bin")]
|
||||
public async Task<IActionResult> ClearRecycleBin([FromQuery] long? ledgerId = null)
|
||||
{
|
||||
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
|
||||
if (!resolvedLedgerId.HasValue)
|
||||
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
|
||||
var items = await db.Transactions.IgnoreQueryFilters()
|
||||
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value && t.IsDeleted)
|
||||
.ToListAsync();
|
||||
foreach (var item in items) await PermanentDeleteCore(item);
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>月账单(按日分组,首页明细用)</summary>
|
||||
[HttpGet("month")]
|
||||
public async Task<ActionResult<MonthSummaryDto>> Month([FromQuery] int year, [FromQuery] int month, [FromQuery] long? ledgerId = null)
|
||||
{
|
||||
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
|
||||
if (!resolvedLedgerId.HasValue)
|
||||
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
|
||||
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
|
||||
|
||||
var q = db.Transactions
|
||||
.Include(t => t.Category)
|
||||
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value &&
|
||||
t.OccurredAt >= start && t.OccurredAt < end);
|
||||
|
||||
var list = await q.OrderByDescending(t => t.OccurredAt).ToListAsync();
|
||||
|
||||
var income = list.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount);
|
||||
var expense = list.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount);
|
||||
|
||||
var days = list
|
||||
.GroupBy(t => DateOnly.FromDateTime(ChinaClock.ToLocal(t.OccurredAt)))
|
||||
.OrderByDescending(g => g.Key)
|
||||
.Select(g => new DailyGroupDto(
|
||||
g.Key,
|
||||
g.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount),
|
||||
g.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount),
|
||||
g.Select(t => ToDto(t, t.Category)).ToList()))
|
||||
.ToList();
|
||||
|
||||
return Ok(new MonthSummaryDto(year, month, income, expense, income - expense, list.Count, days));
|
||||
}
|
||||
|
||||
/// <summary>月统计(统计页:分类占比 + 每日趋势 + AI 分析)</summary>
|
||||
[HttpGet("stats")]
|
||||
public async Task<ActionResult<MonthStatsDto>> Stats(
|
||||
[FromQuery] int year,
|
||||
[FromQuery] int month,
|
||||
[FromQuery] long? ledgerId = null)
|
||||
{
|
||||
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
|
||||
if (!resolvedLedgerId.HasValue)
|
||||
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
|
||||
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
|
||||
var daysInMonth = DateTime.DaysInMonth(year, month);
|
||||
|
||||
var list = await db.Transactions
|
||||
.Include(t => t.Category)
|
||||
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value &&
|
||||
t.OccurredAt >= start && t.OccurredAt < end)
|
||||
.ToListAsync();
|
||||
|
||||
var expenses = list.Where(t => t.Type == TransactionType.Expense).ToList();
|
||||
var totalExpense = expenses.Sum(t => t.Amount);
|
||||
var totalIncome = list.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount);
|
||||
|
||||
var byCat = expenses
|
||||
.GroupBy(t => t.Category)
|
||||
.Select(g => new CategoryStatDto(
|
||||
g.Key.Id, g.Key.Name, g.Key.IconKey,
|
||||
g.Sum(t => t.Amount),
|
||||
totalExpense == 0 ? 0 : (double)(g.Sum(t => t.Amount) / totalExpense * 100),
|
||||
g.Key.ColorKey))
|
||||
.OrderByDescending(c => c.Amount)
|
||||
.ToList();
|
||||
|
||||
var daily = Enumerable.Range(1, daysInMonth)
|
||||
.Select(d => expenses.Where(t => ChinaClock.ToLocal(t.OccurredAt).Day == d).Sum(t => t.Amount))
|
||||
.ToList();
|
||||
|
||||
// AI 分析卡文案
|
||||
var topCat = byCat.FirstOrDefault();
|
||||
var prevMonth = month == 1 ? 12 : month - 1;
|
||||
var prevYear = month == 1 ? year - 1 : year;
|
||||
var (prevStart, _) = ChinaClock.MonthRangeUtc(prevYear, prevMonth);
|
||||
var prevExpense = await db.Transactions
|
||||
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value &&
|
||||
t.Type == TransactionType.Expense && t.OccurredAt >= prevStart && t.OccurredAt < start)
|
||||
.SumAsync(t => t.Amount);
|
||||
var trend = prevExpense == 0 ? "这是你的第一个月记账哦"
|
||||
: totalExpense > prevExpense * 1.05m ? $"比上月多花了 ¥{(totalExpense - prevExpense):F0}"
|
||||
: totalExpense < prevExpense * 0.95m ? $"比上月省了 ¥{(prevExpense - totalExpense):F0}" : "和上月差不多";
|
||||
var aiText = topCat is null
|
||||
? "这个月还没开始花钱呢,快去记一笔吧"
|
||||
: $"这个月「{topCat.Name}」花最多,共 ¥{topCat.Amount:F0}(占 {topCat.Percent:F0}%),{trend}。";
|
||||
|
||||
return Ok(new MonthStatsDto(year, month, totalExpense, totalIncome, byCat, daily) { AiAnalysis = aiText });
|
||||
}
|
||||
|
||||
[HttpGet("stats/period")]
|
||||
public async Task<ActionResult<PeriodStatsDto>> PeriodStats(
|
||||
[FromQuery] string period = "month",
|
||||
[FromQuery] DateTime? anchor = null,
|
||||
[FromQuery] long? ledgerId = null)
|
||||
{
|
||||
var normalizedPeriod = period.Trim().ToLowerInvariant();
|
||||
if (normalizedPeriod is not ("week" or "month" or "year"))
|
||||
return BadRequest(new ApiError(
|
||||
"PERIOD_INVALID",
|
||||
"统计周期必须是 week、month 或 year"));
|
||||
|
||||
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
|
||||
if (!resolvedLedgerId.HasValue)
|
||||
return BadRequest(new ApiError(
|
||||
"LEDGER_NOT_FOUND",
|
||||
"账本不存在或无权访问"));
|
||||
|
||||
var selected = DateTime.SpecifyKind(
|
||||
(anchor ?? ChinaClock.Now).Date,
|
||||
DateTimeKind.Unspecified);
|
||||
DateTime localStart;
|
||||
DateTime localEnd;
|
||||
DateTime previousLocalStart;
|
||||
string periodLabel;
|
||||
switch (normalizedPeriod)
|
||||
{
|
||||
case "week":
|
||||
{
|
||||
var offset = ((int)selected.DayOfWeek + 6) % 7;
|
||||
localStart = selected.AddDays(-offset);
|
||||
localEnd = localStart.AddDays(7);
|
||||
previousLocalStart = localStart.AddDays(-7);
|
||||
periodLabel = $"{localStart:MM月dd日} - {localEnd.AddDays(-1):MM月dd日}";
|
||||
break;
|
||||
}
|
||||
case "year":
|
||||
localStart = new DateTime(selected.Year, 1, 1);
|
||||
localEnd = localStart.AddYears(1);
|
||||
previousLocalStart = localStart.AddYears(-1);
|
||||
periodLabel = $"{localStart.Year}年";
|
||||
break;
|
||||
default:
|
||||
localStart = new DateTime(selected.Year, selected.Month, 1);
|
||||
localEnd = localStart.AddMonths(1);
|
||||
previousLocalStart = localStart.AddMonths(-1);
|
||||
periodLabel = $"{localStart.Year}年{localStart.Month}月";
|
||||
break;
|
||||
}
|
||||
|
||||
var start = ChinaClock.ToUtc(localStart);
|
||||
var end = ChinaClock.ToUtc(localEnd);
|
||||
var previousStart = ChinaClock.ToUtc(previousLocalStart);
|
||||
var list = await db.Transactions
|
||||
.Include(transaction => transaction.Category)
|
||||
.Where(transaction =>
|
||||
transaction.UserId == Uid &&
|
||||
transaction.LedgerId == resolvedLedgerId.Value &&
|
||||
transaction.OccurredAt >= start &&
|
||||
transaction.OccurredAt < end)
|
||||
.ToListAsync();
|
||||
|
||||
var expenses = list
|
||||
.Where(transaction => transaction.Type == TransactionType.Expense)
|
||||
.ToList();
|
||||
var totalExpense = expenses.Sum(transaction => transaction.Amount);
|
||||
var totalIncome = list
|
||||
.Where(transaction => transaction.Type == TransactionType.Income)
|
||||
.Sum(transaction => transaction.Amount);
|
||||
var byCategory = expenses
|
||||
.GroupBy(transaction => transaction.Category)
|
||||
.Select(group => new CategoryStatDto(
|
||||
group.Key.Id,
|
||||
group.Key.Name,
|
||||
group.Key.IconKey,
|
||||
group.Sum(transaction => transaction.Amount),
|
||||
totalExpense == 0
|
||||
? 0
|
||||
: (double)(group.Sum(transaction => transaction.Amount) / totalExpense * 100),
|
||||
group.Key.ColorKey))
|
||||
.OrderByDescending(item => item.Amount)
|
||||
.ThenBy(item => item.Name)
|
||||
.ToList();
|
||||
|
||||
List<PeriodTrendPointDto> trend;
|
||||
if (normalizedPeriod == "year")
|
||||
{
|
||||
trend = Enumerable.Range(0, 12)
|
||||
.Select(index =>
|
||||
{
|
||||
var pointStart = localStart.AddMonths(index);
|
||||
var pointEnd = pointStart.AddMonths(1);
|
||||
var pointItems = list.Where(transaction =>
|
||||
{
|
||||
var local = ChinaClock.ToLocal(transaction.OccurredAt);
|
||||
return local >= pointStart && local < pointEnd;
|
||||
});
|
||||
return new PeriodTrendPointDto(
|
||||
$"{pointStart.Month}月",
|
||||
pointStart,
|
||||
pointItems.Where(item => item.Type == TransactionType.Expense).Sum(item => item.Amount),
|
||||
pointItems.Where(item => item.Type == TransactionType.Income).Sum(item => item.Amount));
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
var dayCount = (localEnd - localStart).Days;
|
||||
trend = Enumerable.Range(0, dayCount)
|
||||
.Select(index =>
|
||||
{
|
||||
var date = localStart.AddDays(index);
|
||||
var dayItems = list.Where(transaction =>
|
||||
ChinaClock.ToLocal(transaction.OccurredAt).Date == date.Date);
|
||||
var label = normalizedPeriod == "week"
|
||||
? new[] { "周一", "周二", "周三", "周四", "周五", "周六", "周日" }[index]
|
||||
: $"{date.Day}日";
|
||||
return new PeriodTrendPointDto(
|
||||
label,
|
||||
date,
|
||||
dayItems.Where(item => item.Type == TransactionType.Expense).Sum(item => item.Amount),
|
||||
dayItems.Where(item => item.Type == TransactionType.Income).Sum(item => item.Amount));
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var previousExpense = await db.Transactions
|
||||
.Where(transaction =>
|
||||
transaction.UserId == Uid &&
|
||||
transaction.LedgerId == resolvedLedgerId.Value &&
|
||||
transaction.Type == TransactionType.Expense &&
|
||||
transaction.OccurredAt >= previousStart &&
|
||||
transaction.OccurredAt < start)
|
||||
.SumAsync(transaction => transaction.Amount);
|
||||
var topCategory = byCategory.FirstOrDefault();
|
||||
string analysis;
|
||||
if (topCategory is null)
|
||||
{
|
||||
analysis = "这个周期还没有支出记录";
|
||||
}
|
||||
else if (previousExpense == 0)
|
||||
{
|
||||
analysis = $"{topCategory.Name}支出最多,共 ¥{topCategory.Amount:F0}";
|
||||
}
|
||||
else
|
||||
{
|
||||
var difference = totalExpense - previousExpense;
|
||||
analysis = Math.Abs(difference) < previousExpense * 0.05m
|
||||
? $"{topCategory.Name}支出最多,与上个周期基本持平"
|
||||
: difference > 0
|
||||
? $"{topCategory.Name}支出最多,比上个周期多 ¥{difference:F0}"
|
||||
: $"{topCategory.Name}支出最多,比上个周期少 ¥{-difference:F0}";
|
||||
}
|
||||
|
||||
return Ok(new PeriodStatsDto(
|
||||
normalizedPeriod,
|
||||
periodLabel,
|
||||
localStart,
|
||||
localEnd,
|
||||
totalExpense,
|
||||
totalIncome,
|
||||
totalIncome - totalExpense,
|
||||
list.Count,
|
||||
byCategory,
|
||||
trend,
|
||||
analysis));
|
||||
}
|
||||
private async Task PermanentDeleteCore(Transaction transaction)
|
||||
{
|
||||
var cards = await db.ChatMessages
|
||||
.Where(message => message.TransactionId == transaction.Id)
|
||||
.ToListAsync();
|
||||
foreach (var card in cards)
|
||||
{
|
||||
card.TransactionId = null;
|
||||
card.Content = """{"deleted":true}""";
|
||||
}
|
||||
db.Transactions.Remove(transaction);
|
||||
}
|
||||
|
||||
private static TransactionType? ParseType(string? value) => value switch
|
||||
{
|
||||
"income" => TransactionType.Income,
|
||||
"expense" => TransactionType.Expense,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static bool HasVersionConflict(Transaction transaction, DateTime? baseUpdatedAt)
|
||||
{
|
||||
if (!baseUpdatedAt.HasValue) return false;
|
||||
var baseline = baseUpdatedAt.Value.Kind == DateTimeKind.Utc
|
||||
? baseUpdatedAt.Value
|
||||
: baseUpdatedAt.Value.ToUniversalTime();
|
||||
return transaction.UpdatedAt > baseline.AddMilliseconds(1);
|
||||
}
|
||||
private static DateTime NormalizeOccurredAt(DateTime value) =>
|
||||
value.Kind == DateTimeKind.Utc ? value : ChinaClock.ToUtc(value);
|
||||
|
||||
internal static TransactionDto ToDto(Transaction t, Category c) => new(
|
||||
t.Id, t.LedgerId, c.Id, c.Name, c.IconKey,
|
||||
t.Type == TransactionType.Income ? "income" : "expense",
|
||||
t.Amount,
|
||||
t.Source == TransactionSource.AiChat
|
||||
&& !string.IsNullOrWhiteSpace(t.SourceText)
|
||||
&& string.Equals(t.Note?.Trim(), t.SourceText.Trim(), StringComparison.Ordinal)
|
||||
? TransactionNoteFormatter.BuildNote(t.SourceText, c.Name)
|
||||
: t.Note,
|
||||
t.PaymentMethod, t.OccurredAt,
|
||||
t.Source switch
|
||||
{
|
||||
TransactionSource.AiChat => "ai_chat",
|
||||
TransactionSource.Voice => "voice",
|
||||
TransactionSource.ReceiptOcr => "ocr",
|
||||
TransactionSource.Screenshot => "screenshot",
|
||||
TransactionSource.Accessibility => "accessibility",
|
||||
TransactionSource.Notification => "notification",
|
||||
TransactionSource.RecognitionAi => "recognition_ai",
|
||||
TransactionSource.LocalOcr => "local_ocr",
|
||||
_ => "manual",
|
||||
},
|
||||
t.SourceText,
|
||||
t.IsDeleted,
|
||||
c.ColorKey,
|
||||
t.UpdatedAt);
|
||||
}
|
||||
@@ -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