feat: add vehicle mileage statistics
This commit is contained in:
@@ -42,6 +42,7 @@ func (h *Handler) routes() {
|
||||
h.mux.HandleFunc("POST /api/history/raw-frames/query", h.handleRawFramesPost)
|
||||
h.mux.HandleFunc("GET /api/mileage/summary", h.handleMileageSummary)
|
||||
h.mux.HandleFunc("GET /api/mileage/daily", h.handleDailyMileage)
|
||||
h.mux.HandleFunc("GET /api/v2/statistics/mileage", h.handleMileageStatistics)
|
||||
h.mux.HandleFunc("GET /api/statistics/online-summary", h.handleOnlineStatisticsSummary)
|
||||
h.mux.HandleFunc("GET /api/statistics/online-vehicles", h.handleOnlineVehicleStatuses)
|
||||
h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary)
|
||||
@@ -428,6 +429,11 @@ func (h *Handler) handleMileageSummary(w http.ResponseWriter, r *http.Request) {
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleMileageStatistics(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.MileageStatistics(r.Context(), r.URL.Query())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleOnlineStatisticsSummary(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.OnlineStatisticsSummary(r.Context(), r.URL.Query())
|
||||
h.write(w, r, data, err)
|
||||
|
||||
@@ -880,6 +880,7 @@ func TestHandlerHistoryMileageQualityOps(t *testing.T) {
|
||||
{"/api/history/raw-frames?protocol=GB32960&vin=LB9A32A24R0LS1426&limit=1&includeFields=true", "plate"},
|
||||
{"/api/mileage/summary?limit=10", "totalMileageKm"},
|
||||
{"/api/mileage/daily?limit=10", "dailyMileageKm"},
|
||||
{"/api/v2/statistics/mileage?dateFrom=2026-07-01&dateTo=2026-07-31", "periodMileageKm"},
|
||||
{"/api/statistics/online-summary?limit=10", "onlineRatePercent"},
|
||||
{"/api/statistics/online-vehicles?limit=10", "offlineDurationMinutes"},
|
||||
{"/api/quality/summary?limit=10", "issueVehicleCount"},
|
||||
|
||||
@@ -843,6 +843,50 @@ func (m *MockStore) MileageSummary(_ context.Context, query url.Values) (Mileage
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) MileageStatistics(_ context.Context, query url.Values) (MileageStatistics, error) {
|
||||
rows := m.dailyMileageRows(query)
|
||||
byDate := map[string]*MileageTrendPoint{}
|
||||
result := MileageStatistics{DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"), Trend: []MileageTrendPoint{}, Ranking: []MileageVehicleRank{}, AsOf: "2026-07-03 20:12:06", Evidence: "mock mileage statistics"}
|
||||
vehicles, sources := map[string]*MileageVehicleRank{}, map[string]struct{}{}
|
||||
for _, row := range rows {
|
||||
result.PeriodMileageKm += row.DailyMileageKm
|
||||
result.RecordCount++
|
||||
sources[row.Source] = struct{}{}
|
||||
point := byDate[row.Date]
|
||||
if point == nil {
|
||||
point = &MileageTrendPoint{Date: row.Date}
|
||||
byDate[row.Date] = point
|
||||
}
|
||||
point.MileageKm += row.DailyMileageKm
|
||||
point.Vehicles++
|
||||
rank := vehicles[row.VIN]
|
||||
if rank == nil {
|
||||
rank = &MileageVehicleRank{VIN: row.VIN, Plate: row.Plate}
|
||||
vehicles[row.VIN] = rank
|
||||
}
|
||||
rank.MileageKm += row.DailyMileageKm
|
||||
rank.LatestMileageKm = row.EndMileageKm
|
||||
rank.ActiveDays++
|
||||
}
|
||||
result.VehicleCount, result.SourceCount = len(vehicles), len(sources)
|
||||
for _, point := range byDate {
|
||||
result.Trend = append(result.Trend, *point)
|
||||
}
|
||||
sort.Slice(result.Trend, func(i, j int) bool { return result.Trend[i].Date < result.Trend[j].Date })
|
||||
for _, rank := range vehicles {
|
||||
result.Ranking = append(result.Ranking, *rank)
|
||||
result.FleetLatestMileageKm += rank.LatestMileageKm
|
||||
}
|
||||
sort.Slice(result.Ranking, func(i, j int) bool { return result.Ranking[i].MileageKm > result.Ranking[j].MileageKm })
|
||||
if result.VehicleCount > 0 {
|
||||
result.AverageMileagePerVIN = result.PeriodMileageKm / float64(result.VehicleCount)
|
||||
}
|
||||
if result.RecordCount > 0 {
|
||||
result.AverageDailyMileageKm = result.PeriodMileageKm / float64(result.RecordCount)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) dailyMileageRows(query url.Values) []DailyMileageRow {
|
||||
rows := []DailyMileageRow{
|
||||
{VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Date: "2026-07-03", StartMileageKm: 48710.2, EndMileageKm: 48798.9, DailyMileageKm: 88.7, Source: "JT808"},
|
||||
|
||||
@@ -1064,6 +1064,36 @@ type MileageSummary struct {
|
||||
AverageMileagePerVIN float64 `json:"averageMileagePerVin"`
|
||||
}
|
||||
|
||||
type MileageTrendPoint struct {
|
||||
Date string `json:"date"`
|
||||
MileageKm float64 `json:"mileageKm"`
|
||||
Vehicles int `json:"vehicles"`
|
||||
}
|
||||
|
||||
type MileageVehicleRank struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
MileageKm float64 `json:"mileageKm"`
|
||||
LatestMileageKm float64 `json:"latestMileageKm"`
|
||||
ActiveDays int `json:"activeDays"`
|
||||
}
|
||||
|
||||
type MileageStatistics struct {
|
||||
DateFrom string `json:"dateFrom"`
|
||||
DateTo string `json:"dateTo"`
|
||||
VehicleCount int `json:"vehicleCount"`
|
||||
RecordCount int `json:"recordCount"`
|
||||
SourceCount int `json:"sourceCount"`
|
||||
PeriodMileageKm float64 `json:"periodMileageKm"`
|
||||
FleetLatestMileageKm float64 `json:"fleetLatestMileageKm"`
|
||||
AverageMileagePerVIN float64 `json:"averageMileagePerVin"`
|
||||
AverageDailyMileageKm float64 `json:"averageDailyMileageKm"`
|
||||
Trend []MileageTrendPoint `json:"trend"`
|
||||
Ranking []MileageVehicleRank `json:"ranking"`
|
||||
AsOf string `json:"asOf"`
|
||||
Evidence string `json:"evidence"`
|
||||
}
|
||||
|
||||
type OnlineStatisticsSummary struct {
|
||||
VehicleCount int `json:"vehicleCount"`
|
||||
OnlineVehicleCount int `json:"onlineVehicleCount"`
|
||||
|
||||
@@ -474,6 +474,83 @@ func buildMileageSummarySQL(query url.Values) SQLQuery {
|
||||
}
|
||||
}
|
||||
|
||||
func buildMileageStatisticsWhere(query url.Values) (string, []any) {
|
||||
where := []string{"1 = 1"}
|
||||
args := []any{}
|
||||
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||
where = append(where, "(m.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + vin + "%"
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where = append(where, "m.protocol = ?")
|
||||
args = append(args, protocol)
|
||||
}
|
||||
if dateFrom := strings.TrimSpace(query.Get("dateFrom")); dateFrom != "" {
|
||||
where = append(where, "m.stat_date >= ?")
|
||||
args = append(args, dateFrom)
|
||||
}
|
||||
if dateTo := strings.TrimSpace(query.Get("dateTo")); dateTo != "" {
|
||||
where = append(where, "m.stat_date <= ?")
|
||||
args = append(args, dateTo)
|
||||
}
|
||||
return strings.Join(where, " AND "), args
|
||||
}
|
||||
|
||||
func buildMileageStatisticsBaseSQL(query url.Values) (string, []any) {
|
||||
where, args := buildMileageStatisticsWhere(query)
|
||||
return `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, m.stat_date, ` +
|
||||
`MAX(COALESCE(m.daily_mileage_km, 0)) AS daily_mileage_km, ` +
|
||||
`MAX(COALESCE(m.latest_total_mileage_km, 0)) AS latest_mileage_km ` +
|
||||
`FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin ` +
|
||||
`WHERE ` + where + ` GROUP BY m.vin, m.stat_date`, args
|
||||
}
|
||||
|
||||
func buildMileageStatisticsSummarySQL(query url.Values) SQLQuery {
|
||||
base, args := buildMileageStatisticsBaseSQL(query)
|
||||
return SQLQuery{Text: `SELECT COUNT(DISTINCT d.vin), COUNT(*), COALESCE(SUM(d.daily_mileage_km), 0), ` +
|
||||
`COALESCE(AVG(d.daily_mileage_km), 0) FROM (` + base + `) d`, Args: args}
|
||||
}
|
||||
|
||||
func buildMileageStatisticsTrendSQL(query url.Values) SQLQuery {
|
||||
base, args := buildMileageStatisticsBaseSQL(query)
|
||||
return SQLQuery{Text: `SELECT DATE_FORMAT(d.stat_date, '%Y-%m-%d'), COALESCE(SUM(d.daily_mileage_km), 0), ` +
|
||||
`COUNT(DISTINCT d.vin) FROM (` + base + `) d GROUP BY d.stat_date ORDER BY d.stat_date ASC`, Args: args}
|
||||
}
|
||||
|
||||
func buildMileageStatisticsRankingSQL(query url.Values) SQLQuery {
|
||||
base, args := buildMileageStatisticsBaseSQL(query)
|
||||
return SQLQuery{Text: `SELECT d.vin, COALESCE(MAX(d.plate), ''), COALESCE(SUM(d.daily_mileage_km), 0), ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(d.latest_mileage_km AS CHAR) ORDER BY d.stat_date DESC), ',', 1) AS DECIMAL(18,3)), 0), ` +
|
||||
`COUNT(DISTINCT d.stat_date) FROM (` + base + `) d ` +
|
||||
`GROUP BY d.vin ORDER BY SUM(d.daily_mileage_km) DESC, d.vin ASC LIMIT 20`, Args: args}
|
||||
}
|
||||
|
||||
func buildMileageStatisticsSourceCountSQL(query url.Values) SQLQuery {
|
||||
where, args := buildMileageStatisticsWhere(query)
|
||||
return SQLQuery{Text: `SELECT COUNT(DISTINCT m.protocol) FROM vehicle_daily_mileage m ` +
|
||||
`LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin WHERE ` + where, Args: args}
|
||||
}
|
||||
|
||||
func buildFleetLatestMileageSQL(query url.Values) SQLQuery {
|
||||
where := []string{"l.total_mileage_km IS NOT NULL", "l.total_mileage_km >= 0"}
|
||||
args := []any{}
|
||||
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||
where = append(where, "(l.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + vin + "%"
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where = append(where, "l.protocol = ?")
|
||||
args = append(args, protocol)
|
||||
}
|
||||
inner := `SELECT l.vin, CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.total_mileage_km AS CHAR) ` +
|
||||
`ORDER BY l.updated_at DESC, l.protocol ASC), ',', 1) AS DECIMAL(18,3)) AS latest_mileage_km ` +
|
||||
`FROM vehicle_realtime_location l LEFT JOIN vehicle_identity_binding b ON b.vin = l.vin WHERE ` +
|
||||
strings.Join(where, " AND ") + ` GROUP BY l.vin`
|
||||
return SQLQuery{Text: `SELECT COALESCE(SUM(x.latest_mileage_km), 0) FROM (` + inner + `) x`, Args: args}
|
||||
}
|
||||
|
||||
func buildLimitOffset(query url.Values) (int, int) {
|
||||
return parsePositive(query.Get("limit"), 20), parsePositive(query.Get("offset"), 0)
|
||||
}
|
||||
|
||||
@@ -1012,6 +1012,67 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
|
||||
return Page[DailyMileageRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
|
||||
result := MileageStatistics{
|
||||
DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"),
|
||||
Trend: []MileageTrendPoint{}, Ranking: []MileageVehicleRank{},
|
||||
Evidence: "vehicle_daily_mileage(按车辆和日期去重)/ vehicle_realtime_location(最新里程表)",
|
||||
}
|
||||
summary := buildMileageStatisticsSummarySQL(query)
|
||||
if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan(&result.VehicleCount, &result.RecordCount, &result.PeriodMileageKm, &result.AverageDailyMileageKm); err != nil {
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
if result.VehicleCount > 0 {
|
||||
result.AverageMileagePerVIN = result.PeriodMileageKm / float64(result.VehicleCount)
|
||||
}
|
||||
sources := buildMileageStatisticsSourceCountSQL(query)
|
||||
if err := s.db.QueryRowContext(ctx, sources.Text, sources.Args...).Scan(&result.SourceCount); err != nil {
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
fleet := buildFleetLatestMileageSQL(query)
|
||||
if err := s.db.QueryRowContext(ctx, fleet.Text, fleet.Args...).Scan(&result.FleetLatestMileageKm); err != nil {
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
trend := buildMileageStatisticsTrendSQL(query)
|
||||
rows, err := s.db.QueryContext(ctx, trend.Text, trend.Args...)
|
||||
if err != nil {
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var point MileageTrendPoint
|
||||
if err := rows.Scan(&point.Date, &point.MileageKm, &point.Vehicles); err != nil {
|
||||
rows.Close()
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
result.Trend = append(result.Trend, point)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
ranking := buildMileageStatisticsRankingSQL(query)
|
||||
rows, err = s.db.QueryContext(ctx, ranking.Text, ranking.Args...)
|
||||
if err != nil {
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var item MileageVehicleRank
|
||||
if err := rows.Scan(&item.VIN, &item.Plate, &item.MileageKm, &item.LatestMileageKm, &item.ActiveDays); err != nil {
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
result.Ranking = append(result.Ranking, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
result.AsOf = time.Now().Format("2006-01-02 15:04:05")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildQualityIssueWhere(query url.Values) (string, []any) {
|
||||
where := []string{"1 = 1"}
|
||||
args := []any{}
|
||||
|
||||
@@ -280,6 +280,53 @@ func TestBuildMileageSummarySQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
|
||||
query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-31"}}
|
||||
summary := buildMileageStatisticsSummarySQL(query)
|
||||
for _, want := range []string{"GROUP BY m.vin, m.stat_date", "MAX(COALESCE(m.daily_mileage_km", "COUNT(DISTINCT d.vin)", "SUM(d.daily_mileage_km)"} {
|
||||
if !strings.Contains(summary.Text, want) {
|
||||
t.Fatalf("statistics summary SQL missing %q: %s", want, summary.Text)
|
||||
}
|
||||
}
|
||||
if len(summary.Args) != 7 || summary.Args[0] != "%粤A%" || summary.Args[4] != "GB32960" {
|
||||
t.Fatalf("statistics args = %#v", summary.Args)
|
||||
}
|
||||
trend := buildMileageStatisticsTrendSQL(query)
|
||||
if !strings.Contains(trend.Text, "GROUP BY d.stat_date ORDER BY d.stat_date ASC") {
|
||||
t.Fatalf("statistics trend SQL should be chronologically stable: %s", trend.Text)
|
||||
}
|
||||
ranking := buildMileageStatisticsRankingSQL(query)
|
||||
if !strings.Contains(ranking.Text, "ORDER BY SUM(d.daily_mileage_km) DESC") || !strings.Contains(ranking.Text, "ORDER BY d.stat_date DESC") || !strings.Contains(ranking.Text, "LIMIT 20") {
|
||||
t.Fatalf("statistics ranking SQL should be bounded and stable: %s", ranking.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMileageStatisticsWindow(t *testing.T) {
|
||||
now := time.Date(2026, 7, 14, 12, 0, 0, 0, time.Local)
|
||||
defaults, err := normalizeMileageStatisticsWindow(url.Values{}, now)
|
||||
if err != nil || defaults.Get("dateFrom") != "2026-06-15" || defaults.Get("dateTo") != "2026-07-14" {
|
||||
t.Fatalf("default statistics window = %v, err=%v", defaults, err)
|
||||
}
|
||||
if _, err := normalizeMileageStatisticsWindow(url.Values{"dateFrom": {"2025-01-01"}, "dateTo": {"2026-07-14"}}, now); err == nil {
|
||||
t.Fatal("statistics window over 366 days must be rejected")
|
||||
}
|
||||
if _, err := normalizeMileageStatisticsWindow(url.Values{"dateFrom": {"2026-07-15"}, "dateTo": {"2026-07-14"}}, now); err == nil {
|
||||
t.Fatal("reversed statistics window must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFleetLatestMileageSQLUsesOneLatestValuePerVehicle(t *testing.T) {
|
||||
built := buildFleetLatestMileageSQL(url.Values{"vin": {"VIN001"}, "protocol": {"JT808"}})
|
||||
for _, want := range []string{"GROUP_CONCAT", "ORDER BY l.updated_at DESC", "GROUP BY l.vin", "SUM(x.latest_mileage_km)"} {
|
||||
if !strings.Contains(built.Text, want) {
|
||||
t.Fatalf("fleet latest mileage SQL missing %q: %s", want, built.Text)
|
||||
}
|
||||
}
|
||||
if len(built.Args) != 5 || built.Args[0] != "%VIN001%" || built.Args[4] != "JT808" {
|
||||
t.Fatalf("fleet latest mileage args = %#v", built.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRawFrameSQL(t *testing.T) {
|
||||
built := buildRawFrameSQL("lingniu_vehicle_ts", RawFrameQuery{
|
||||
Protocol: "GB32960",
|
||||
|
||||
@@ -33,6 +33,7 @@ type Store interface {
|
||||
RawFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error)
|
||||
MileageSummary(context.Context, url.Values) (MileageSummary, error)
|
||||
DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error)
|
||||
MileageStatistics(context.Context, url.Values) (MileageStatistics, error)
|
||||
QualitySummary(context.Context, url.Values) (QualitySummary, error)
|
||||
QualityIssues(context.Context, url.Values) (Page[QualityIssueRow], error)
|
||||
OpsHealth(context.Context) (OpsHealth, error)
|
||||
@@ -3105,6 +3106,42 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
|
||||
return s.store.DailyMileage(ctx, resolvedQuery)
|
||||
}
|
||||
|
||||
func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
|
||||
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
|
||||
if err != nil {
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
resolvedQuery, err = normalizeMileageStatisticsWindow(resolvedQuery, time.Now())
|
||||
if err != nil {
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
return s.store.MileageStatistics(ctx, resolvedQuery)
|
||||
}
|
||||
|
||||
func normalizeMileageStatisticsWindow(query url.Values, now time.Time) (url.Values, error) {
|
||||
next := cloneValues(query)
|
||||
dateTo := strings.TrimSpace(next.Get("dateTo"))
|
||||
if dateTo == "" {
|
||||
dateTo = now.Format("2006-01-02")
|
||||
next.Set("dateTo", dateTo)
|
||||
}
|
||||
dateFrom := strings.TrimSpace(next.Get("dateFrom"))
|
||||
if dateFrom == "" {
|
||||
parsedTo, _ := time.ParseInLocation("2006-01-02", dateTo, now.Location())
|
||||
dateFrom = parsedTo.AddDate(0, 0, -29).Format("2006-01-02")
|
||||
next.Set("dateFrom", dateFrom)
|
||||
}
|
||||
from, fromErr := time.ParseInLocation("2006-01-02", dateFrom, now.Location())
|
||||
to, toErr := time.ParseInLocation("2006-01-02", dateTo, now.Location())
|
||||
if fromErr != nil || toErr != nil || from.After(to) {
|
||||
return nil, clientError{Code: "INVALID_DATE_RANGE", Message: "统计日期范围无效"}
|
||||
}
|
||||
if to.Sub(from) > 365*24*time.Hour {
|
||||
return nil, clientError{Code: "DATE_RANGE_TOO_LARGE", Message: "车辆统计最多查询 366 个自然日"}
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func (s *Service) OnlineStatisticsSummary(ctx context.Context, query url.Values) (OnlineStatisticsSummary, error) {
|
||||
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user