Files
server-core/app/api/routers/rbac_credit.py
T

340 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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}