Files
server-core/app/services/badge_engine.py
T

177 lines
7.0 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""徽章体系核心服务:规则评估 → 授予/回收。
徽章授予规则(grant_rule_json):
- {"type":"credit_level","op":">=","value":3} 信用等级达到 Ln
- {"type":"cert_count","op":">=","value":1,"cert_category":"skill"}
- {"type":"task_completed_count","op":">=","value":10}
- {"type":"five_star_count","op":">=","value":5}
- {"type":"on_time_streak","op":">=","value":10}
- {"type":"training_completed_count","op":">=","value":3}
- {"type":"training_hours","op":">=","value":50}
- {"type":"cert_expert_count","op":">=","value":1}
- {"type":"fast_response","op":">=","value":1}
- {"all":[...]} 多条件同时满足
"""
from __future__ import annotations
import logging
from ..infrastructure.repositories import Database
log = logging.getLogger("credit.badge")
async def evaluate_badges(db: Database, user_id: str) -> list[dict]:
"""评估该用户全部激活徽章的授予条件;新满足的授予并返回列表。"""
badges = await db.badges.list_active()
granted: list[dict] = []
for badge in badges:
rule = badge.get("grant_rule") or {}
if not rule:
continue
try:
if await _match_rule(db, user_id, rule):
has = await db.user_badges.get_active(user_id, badge["code"])
if has is None:
entry = await db.user_badges.grant(
user_id=user_id, badge_code=badge["code"], badge_id=badge["id"],
source="auto",
)
granted.append({**badge, "user_badge": entry})
# 徽章加分:直写流水(badge.granteddelta=credit_bonus),再重算
bonus = int(badge.get("credit_bonus", 0) or 0)
if bonus:
await db.credit_ledger.add(
user_id=user_id, event_code="badge.granted", delta=bonus,
dimension="system", reason=f"获得徽章「{badge['name']}",
ref_type="badge", ref_id=badge["id"], source="badge",
)
# 重算总分/等级(徽章可能触发等级徽章链,交给外层循环下一轮)
from .credit_engine import recalc_user # 避免循环 import
await recalc_user(db, user_id)
except Exception as exc: # noqa: BLE001
log.warning("badge eval failed %s/%s: %s", user_id, badge.get("code"), exc)
return granted
async def _match_rule(db: Database, user_id: str, rule: dict) -> bool:
if "all" in rule and isinstance(rule["all"], list):
for sub in rule["all"]:
if not await _match_rule(db, user_id, sub):
return False
return True
if "any" in rule and isinstance(rule["any"], list):
for sub in rule["any"]:
if await _match_rule(db, user_id, sub):
return True
return False
rtype = rule.get("type")
op = rule.get("op", ">=")
value = rule.get("value")
actual = await _get_metric(db, user_id, rtype, rule)
return _compare(actual, op, value)
async def _get_metric(db: Database, user_id: str, rtype: str, rule: dict):
"""按指标类型取用户实际值。"""
if rtype == "credit_level":
score = await db.credit_scores.get(user_id)
lv = (score or {}).get("level", "L1")
order = {"L1": 1, "L2": 2, "L3": 3, "L4": 4, "L5": 5, "L6": 6, "L7": 7, "L8": 8}
return order.get(lv, 1)
if rtype == "cert_count":
cat = rule.get("cert_category", "")
try:
certs = await db.certifications.list(user_id=user_id, status="active")
if not cat:
return len(certs)
return sum(1 for c in certs if (c.get("cert_category") or c.get("cert_type", "")) == cat)
except Exception: # noqa: BLE001
return 0
if rtype == "cert_expert_count":
try:
certs = await db.certifications.list(user_id=user_id, status="active")
return sum(1 for c in certs if str(c.get("level", "")).lower() in ("expert", "advanced"))
except Exception: # noqa: BLE001
return 0
if rtype == "task_completed_count":
try:
rows = await db.credit_ledger.list_all(user_id=user_id, limit=1000)
return sum(1 for r in rows if r["event_code"] in ("task.complete.on_time", "task.complete.overdue"))
except Exception: # noqa: BLE001
return 0
if rtype == "five_star_count":
try:
ratings = await db.ratings.list_for(user_id)
return sum(1 for r in ratings if float(r.get("score", 0) or 0) >= 5)
except Exception: # noqa: BLE001
return 0
if rtype == "on_time_streak":
# 简化:以按时交付事件数作为连续次数近似(后续可按任务时间序精确计算)
try:
rows = await db.credit_ledger.list_all(user_id=user_id, limit=1000)
return sum(1 for r in rows if r["event_code"] == "task.complete.on_time")
except Exception: # noqa: BLE001
return 0
if rtype == "training_completed_count":
try:
return len(await db.course_certificates.list_by_user(user_id))
except Exception: # noqa: BLE001
return 0
if rtype == "training_hours":
try:
rows = await db.credit_ledger.list_all(user_id=user_id, limit=1000)
total = 0
for r in rows:
if r["event_code"] == "training.hours":
total += abs(r["delta"]) * 2 # 每 +5 = 10 小时
return total
except Exception: # noqa: BLE001
return 0
if rtype == "fast_response":
# 简化:以接单/响应活跃事件估算(后续按真实响应时长)
try:
rows = await db.credit_ledger.list_all(user_id=user_id, limit=1000)
return 1 if any(r["event_code"] in ("task.accept", "service.publish") for r in rows) else 0
except Exception: # noqa: BLE001
return 0
if rtype == "acceptance_rate":
# 以按时交付占比近似(credit_ledger 统计)
try:
rows = await db.credit_ledger.list_all(user_id=user_id, limit=1000)
on_time = sum(1 for r in rows if r["event_code"] == "task.complete.on_time")
done = on_time + sum(1 for r in rows if r["event_code"] == "task.complete.overdue")
return round(on_time / done, 3) if done else 0.0
except Exception: # noqa: BLE001
return 0.0
return 0
def _compare(actual, op, value) -> bool:
try:
if op == ">=":
return actual >= value
if op == ">":
return actual > value
if op == "<=":
return actual <= value
if op == "<":
return actual < value
if op == "==":
return actual == value
except TypeError:
return False
return False