177 lines
7.0 KiB
Python
177 lines
7.0 KiB
Python
# -*- 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.granted,delta=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
|