using System.Net; using System.Net.Http.Json; using System.Text.Json; using AgriculturalPlatform.Api.Data; using AgriculturalPlatform.Api.Dtos; using AgriculturalPlatform.Api.Models; using AgriculturalPlatform.Api.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace AgriculturalPlatform.Api.Controllers; [ApiController] [Route("api/dashboard")] [Authorize] public class DashboardController( AppDbContext db, DataScopeService scope, CurrentUserService currentUser, IHttpClientFactory httpFactory) : ControllerBase { private async Task> ScopedOrdersAsync() { var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get()); var query = db.PurchaseOrders.AsQueryable(); if (visible is not null) query = query.Where(p => visible.Contains(p.PurchaserOrgId)); return query; } /// 解析查询区间:[From, ToExclusive)。未传 from 时默认回溯 days 天。 private static (DateTime From, DateTime ToExclusive) ResolveRange(DateTime? from, DateTime? to, int days = 30) { var end = to?.Date ?? DateTime.Today; var start = from?.Date ?? end.AddDays(-(days - 1)); return (start, end.AddDays(1)); } /// 大屏概览 KPI(按日期区间) [HttpGet("overview")] public async Task> Overview(DateTime? from, DateTime? to) { var (start, endEx) = ResolveRange(from, to, 30); var orders = await ScopedOrdersAsync(); var completed = orders.Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < endEx); var amount = await completed.SumAsync(p => (decimal?)p.Amount) ?? 0; var count = await orders.CountAsync(p => p.CreatedAt >= start && p.CreatedAt < endEx); var netWeight = await completed.SumAsync(p => (decimal?)p.NetWeight) ?? 0; var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get()); var payQuery = db.PaymentRecords.Where(p => p.Status == PayStatus.Success).AsQueryable(); if (visible is not null) payQuery = payQuery.Where(p => p.PurchaserOrgId != null && visible.Contains(p.PurchaserOrgId.Value)); var paid = await payQuery .Where(p => p.PaidAt >= start && p.PaidAt < endEx) .SumAsync(p => (decimal?)p.Amount) ?? 0; var avgPrice = netWeight > 0 ? Math.Round(amount / netWeight, 2) : 0; var farmerCount = await completed.Select(p => p.FarmerId).Distinct().CountAsync(); var weighingCount = await orders.CountAsync(p => p.Status == PurchaseStatus.Weighing); return Ok(new OverviewDto(amount, count, netWeight, paid, avgPrice, farmerCount, weighingCount)); } /// 区间收购金额/重量趋势(逐日) [HttpGet("trend")] public async Task> Trend(DateTime? from, DateTime? to) { var (start, endEx) = ResolveRange(from, to, 14); var orders = await ScopedOrdersAsync(); var rows = await orders .Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < endEx) .GroupBy(p => new { p.CreatedAt.Year, p.CreatedAt.Month, p.CreatedAt.Day }) .Select(g => new { Key = new DateTime(g.Key.Year, g.Key.Month, g.Key.Day), Amount = g.Sum(p => p.Amount), Weight = g.Sum(p => p.NetWeight) }) .ToListAsync(); var map = rows.ToDictionary(r => r.Key, r => r); var days = (int)(endEx - start).TotalDays; var result = Enumerable.Range(0, days).Select(i => { var date = start.AddDays(i); map.TryGetValue(date, out var row); return new TrendPoint(date.ToString("MM-dd"), row?.Amount ?? 0, row?.Weight ?? 0); }).ToArray(); return Ok(result); } /// 品种收购金额占比 [HttpGet("product-distribution")] public async Task> ProductDistribution(DateTime? from, DateTime? to) { var (start, endEx) = ResolveRange(from, to, 30); var orders = await ScopedOrdersAsync(); var rows = (await orders .Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < endEx) .Select(p => new { p.Product!.Name, p.Amount }) .ToListAsync()) .GroupBy(p => p.Name) .Select(g => new RatioPoint(g.Key, g.Sum(p => p.Amount))) .OrderByDescending(r => r.Value) .ToArray(); return Ok(rows); } /// 农户收购金额排行 [HttpGet("farmer-top")] public async Task> FarmerTop(DateTime? from, DateTime? to, int limit = 8) { var (start, endEx) = ResolveRange(from, to, 30); var orders = await ScopedOrdersAsync(); var rows = (await orders .Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < endEx) .Select(p => new { p.FarmerId, Name = p.Farmer!.Name, p.Amount, p.NetWeight }) .ToListAsync()) .GroupBy(p => new { p.FarmerId, p.Name }) .Select(g => new FarmerTopPoint( g.Key.FarmerId, g.Key.Name, g.Sum(p => p.Amount), g.Count(), g.Sum(p => p.NetWeight))) .OrderByDescending(r => r.Amount).Take(limit) .ToArray(); return Ok(rows); } /// 收购方(公司/站/个体)对比 [HttpGet("station-comparison")] public async Task> StationComparison(DateTime? from, DateTime? to) { var (start, endEx) = ResolveRange(from, to, 30); var orders = await ScopedOrdersAsync(); var rows = (await orders .Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < endEx) .Select(p => new { p.PurchaserOrgId, Name = p.PurchaserOrg!.Name, p.Amount, p.NetWeight }) .ToListAsync()) .GroupBy(p => new { p.PurchaserOrgId, p.Name }) .Select(g => new StationPoint( g.Key.PurchaserOrgId, g.Key.Name, g.Sum(p => p.Amount), g.Count(), g.Sum(p => p.NetWeight))) .OrderByDescending(r => r.Amount) .ToArray(); return Ok(rows); } /// 收购单价排行(按品种平均单价) [HttpGet("price-top")] public async Task> PriceTop(DateTime? from, DateTime? to, int limit = 8) { var (start, endEx) = ResolveRange(from, to, 30); var orders = await ScopedOrdersAsync(); var rows = (await orders .Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < endEx) .Select(p => new { Name = p.Product!.Name, p.UnitPrice, p.NetWeight }) .ToListAsync()) .GroupBy(p => p.Name) .Select(g => new PriceTopPoint( g.Key, Math.Round(g.Average(p => p.UnitPrice), 2), g.Sum(p => p.NetWeight), g.Count())) .OrderByDescending(r => r.AvgPrice).Take(limit) .ToArray(); return Ok(rows); } /// 农户收入排行(收入 = 区间收购金额,附已付金额) [HttpGet("income-top")] public async Task> IncomeTop(DateTime? from, DateTime? to, int limit = 8) { var (start, endEx) = ResolveRange(from, to, 30); var orders = await ScopedOrdersAsync(); var farmerRows = (await orders .Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < endEx) .Select(p => new { p.FarmerId, Name = p.Farmer!.Name, p.Amount }) .ToListAsync()) .GroupBy(p => p.FarmerId) .Select(g => new { FarmerId = g.Key, Name = g.First().Name, Amount = g.Sum(p => p.Amount), Count = g.Count() }) .ToList(); var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get()); var payQuery = db.PaymentRecords .Where(p => p.Status == PayStatus.Success && p.PaidAt >= start && p.PaidAt < endEx).AsQueryable(); if (visible is not null) payQuery = payQuery.Where(p => p.PurchaserOrgId != null && visible.Contains(p.PurchaserOrgId.Value)); var paidMap = (await payQuery .Select(p => new { p.FarmerId, p.Amount }) .ToListAsync()) .GroupBy(p => p.FarmerId) .ToDictionary(g => g.Key, g => g.Sum(p => p.Amount)); var result = farmerRows .Select(r => new IncomeTopPoint(r.FarmerId, r.Name, r.Amount, paidMap.GetValueOrDefault(r.FarmerId), r.Count)) .OrderByDescending(r => r.Amount).Take(limit) .ToArray(); return Ok(result); } /// 实时过磅(进行中的收购单) [HttpGet("realtime-weighing")] public async Task> RealtimeWeighing() { var orders = await ScopedOrdersAsync(); var rows = await orders .Where(p => p.Status == PurchaseStatus.Weighing) .OrderByDescending(p => p.WeighInAt).Take(20) .Select(p => new RealtimeWeighing( p.Id, p.OrderNo, p.Farmer!.Name, p.Product!.Name, p.Status.ToString(), p.GrossWeight, p.WeighInAt)) .ToListAsync(); return Ok(rows.ToArray()); } // ---------- 天气(ShowAPI ip-to-weather:IP 定位实时天气) ---------- /// ShowAPI 天气中文描述 -> 图标 private static string IconFor(string text) { if (string.IsNullOrEmpty(text)) return "🌡️"; if (text.Contains("晴")) return "☀️"; if (text.Contains("雷") || text.Contains("电")) return "⛈️"; if (text.Contains("雨")) return "🌧️"; if (text.Contains("雪")) return "🌨️"; if (text.Contains("多云") || text.Contains("少云") || text.Contains("转")) return "⛅"; if (text.Contains("阴")) return "☁️"; if (text.Contains("雾") || text.Contains("霾")) return "🌫️"; if (text.Contains("风")) return "🌪️"; if (text.Contains("霜")) return "🥶"; return "🌡️"; } private static (DateTime Time, WeatherInfo? Data) _weatherCache; /// 天气预报:ShowAPI ip-to-weather(按调用方 IP 定位,返回实时天气) [HttpGet("weather")] [AllowAnonymous] public async Task> Weather() { if (_weatherCache.Data is not null && DateTime.UtcNow - _weatherCache.Time < TimeSpan.FromMinutes(30)) return Ok(_weatherCache.Data); var client = httpFactory.CreateClient(); client.Timeout = TimeSpan.FromSeconds(10); try { var ip = await ResolveClientIpAsync(client); // ShowAPI ip-to-weather:必须携带 ip 参数,Authorization 头带 AppCode using var request = new HttpRequestMessage(HttpMethod.Get, $"https://ali-weather.showapi.com/ip-to-weather?ip={Uri.EscapeDataString(ip)}"); request.Headers.TryAddWithoutValidation("Authorization", "APPCODE e3bdae1a73d2415492e7fdc024f07d08"); using var resp = await client.SendAsync(request); resp.EnsureSuccessStatusCode(); using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); var root = doc.RootElement; if (root.TryGetProperty("showapi_res_code", out var rc) && rc.GetInt32() != 0) return StatusCode(502, new { message = "天气服务暂不可用", detail = root.TryGetProperty("showapi_res_error", out var re) ? re.GetString() : "未知错误" }); var body = root.GetProperty("showapi_res_body"); var cityInfo = body.GetProperty("cityInfo"); var now = body.GetProperty("now"); var today = body.TryGetProperty("f1", out var f1) ? f1 : default; var city = cityInfo.TryGetProperty("c3", out var c) ? c.GetString() ?? "" : ""; var region = cityInfo.TryGetProperty("c7", out var p) ? p.GetString() ?? "" : ""; var text = now.TryGetProperty("weather", out var w) ? w.GetString() ?? "" : ""; if (string.IsNullOrEmpty(text)) text = "未知"; var temp = ParseWeatherNumber(now, "temperature"); var feels = ParseWeatherNumber(now, "feels_like"); var humidity = ParseWeatherNumber(now, "sd"); var wind = $"{GetWeatherField(now, "wind_direction")} {GetWeatherField(now, "wind_power")}".Trim(); var min = today.ValueKind == JsonValueKind.Object ? ParseWeatherNumber(today, "night_air_temperature") : temp; var max = today.ValueKind == JsonValueKind.Object ? ParseWeatherNumber(today, "day_air_temperature") : temp; var result = new WeatherInfo( city, region, text, IconFor(text), temp, feels, humidity, wind, min, max, DateTime.Now, "ShowAPI"); _weatherCache = (DateTime.UtcNow, result); return Ok(result); } catch (Exception ex) { return StatusCode(502, new { message = "天气服务暂不可用", detail = ex.Message }); } } /// 解析调用方 IP:代理转发头 -> 直连 IP -> 本机公网 IP 兜底(本地开发时用) private async Task ResolveClientIpAsync(HttpClient client) { var fwd = Request.Headers["X-Forwarded-For"].FirstOrDefault(); if (!string.IsNullOrEmpty(fwd)) { var ip = fwd.Split(',')[0].Trim(); if (!IsPrivateIp(ip)) return ip; } var remote = HttpContext.Connection.RemoteIpAddress?.MapToIPv4().ToString() ?? ""; if (!IsPrivateIp(remote)) return remote; try { return (await client.GetStringAsync("http://ip.3322.net")).Trim(); } catch { return remote.Length > 0 ? remote : "0.0.0.0"; } } private static bool IsPrivateIp(string ip) { if (string.IsNullOrEmpty(ip) || ip == "::1") return true; if (!IPAddress.TryParse(ip, out var addr)) return true; var bytes = addr.GetAddressBytes(); if (bytes.Length != 4) return true; // 仅处理 IPv4 return bytes[0] == 10 || bytes[0] == 127 || (bytes[0] == 192 && bytes[1] == 168) || (bytes[0] == 172 && bytes[1] is >= 16 and <= 31) || bytes[0] == 0; } /// 读取数值型天气字段(容忍"25℃"、"50%"等后缀) private static decimal ParseWeatherNumber(JsonElement obj, string prop) { if (!obj.TryGetProperty(prop, out var el)) return 0; var raw = el.ValueKind == JsonValueKind.String ? el.GetString() : el.ToString(); if (string.IsNullOrEmpty(raw)) return 0; var num = new string(raw.TakeWhile(ch => char.IsDigit(ch) || ch == '-' || ch == '.').ToArray()); return decimal.TryParse(num, out var v) ? v : 0; } /// 读取天气字符串字段 private static string GetWeatherField(JsonElement obj, string prop) { if (obj.TryGetProperty(prop, out var el) && el.ValueKind == JsonValueKind.String) return el.GetString() ?? ""; return ""; } }