using System.Diagnostics; using System.Text.Json; using EARSystem.Core; using Microsoft.EntityFrameworkCore; namespace EARSystem.Infrastructure; public class EmotionProfileService(AppDbContext db, TimeProvider clock) : IEmotionProfileService { public async Task GetAsync(string userId, CancellationToken cancellationToken = default) { var now = clock.GetUtcNow(); var entries = await db.Moods.AsNoTracking().Where(x => x.UserId == userId && x.RecordedAt >= now.AddDays(-7) && x.RecordedAt <= now).ToListAsync(cancellationToken); return EmotionProfileBuilder.Build(entries.Select(x => new MoodSnapshot(x.Mood, x.Energy, x.Stress, x.RecordedAt, Labels.Read(x.TagsJson), x.CreatedAt)), now); } } public class CatalogPlaceProvider(AppDbContext db, TimeProvider clock) { public async Task SearchAsync(RecommendationQuery query, CancellationToken ct = default) { var source = query.Mode == DataMode.Demo ? PlaceSource.Demo : PlaceSource.Collected; var places = await db.Places.AsNoTracking().Include(x => x.Atmosphere).Where(x => x.Source == source).ToListAsync(ct); return await BuildAsync(places, ct); } public async Task GetAsync(Guid id, CancellationToken ct = default) { var place = await db.Places.AsNoTracking().Include(x => x.Atmosphere).SingleOrDefaultAsync(x => x.Id == id, ct); return place == null ? null : (await BuildAsync([place], ct))[0]; } public async Task BuildAsync(IEnumerable source, CancellationToken ct = default) { var places = source.ToArray(); var ids = places.Select(x => x.Id).ToArray(); var votes = await (from experience in db.Experiences.AsNoTracking() join user in db.Users.AsNoTracking() on experience.UserId equals user.Id where ids.Contains(experience.PlaceId) && experience.Contribute select new { Experience = experience, user.IsDemo }).ToListAsync(ct); var hour = ChinaTime.Local(clock.GetUtcNow()).Hour; return places.Select(p => { var latest = votes.Where(x => x.Experience.PlaceId == p.Id && (p.Source == PlaceSource.Demo || !x.IsDemo)) .Select(x => x.Experience).GroupBy(x => x.UserId) .Select(g => g.OrderByDescending(x => x.VisitedAt).ThenByDescending(x => x.CreatedAt).First()).ToArray(); var n = (p.Atmosphere?.BaselineCount ?? 0) + latest.Length; var values = n == 0 ? null : Enumerable.Range(0, 6).Select(i => ((p.Atmosphere?.Values()[i] ?? 0) * (p.Atmosphere?.BaselineCount ?? 0) + latest.Sum(x => x.Values()[i])) / n).ToArray(); bool? open = !p.OpenHour.HasValue || !p.CloseHour.HasValue ? null : p.OpenHour == p.CloseHour || (p.OpenHour < p.CloseHour ? hour >= p.OpenHour && hour < p.CloseHour : hour >= p.OpenHour || hour < p.CloseHour); return new PlaceCandidate(p.Id.ToString(), p.Id, p.Name, p.Address, p.Category, p.Longitude, p.Latitude, p.Price, p.Rating, open, values, n, p.Source.ToString(), p.Source == PlaceSource.Demo ? "演示数据" : n > 0 ? "采集及匿名贡献反馈" : "类型推测", p.Description); }).ToArray(); } } public class PlaceProvider(CatalogPlaceProvider catalog, AMapClient map) : IPlaceProvider { public async Task SearchAsync(RecommendationQuery query, CancellationToken cancellationToken = default) { if (query.Mode == DataMode.Demo) return await catalog.SearchAsync(query, cancellationToken); var collected = await catalog.SearchAsync(query, cancellationToken); var live = await map.SearchAsync(query, cancellationToken); return collected.Concat(live).OrderBy(x => RecommendationScorer.Distance(query.Location, x.Longitude, x.Latitude)) .DistinctBy(x => x.Id).Take(100).ToArray(); } } public class RecommendationService(AppDbContext db, IEmotionProfileService profiles, IPlaceProvider provider, AMapClient map, TimeProvider clock) : IRecommendationService { public async Task RecommendAsync(string userId, RecommendationQuery query, CancellationToken cancellationToken = default) { var stopwatch = Stopwatch.StartNew(); using var networkBudget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); if (query.Mode == DataMode.Online) networkBudget.CancelAfter(TimeSpan.FromSeconds(8)); if (query.Mode == DataMode.Online && query.Location.System == "WGS84") query.Location = await map.ConvertAsync(query.Location, networkBudget.Token); if (query.Location.System != "GCJ02") throw new ArgumentException("演示中心需使用 GCJ02 坐标;在线定位会自动转换坐标。"); var profile = await profiles.GetAsync(userId, cancellationToken); var goal = EmotionProfileBuilder.Goal(profile, query.Goal); var preference = await db.Preferences.AsNoTracking().SingleOrDefaultAsync(x => x.UserId == userId, cancellationToken) ?? new UserPreference { UserId = userId }; // The page pre-fills saved defaults; an explicit empty budget means unrestricted for this request. var rule = await db.Rules.AsNoTracking().SingleAsync(cancellationToken); var weights = JsonSerializer.Deserialize(rule.WeightsJson) ?? new(); if (!weights.IsValid()) throw new InvalidOperationException("推荐权重配置无效。"); var from = clock.GetUtcNow().AddDays(-90); var history = await db.Experiences.AsNoTracking().Where(x => x.UserId == userId && x.VisitedAt >= from && x.VisitedAt <= clock.GetUtcNow()).Select(x => new HistoryRating(x.PlaceId, x.Place.Category, x.Satisfaction)).ToArrayAsync(cancellationToken); var candidates = await provider.SearchAsync(query, query.Mode == DataMode.Online ? networkBudget.Token : cancellationToken); var context = new RankingContext(profile, goal, query, Labels.Read(preference.FavoriteCategoriesJson), Labels.Read(preference.BlockedCategoriesJson), Labels.Read(preference.BlockedPlacesJson), history, weights); var items = RecommendationScorer.Rank(candidates, context, out var count); db.Recommendations.Add(new() { UserId = userId, Goal = goal, Mode = query.Mode, AlgorithmVersion = rule.Version, DurationMs = stopwatch.Elapsed.TotalMilliseconds, CreatedAt = clock.GetUtcNow(), // Live supplier fields and exact positions are deliberately never copied into history. ResultsJson = Labels.Json(items.Where(x => x.Place.CatalogId.HasValue).Select(x => new { PlaceId = x.Place.CatalogId })) }); await db.SaveChangesAsync(cancellationToken); return new(goal, Labels.Goal(goal), EmotionProfileBuilder.Basis(profile, goal, query.Goal.HasValue), profile, query.Mode, rule.Version, count, count < 5, items, query.Location); } } public class ExperienceService(AppDbContext db, TimeProvider clock) : IExperienceService { public async Task SaveAsync(string userId, Experience input, bool syncDiary, int energy, int stress, Guid? expectedVersion = null, CancellationToken cancellationToken = default) { if (!await db.Places.AnyAsync(x => x.Id == input.PlaceId, cancellationToken)) throw new KeyNotFoundException("地点不存在。"); var current = await db.Experiences.SingleOrDefaultAsync(x => x.Id == input.Id && x.UserId == userId, cancellationToken); if (expectedVersion.HasValue && current == null) throw new KeyNotFoundException("体验记录不存在。"); if (current != null && (!expectedVersion.HasValue || current.Version != expectedVersion)) throw new DbUpdateConcurrencyException(); var day = ChinaTime.DayKey(input.VisitedAt); if (await db.Experiences.AnyAsync(x => x.UserId == userId && x.PlaceId == input.PlaceId && x.VisitDay == day && x.Id != input.Id, cancellationToken)) throw new ArgumentException("同一地点每天保留一份体验,请修改当天已有记录。"); await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); input.UserId = userId; input.VisitDay = day; input.Version = Guid.NewGuid(); if (current == null) { input.CreatedAt = clock.GetUtcNow(); db.Experiences.Add(input); } else { input.CreatedAt = current.CreatedAt; db.Entry(current).CurrentValues.SetValues(input); } var diary = await db.Moods.SingleOrDefaultAsync(x => x.UserId == userId && x.ExperienceId == input.Id, cancellationToken); if (syncDiary && input.MoodAfter.HasValue) { if (diary == null) { diary = new MoodEntry { UserId = userId, ExperienceId = input.Id }; db.Moods.Add(diary); } diary.Mood = input.MoodAfter.Value; diary.Energy = energy; diary.Stress = stress; diary.RecordedAt = input.VisitedAt; diary.Thoughts = input.Thoughts; diary.Version = Guid.NewGuid(); } else if (diary != null) { // Disabling synchronization preserves the diary as an independent private entry. diary.ExperienceId = null; diary.Version = Guid.NewGuid(); } await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return current ?? input; } public async Task DeleteAsync(string userId, Guid id, Guid expectedVersion, CancellationToken cancellationToken = default) { var item = await db.Experiences.SingleOrDefaultAsync(x => x.UserId == userId && x.Id == id, cancellationToken); if (item == null) return false; if (item.Version != expectedVersion) throw new DbUpdateConcurrencyException(); await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); // Keep diary content, but detach it from the removed experience. foreach (var mood in await db.Moods.Where(x => x.UserId == userId && x.ExperienceId == id).ToListAsync(cancellationToken)) { mood.ExperienceId = null; mood.Version = Guid.NewGuid(); } db.Experiences.Remove(item); await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return true; } }