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<EmotionProfile> 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<string>(x.TagsJson), x.CreatedAt)), now);
}
}
public class CatalogPlaceProvider(AppDbContext db, TimeProvider clock)
{
public async Task<PlaceCandidate[]> 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<PlaceCandidate?> 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<PlaceCandidate[]> BuildAsync(IEnumerable<Place> 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<PlaceCandidate[]> 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<RecommendationResult> 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<AlgorithmWeights>(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<PlaceCategory>(preference.FavoriteCategoriesJson),
Labels.Read<PlaceCategory>(preference.BlockedCategoriesJson), Labels.Read<Guid>(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<Experience> 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<bool> 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;
}
}
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<EmotionProfile> 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<string>(x.TagsJson), x.CreatedAt)), now);
}
}
public class CatalogPlaceProvider(AppDbContext db, TimeProvider clock)
{
public async Task<PlaceCandidate[]> 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<PlaceCandidate?> 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<PlaceCandidate[]> BuildAsync(IEnumerable<Place> 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<PlaceCandidate[]> 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<RecommendationResult> 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<AlgorithmWeights>(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<PlaceCategory>(preference.FavoriteCategoriesJson),
Labels.Read<PlaceCategory>(preference.BlockedCategoriesJson), Labels.Read<Guid>(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<Experience> 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<bool> 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;
}
}