XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

EARSystem

【ASP】耳间,网页端的情绪智能推荐系统

公开
关注 0 Fork 0 Star 0
UTF-8
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<string>(x.TagsJson), x.RecordedAt, x.Version, x.ExperienceId };

    [HttpGet("moods")]
    public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Recommend(RecommendationInput input, CancellationToken ct) =>
        Ok(await recommendations.RecommendAsync(UserId, input.Query(), ct));

    [HttpGet("experiences")]
    public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Preferences(CancellationToken ct) {
        var p = await db.Preferences.AsNoTracking().SingleOrDefaultAsync(x => x.UserId == UserId, ct) ?? new();
        return Ok(new { favoriteCategories = Labels.Read<PlaceCategory>(p.FavoriteCategoriesJson),
            blockedCategories = Labels.Read<PlaceCategory>(p.BlockedCategoriesJson), blockedPlaces = Labels.Read<Guid>(p.BlockedPlacesJson),
            p.Budget, p.RadiusMeters });
    }
    [HttpPut("preferences")]
    public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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));
    }
}