using System.Security.Claims; using EARSystem.Core; using EARSystem.Infrastructure; using EARSystem.Web.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; namespace EARSystem.Web.Controllers; [Authorize, ApiController, Route("api")] public class ApiController(AppDbContext db, IRecommendationService recommendations, IExperienceService experiences, EmotionTagAnalyzer tags, TimeProvider clock, AMapClient map) : ControllerBase { private string UserId => User.FindFirstValue(ClaimTypes.NameIdentifier)!; private static object MoodDto(MoodEntry x) => new { x.Id, x.Mood, x.Energy, x.Stress, x.Thoughts, Tags = Labels.Read(x.TagsJson), x.RecordedAt, x.Version, x.ExperienceId }; [HttpGet("moods")] public async Task Moods([FromQuery] DateOnly? date, [FromQuery] int days = 30, CancellationToken ct = default) { if (days is < 1 or > 365) return BadRequest(new { title = "时间范围须为 1–365 天。" }); var start = date.HasValue ? ChinaTime.Start(date.Value) : clock.GetUtcNow().AddDays(-days); var end = date.HasValue ? start.AddDays(1) : clock.GetUtcNow().AddMinutes(1); var items = await db.Moods.AsNoTracking().Where(x => x.UserId == UserId && x.RecordedAt >= start && x.RecordedAt < end) .OrderByDescending(x => x.RecordedAt).ThenByDescending(x => x.CreatedAt).Take(500).ToArrayAsync(ct); return Ok(items.Select(MoodDto)); } [HttpPost("moods")] public async Task CreateMood(MoodInput input, CancellationToken ct) { var item = new MoodEntry { UserId = UserId, Mood = input.Mood, Energy = input.Energy, Stress = input.Stress, Thoughts = input.Thoughts, TagsJson = Labels.Json(input.Tags.Distinct()), RecordedAt = input.RecordedAt, CreatedAt = clock.GetUtcNow() }; db.Moods.Add(item); await db.SaveChangesAsync(ct); return Created("/api/moods/" + item.Id, MoodDto(item)); } [HttpPut("moods/{id:guid}")] public async Task UpdateMood(Guid id, MoodInput input, CancellationToken ct) { var item = await db.Moods.SingleOrDefaultAsync(x => x.Id == id && x.UserId == UserId, ct); if (item == null) return NotFound(); if (input.Version != item.Version) return Conflict(new { title = "记录已修改,请保留输入并重新加载最新记录。" }); item.Mood = input.Mood; item.Energy = input.Energy; item.Stress = input.Stress; item.Thoughts = input.Thoughts; item.TagsJson = Labels.Json(input.Tags.Distinct()); item.RecordedAt = input.RecordedAt; item.Version = Guid.NewGuid(); await db.SaveChangesAsync(ct); return Ok(MoodDto(item)); } [HttpDelete("moods/{id:guid}")] public async Task DeleteMood(Guid id, [FromQuery] Guid version, CancellationToken ct) { var item = await db.Moods.SingleOrDefaultAsync(x => x.Id == id && x.UserId == UserId, ct); if (item == null) return NotFound(); if (version != item.Version) return Conflict(new { title = "记录已修改,请刷新后再删除。" }); db.Moods.Remove(item); await db.SaveChangesAsync(ct); return NoContent(); } [HttpPost("emotion-tags/preview")] public IActionResult Tags(TagInput input) => Ok(new { suggestions = tags.Analyze(input.Text), basis = "文字规则提示,请自行确认后添加。" }); [HttpPost("recommendations"), EnableRateLimiting("maps")] public async Task Recommend(RecommendationInput input, CancellationToken ct) => Ok(await recommendations.RecommendAsync(UserId, input.Query(), ct)); [HttpGet("experiences")] public async Task Experiences(CancellationToken ct) { var items = await db.Experiences.AsNoTracking().Include(x => x.Place).Where(x => x.UserId == UserId) .OrderByDescending(x => x.VisitedAt).Take(250).ToArrayAsync(ct); var synced = await db.Moods.AsNoTracking().Where(x => x.UserId == UserId && x.ExperienceId != null) .ToDictionaryAsync(x => x.ExperienceId!.Value, ct); return Ok(items.Select(x => new { x.Id, x.PlaceId, placeName = x.Place.Name, source = x.Place.Source.ToString(), x.VisitedAt, x.Satisfaction, x.MoodBefore, x.MoodAfter, x.Quiet, x.Comfort, x.Nature, x.Liveliness, x.Social, x.Focus, x.Thoughts, x.Contribute, x.Version, syncDiary = synced.ContainsKey(x.Id), energy = synced.TryGetValue(x.Id, out var mood) ? mood.Energy : 3, stress = synced.TryGetValue(x.Id, out var stressMood) ? stressMood.Stress : 3 })); } [HttpPost("experiences")] public async Task CreateExperience(ExperienceInput input, CancellationToken ct) { var item = await experiences.SaveAsync(UserId, input.Entity(), input.SyncDiary, input.Energy, input.Stress, cancellationToken: ct); return Created("/api/experiences/" + item.Id, new { item.Id, item.Version }); } [HttpPut("experiences/{id:guid}")] public async Task UpdateExperience(Guid id, ExperienceInput input, CancellationToken ct) { if (!await db.Experiences.AnyAsync(x => x.Id == id && x.UserId == UserId, ct)) return NotFound(); var item = await experiences.SaveAsync(UserId, input.Entity(id), input.SyncDiary, input.Energy, input.Stress, input.Version, ct); return Ok(new { item.Id, item.Version }); } [HttpDelete("experiences/{id:guid}")] public async Task DeleteExperience(Guid id, [FromQuery] Guid version, CancellationToken ct) { var existing = await db.Experiences.AsNoTracking().SingleOrDefaultAsync(x => x.UserId == UserId && x.Id == id, ct); if (existing == null) return NotFound(); if (version != existing.Version) return Conflict(new { title = "体验已修改,请刷新后重试。" }); return await experiences.DeleteAsync(UserId, id, version, ct) ? NoContent() : NotFound(); } [HttpGet("favorites")] public async Task Favorites(CancellationToken ct) { var items = await db.Favorites.AsNoTracking().Include(x => x.Place).Where(x => x.UserId == UserId).OrderByDescending(x => x.CreatedAt).ToArrayAsync(ct); var visited = await db.Experiences.Where(x => x.UserId == UserId).Select(x => x.PlaceId).Distinct().ToArrayAsync(ct); return Ok(items.Select(x => new { x.PlaceId, x.Place.Name, category = x.Place.Category.ToString(), x.Place.Price, x.Place.Rating, source = x.Place.Source.ToString(), visited = visited.Contains(x.PlaceId) })); } [HttpPost("favorites")] public async Task Favorite(FavoriteInput input, CancellationToken ct) { if (!await db.Places.AnyAsync(x => x.Id == input.PlaceId, ct)) return BadRequest(new { title = "请收藏独立目录中的地点。实时第三方地点默认不入库。" }); if (!await db.Favorites.AnyAsync(x => x.UserId == UserId && x.PlaceId == input.PlaceId, ct)) { db.Favorites.Add(new() { UserId = UserId, PlaceId = input.PlaceId }); await db.SaveChangesAsync(ct); } return Ok(new { saved = true }); } [HttpDelete("favorites/{placeId:guid}")] public async Task RemoveFavorite(Guid placeId, CancellationToken ct) { var item = await db.Favorites.SingleOrDefaultAsync(x => x.UserId == UserId && x.PlaceId == placeId, ct); if (item == null) return NotFound(); db.Favorites.Remove(item); await db.SaveChangesAsync(ct); return NoContent(); } [HttpGet("preferences")] public async Task Preferences(CancellationToken ct) { var p = await db.Preferences.AsNoTracking().SingleOrDefaultAsync(x => x.UserId == UserId, ct) ?? new(); return Ok(new { favoriteCategories = Labels.Read(p.FavoriteCategoriesJson), blockedCategories = Labels.Read(p.BlockedCategoriesJson), blockedPlaces = Labels.Read(p.BlockedPlacesJson), p.Budget, p.RadiusMeters }); } [HttpPut("preferences")] public async Task SavePreferences(PreferenceInput input, CancellationToken ct) { var p = await db.Preferences.SingleOrDefaultAsync(x => x.UserId == UserId, ct); if (p == null) { p = new() { UserId = UserId }; db.Preferences.Add(p); } p.FavoriteCategoriesJson = Labels.Json(input.FavoriteCategories.Distinct()); p.BlockedCategoriesJson = Labels.Json(input.BlockedCategories.Distinct()); p.BlockedPlacesJson = Labels.Json(input.BlockedPlaces.Distinct()); p.Budget = input.Budget; p.RadiusMeters = input.RadiusMeters; await db.SaveChangesAsync(ct); return Ok(new { saved = true }); } [HttpGet("reports/weekly")] public async Task Weekly([FromQuery] DateOnly? week, CancellationToken ct) { var monday = ChinaTime.Monday(week ?? ChinaTime.Day(clock.GetUtcNow())); var start = ChinaTime.Start(monday); var end = start.AddDays(7); var moods = await db.Moods.AsNoTracking().Where(x => x.UserId == UserId && x.RecordedAt >= start && x.RecordedAt < end).ToArrayAsync(ct); var daily = moods.GroupBy(x => ChinaTime.Day(x.RecordedAt)).Select(g => g.OrderByDescending(x => x.RecordedAt) .ThenByDescending(x => x.CreatedAt).First()).ToArray(); var visits = await db.Experiences.AsNoTracking().Include(x => x.Place).Where(x => x.UserId == UserId && x.VisitedAt >= start && x.VisitedAt < end).ToArrayAsync(ct); var favorite = visits.GroupBy(x => x.Place.Category).OrderByDescending(g => g.Count()).Select(g => Labels.Category(g.Key)).FirstOrDefault(); return Ok(new { week = monday.ToString("yyyy-MM-dd"), recordedDays = daily.Length, mood = daily.Length > 0 ? (double?)Math.Round(daily.Average(x => x.Mood), 1) : null, energy = daily.Length > 0 ? (double?)Math.Round(daily.Average(x => x.Energy), 1) : null, stress = daily.Length > 0 ? (double?)Math.Round(daily.Average(x => x.Stress), 1) : null, visits = visits.Length, satisfaction = visits.Length > 0 ? (double?)Math.Round(visits.Average(x => x.Satisfaction), 1) : null, topCategory = favorite, summary = daily.Length == 0 ? "这周还没有情绪记录。写下一个小片段,开始了解自己的节奏。" : $"这周记录了 {daily.Length} 天,心情均值 {daily.Average(x => x.Mood):F1},到访 {visits.Length} 次。给每一次认真生活留一个印记。", notice = "体验前后心情变化仅描述记录,不表示确定的因果关系。" }); } [HttpGet("reports/trend")] public async Task Trend([FromQuery] int days = 30, CancellationToken ct = default) { if (days is not (7 or 30)) return BadRequest(); var today = ChinaTime.Day(clock.GetUtcNow()); var start = ChinaTime.Start(today.AddDays(1 - days)); var items = await db.Moods.AsNoTracking().Where(x => x.UserId == UserId && x.RecordedAt >= start && x.RecordedAt <= clock.GetUtcNow()).ToArrayAsync(ct); var daily = items.GroupBy(x => ChinaTime.Day(x.RecordedAt)).ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.RecordedAt) .ThenByDescending(x => x.CreatedAt).First()); return Ok(Enumerable.Range(0, days).Select(i => { var day = today.AddDays(1 - days + i); daily.TryGetValue(day, out var mood); return new { date = day.ToString("yyyy-MM-dd"), mood = mood?.Mood, energy = mood?.Energy, stress = mood?.Stress }; })); } [HttpPost("map/geocode"), EnableRateLimiting("maps")] public async Task Geocode([FromBody] TagInput input, CancellationToken ct) { if (string.IsNullOrWhiteSpace(input.Text) || input.Text.Length > 200) return BadRequest(); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(TimeSpan.FromSeconds(8)); return Ok(await map.GeocodeAsync(input.Text, timeout.Token)); } }