Add gamification trust analytics polish

This commit is contained in:
VPN SaaS Dev
2026-05-12 20:15:24 +09:00
parent 26875e396c
commit 7fd4ab768f
5 changed files with 95 additions and 2 deletions

View File

@@ -158,6 +158,12 @@ Backend проверяет подпись Telegram, создает/обновл
- `POST /api/ocr/license-plate` - `POST /api/ocr/license-plate`
- `POST /api/ocr/vin` - `POST /api/ocr/vin`
- `POST /api/ocr/service-document` - `POST /api/ocr/service-document`
- `GET /api/me/achievements`
- `GET /api/my/vehicles/{vehicle_id}/score`
- `GET /api/my/vehicles/{vehicle_id}/timeline`
- `GET /api/service-centers/{service_center_id}/trust-score`
CarPass quality and trust scores are backend-owned. The scoring engine in `app/services/scoring.py` calculates vehicle profile completeness, verified maintenance history, maintenance health, service-center trust, evidence-style achievements, and cooldown-protected engagement events. Frontend only displays the result.
Расход топлива считается по интервалам между полными баками (`is_full_tank=true`). Если данных мало, API возвращает `null`, а не выдуманную цифру. Расход топлива считается по интервалам между полными баками (`is_full_tank=true`). Если данных мало, API возвращает `null`, а не выдуманную цифру.

View File

@@ -22,6 +22,7 @@ from app.services.scoring import (
compute_service_center_score, compute_service_center_score,
compute_vehicle_score, compute_vehicle_score,
evaluate_garage_achievements, evaluate_garage_achievements,
record_engagement_event,
) )
router = APIRouter(tags=["gamification"]) router = APIRouter(tags=["gamification"])
@@ -64,6 +65,13 @@ async def vehicle_score(
): ):
car = await ensure_vehicle_owner_or_access(session, vehicle_id, current_user) car = await ensure_vehicle_owner_or_access(session, vehicle_id, current_user)
score = await compute_vehicle_score(session, car) score = await compute_vehicle_score(session, car)
await record_engagement_event(
session,
event_type="vehicle_score_viewed",
user_id=current_user.id,
vehicle_id=vehicle_id,
metadata={"completeness_score": score.completeness_score},
)
await session.commit() await session.commit()
await session.refresh(score) await session.refresh(score)
return score return score
@@ -82,6 +90,12 @@ async def vehicle_timeline(
limit = min(limit, 200) limit = min(limit, 200)
offset = max(offset, 0) offset = max(offset, 0)
car = await ensure_vehicle_owner_or_access(session, vehicle_id, current_user) car = await ensure_vehicle_owner_or_access(session, vehicle_id, current_user)
await record_engagement_event(
session,
event_type="vehicle_timeline_viewed",
user_id=current_user.id,
vehicle_id=vehicle_id,
)
fuel_entries = list((await session.execute(select(FuelEntry).where(FuelEntry.car_id == car.id))).scalars()) fuel_entries = list((await session.execute(select(FuelEntry).where(FuelEntry.car_id == car.id))).scalars())
service_entries = list((await session.execute(select(ServiceEntry).where(ServiceEntry.car_id == car.id))).scalars()) service_entries = list((await session.execute(select(ServiceEntry).where(ServiceEntry.car_id == car.id))).scalars())
visits = list((await session.execute(select(ServiceVisit).where(ServiceVisit.vehicle_id == car.id))).scalars()) visits = list((await session.execute(select(ServiceVisit).where(ServiceVisit.vehicle_id == car.id))).scalars())
@@ -179,6 +193,13 @@ async def service_center_trust_score(
raise HTTPException(status_code=404, detail="Service center not found") raise HTTPException(status_code=404, detail="Service center not found")
await ensure_service_employee(session, service_center_id, current_user) await ensure_service_employee(session, service_center_id, current_user)
score = await compute_service_center_score(session, center) score = await compute_service_center_score(session, center)
await record_engagement_event(
session,
event_type="service_trust_score_viewed",
user_id=current_user.id,
service_center_id=service_center_id,
metadata={"trust_score": score.trust_score, "trust_level": score.trust_level},
)
await session.commit() await session.commit()
await session.refresh(score) await session.refresh(score)
return score return score

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import UTC, date, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from sqlalchemy import func, select from sqlalchemy import func, select
@@ -327,6 +327,41 @@ async def unlock_achievement(
) )
async def record_engagement_event(
session: AsyncSession,
*,
event_type: str,
user_id: int | None = None,
vehicle_id: int | None = None,
service_center_id: int | None = None,
metadata: dict | None = None,
cooldown_minutes: int = 360,
) -> None:
since = datetime.now(UTC) - timedelta(minutes=cooldown_minutes)
result = await session.execute(
select(EngagementEvent).where(
EngagementEvent.event_type == event_type,
EngagementEvent.user_id.is_(None) if user_id is None else EngagementEvent.user_id == user_id,
EngagementEvent.vehicle_id.is_(None) if vehicle_id is None else EngagementEvent.vehicle_id == vehicle_id,
EngagementEvent.service_center_id.is_(None)
if service_center_id is None
else EngagementEvent.service_center_id == service_center_id,
EngagementEvent.created_at >= since,
)
)
if result.scalar_one_or_none() is not None:
return
session.add(
EngagementEvent(
user_id=user_id,
vehicle_id=vehicle_id,
service_center_id=service_center_id,
event_type=event_type,
metadata_json=metadata,
)
)
async def evaluate_vehicle_achievements( async def evaluate_vehicle_achievements(
session: AsyncSession, session: AsyncSession,
car: Car, car: Car,

View File

@@ -803,7 +803,16 @@ function openCarProfile() {
} }
async function loadServiceCenters() { async function loadServiceCenters() {
state.serviceCenters = await api("/service-centers/my"); const centers = await api("/service-centers/my");
state.serviceCenters = await Promise.all(
centers.map(async (center) => {
try {
return { ...center, trust_score: await api(`/service-centers/${center.id}/trust-score`) };
} catch (_) {
return center;
}
}),
);
renderServiceCenters(); renderServiceCenters();
} }
@@ -821,12 +830,23 @@ function renderServiceCenters() {
<strong>${center.display_name || center.name}</strong> <strong>${center.display_name || center.name}</strong>
<small>${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"}</small> <small>${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"}</small>
<small>Статус: ${center.verification_status}</small> <small>Статус: ${center.verification_status}</small>
${center.trust_score ? `<span class="trust-badge">${trustLabel(center.trust_score.trust_level)} · ${center.trust_score.trust_score}/100</span>` : ""}
</div> </div>
`, `,
) )
.join(""); .join("");
} }
function trustLabel(level) {
const labels = {
new_service: "Новый сервис",
verified_service: "Проверенный сервис",
reliable_service: "Надежный сервис",
high_confidence_service: "Высокое доверие",
};
return labels[level] || "Новый сервис";
}
function renderPlaceholderList(selector, message) { function renderPlaceholderList(selector, message) {
const root = document.querySelector(selector); const root = document.querySelector(selector);
if (root) root.innerHTML = `<div class="empty">${message}</div>`; if (root) root.innerHTML = `<div class="empty">${message}</div>`;

View File

@@ -1373,6 +1373,17 @@ select {
color: var(--muted); color: var(--muted);
} }
.trust-badge {
width: fit-content;
padding: 5px 8px;
color: #17362f;
border: 1px solid rgba(22, 128, 106, 0.2);
border-radius: 8px;
background: rgba(94, 224, 189, 0.18);
font-size: 12px;
font-weight: 700;
}
@keyframes toastIn { @keyframes toastIn {
from { from {
opacity: 0; opacity: 0;