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

177 lines
7.0 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 -*-
"""徽章体系核心服务:规则评估 → 授予/回收。
徽章授予规则(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