340 lines
11 KiB
Python
340 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""信用体系用户侧端点:信用概览 / 徽章墙 / 流水 / 等级表 / 事件说明。
|
||
|
||
数据全部来自 credit_engine 重算结果(credit_scores 缓存 + credit_ledger 流水 + user_badges)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||
from pydantic import BaseModel
|
||
|
||
from ..dependencies import get_db
|
||
from ...rbac import require_roles
|
||
from ...infrastructure.repositories import Database
|
||
from ...services.credit_engine import ensure_user_profile, recalc_user
|
||
|
||
router = APIRouter(tags=["credit"])
|
||
|
||
_ANY = ("opc_member", "carrier", "operator")
|
||
|
||
|
||
@router.get("/credit/overview", summary="我的信用概览(总分/等级/六维/百分位)")
|
||
async def credit_overview(
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles(*_ANY)),
|
||
):
|
||
user_id = user["id"]
|
||
# 首次访问自动建档(初始 0 分 = L1),并惰性评估徽章
|
||
profile = await ensure_user_profile(db, user_id)
|
||
levels = await db.credit_levels.list_all()
|
||
|
||
# 当前等级与下一级
|
||
total = profile.get("total_score", 0)
|
||
level = profile.get("level", "L1")
|
||
cur = next((lv for lv in levels if lv["level"] == level), None)
|
||
nxt = None
|
||
for lv in levels:
|
||
if lv["sort_order"] > (cur or {}).get("sort_order", 0):
|
||
nxt = lv
|
||
break
|
||
progress = 0
|
||
if cur and nxt:
|
||
span = nxt["min_score"] - cur["min_score"]
|
||
if span > 0:
|
||
progress = max(0, min(100, round((total - cur["min_score"]) / span * 100)))
|
||
|
||
return {
|
||
"user_id": user_id,
|
||
"total_score": total,
|
||
"level": level,
|
||
"level_name": (cur or {}).get("name", ""),
|
||
"tier": (cur or {}).get("tier", "bronze"),
|
||
"color": (cur or {}).get("color", "#A0A0A0"),
|
||
"level_benefits": (cur or {}).get("benefits", []),
|
||
"next_level": {
|
||
"level": nxt["level"], "name": nxt["name"], "min_score": nxt["min_score"],
|
||
"tier": nxt["tier"], "color": nxt["color"],
|
||
} if nxt else None,
|
||
"progress_to_next": progress,
|
||
"dimensions": profile.get("dimensions", []),
|
||
"rating_avg": profile.get("rating_avg", 0.0),
|
||
"rating_count": profile.get("rating_count", 0),
|
||
"task_count": profile.get("task_count", 0),
|
||
"on_time_rate": profile.get("on_time_rate", 0.0),
|
||
"percentile": profile.get("percentile", 0),
|
||
"updated_at": profile.get("updated_at", ""),
|
||
}
|
||
|
||
|
||
@router.get("/credit/badges", summary="我的徽章(含未获得)")
|
||
async def credit_badges(
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles(*_ANY)),
|
||
):
|
||
user_id = user["id"]
|
||
await ensure_user_profile(db, user_id)
|
||
all_badges = await db.badges.list_active()
|
||
mine = await db.user_badges.list_active(user_id)
|
||
mine_codes = {ub["badge_code"] for ub in mine}
|
||
|
||
items = []
|
||
for b in all_badges:
|
||
items.append({
|
||
"code": b["code"], "name": b["name"], "category": b["category"],
|
||
"tier": b["tier"], "icon": b["icon"], "description": b["description"],
|
||
"condition": b["condition"], "credit_bonus": b["credit_bonus"],
|
||
"earned": b["code"] in mine_codes,
|
||
"granted_at": next((ub["granted_at"] for ub in mine if ub["badge_code"] == b["code"]), ""),
|
||
})
|
||
|
||
return {
|
||
"items": items,
|
||
"earned_count": len(mine_codes),
|
||
"total_count": len(items),
|
||
}
|
||
|
||
|
||
@router.get("/credit/ledger", summary="我的信用流水")
|
||
async def credit_ledger(
|
||
request: Request,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles(*_ANY)),
|
||
):
|
||
user_id = user["id"]
|
||
await ensure_user_profile(db, user_id)
|
||
rows = await db.credit_ledger.list_for_user(user_id, limit=min(limit, 200), offset=offset)
|
||
total = await db.credit_ledger.count_for_user(user_id)
|
||
return {"items": rows, "total": total, "limit": limit, "offset": offset}
|
||
|
||
|
||
@router.get("/credit/levels", summary="信用等级表(L1-L8)")
|
||
async def credit_levels(
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles(*_ANY)),
|
||
):
|
||
levels = await db.credit_levels.list_all()
|
||
return {"items": levels}
|
||
|
||
|
||
@router.get("/credit/rules", summary="信用事件规则说明")
|
||
async def credit_rules(
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles(*_ANY)),
|
||
):
|
||
rules = await db.credit_rules.list_active()
|
||
# 隐藏 system 维度规则(badge.granted/manual.adjust)保留展示但标记内部
|
||
return {"items": rules}
|
||
|
||
|
||
@router.get("/credit/rank", summary="信用排行榜(Top 50)")
|
||
async def credit_rank(
|
||
request: Request,
|
||
limit: int = 50,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles(*_ANY)),
|
||
):
|
||
tops = await db.credit_scores.list_top(limit=min(limit, 100))
|
||
result = []
|
||
for i, row in enumerate(tops, start=1):
|
||
u = await db.users.get_by_id(row["user_id"])
|
||
result.append({
|
||
"rank": i,
|
||
"user_id": row["user_id"],
|
||
"nickname": (u or {}).get("nickname") or (u or {}).get("username", ""),
|
||
"avatar": (u or {}).get("avatar", ""),
|
||
"total_score": row["total_score"],
|
||
"level": row["level"],
|
||
})
|
||
return {"items": result}
|
||
|
||
|
||
# ── 运营端:信用/徽章管理 ────────────────────────────────────────────────
|
||
|
||
_OPERATOR = ("operator",)
|
||
|
||
|
||
class RuleUpdateRequest(BaseModel):
|
||
base_delta: int | None = None
|
||
delta_config: dict | None = None
|
||
cap_single: int | None = None
|
||
monthly_cap: int | None = None
|
||
unique_per_user: int | None = None
|
||
is_active: int | None = None
|
||
|
||
|
||
class LevelUpdateRequest(BaseModel):
|
||
min_score: int | None = None
|
||
max_score: int | None = None
|
||
name: str | None = None
|
||
tier: str | None = None
|
||
color: str | None = None
|
||
benefits: list | None = None
|
||
badge_code: str | None = None
|
||
|
||
|
||
class BadgeCreateRequest(BaseModel):
|
||
code: str
|
||
name: str
|
||
category: str = "activity"
|
||
tier: str = "bronze"
|
||
icon: str = "StarFilled"
|
||
description: str = ""
|
||
condition: str = ""
|
||
grant_rule: dict = {}
|
||
credit_bonus: int = 0
|
||
lifetime: int = 1
|
||
revocable: int = 1
|
||
expiry_days: int = 0
|
||
priority: int = 99
|
||
|
||
|
||
class ManualAdjustRequest(BaseModel):
|
||
user_id: str
|
||
delta: int
|
||
reason: str = ""
|
||
|
||
|
||
@router.get("/admin/credit/users", summary="用户信用列表(运营端,按总分倒序)")
|
||
async def admin_credit_users(
|
||
request: Request,
|
||
limit: int = 100,
|
||
offset: int = 0,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles(*_OPERATOR)),
|
||
):
|
||
rows = await db.credit_scores.list_top(limit=min(limit, 500))
|
||
items = []
|
||
for row in rows[offset:offset + limit]:
|
||
u = await db.users.get_by_id(row["user_id"])
|
||
items.append({
|
||
**row,
|
||
"nickname": (u or {}).get("nickname") or (u or {}).get("username", ""),
|
||
"avatar": (u or {}).get("avatar", ""),
|
||
"username": (u or {}).get("username", ""),
|
||
})
|
||
return {"items": items, "total": len(rows)}
|
||
|
||
|
||
@router.get("/admin/credit/users/{user_id}", summary="用户信用详情(运营端)")
|
||
async def admin_credit_user_detail(
|
||
user_id: str,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles(*_OPERATOR)),
|
||
):
|
||
profile = await db.credit_scores.get(user_id)
|
||
if profile is None:
|
||
raise HTTPException(status_code=404, detail="用户信用档案不存在")
|
||
ledger = await db.credit_ledger.list_for_user(user_id, limit=200)
|
||
badges = await db.user_badges.list_active(user_id)
|
||
u = await db.users.get_by_id(user_id)
|
||
return {
|
||
"profile": profile,
|
||
"ledger": ledger,
|
||
"badges": badges,
|
||
"user": {"nickname": (u or {}).get("nickname"), "username": (u or {}).get("username", ""),
|
||
"avatar": (u or {}).get("avatar", "")},
|
||
}
|
||
|
||
|
||
@router.put("/admin/credit/rules/{event_code}", summary="信用规则配置(运营端)")
|
||
async def admin_update_rule(
|
||
event_code: str,
|
||
req: RuleUpdateRequest,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles(*_OPERATOR)),
|
||
):
|
||
patch = {k: v for k, v in req.model_dump().items() if v is not None}
|
||
updated = await db.credit_rules.update(event_code, patch)
|
||
if updated is None:
|
||
raise HTTPException(status_code=404, detail="规则不存在")
|
||
return {"ok": True, "item": updated}
|
||
|
||
|
||
@router.get("/admin/credit/rules", summary="信用规则列表(运营端)")
|
||
async def admin_list_rules(
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles(*_OPERATOR)),
|
||
):
|
||
return {"items": await db.credit_rules.list_active()}
|
||
|
||
|
||
@router.put("/admin/credit/levels/{level}", summary="信用等级配置(运营端)")
|
||
async def admin_update_level(
|
||
level: str,
|
||
req: LevelUpdateRequest,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles(*_OPERATOR)),
|
||
):
|
||
patch = {k: v for k, v in req.model_dump().items() if v is not None}
|
||
updated = await db.credit_levels.update(level, patch)
|
||
if updated is None:
|
||
raise HTTPException(status_code=404, detail="等级不存在")
|
||
return {"ok": True, "item": updated}
|
||
|
||
|
||
@router.get("/admin/badges", summary="徽章列表(运营端)")
|
||
async def admin_list_badges(
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles(*_OPERATOR)),
|
||
):
|
||
return {"items": await db.badges.list_all()}
|
||
|
||
|
||
@router.post("/admin/badges", summary="新增徽章(运营端)")
|
||
async def admin_create_badge(
|
||
req: BadgeCreateRequest,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles(*_OPERATOR)),
|
||
):
|
||
try:
|
||
badge = await db.badges.create(**req.model_dump())
|
||
except Exception as exc: # noqa: BLE001
|
||
raise HTTPException(status_code=400, detail=f"创建失败: {exc}") from exc
|
||
return {"ok": True, "item": badge}
|
||
|
||
|
||
@router.put("/admin/badges/{code}", summary="编辑徽章(运营端)")
|
||
async def admin_update_badge(
|
||
code: str,
|
||
req: BadgeCreateRequest,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles(*_OPERATOR)),
|
||
):
|
||
patch = {k: v for k, v in req.model_dump().items() if v is not None}
|
||
updated = await db.badges.update(code, patch)
|
||
if updated is None:
|
||
raise HTTPException(status_code=404, detail="徽章不存在")
|
||
return {"ok": True, "item": updated}
|
||
|
||
|
||
@router.post("/admin/credit/manual-adjust", summary="人工调分(运营端)")
|
||
async def admin_manual_adjust(
|
||
req: ManualAdjustRequest,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles(*_OPERATOR)),
|
||
):
|
||
if not req.user_id or req.delta == 0:
|
||
raise HTTPException(status_code=400, detail="参数不完整或分值为 0")
|
||
from ...services.credit_engine import recalc_user
|
||
await db.credit_ledger.add(
|
||
user_id=req.user_id, event_code="manual.adjust", delta=req.delta,
|
||
dimension="system", reason=f"运营人工调分: {req.reason}",
|
||
source="admin", operator_id=user["id"],
|
||
)
|
||
await recalc_user(db, req.user_id)
|
||
return {"ok": True, "user_id": req.user_id, "delta": req.delta}
|