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

442 lines
18 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""信用体系核心服务(累加积分制 · 无上限 · 初始 0 分)。
设计要点(v1.1):
- 信用总分 = Σ(所有事件 delta),无上限;新用户初始 0 分(L1 待起步)
- 六维为画像维度(0-100 归一化,供雷达图展示相对水平),不参与总分
- 事件驱动:先落 credit_ledger 流水 → 重算累计分+等级 → 徽章评估
- 每日全量重算兜底:full_recompute()(存量用户按历史数据回填)
"""
from __future__ import annotations
import logging
from typing import Any
from ..infrastructure.repositories import Database
from .badge_engine import evaluate_badges
log = logging.getLogger("credit.engine")
# 各事件计算规则使用的统计口径(避免魔法数字散落)
_ON_TIME_WINDOW_DAYS = 90 # 履约统计窗口
_POSITIVE_REVIEW_MIN = 3 # 好评最低星
_NEGATIVE_REVIEW_MAX = 2 # 差评最高星
async def _compute_delta(db: Database, rule: dict, payload: dict | None) -> int:
"""按规则计算事件分值(fixed / by_score / by_level / by_hours)。"""
mode = rule.get("delta_mode", "fixed")
base = int(rule.get("base_delta", 0))
cfg = rule.get("delta_config", {}) or {}
cap = abs(int(rule.get("cap_single", 0)))
payload = payload or {}
if mode == "fixed":
delta = base
elif mode == "by_score":
# payload: {"score": 5} → cfg {"5":15,"4":10,...}
score = payload.get("score")
delta = int(cfg.get(str(score), base)) if score is not None else base
elif mode == "by_level":
# payload: {"level": "expert"} → cfg {"beginner":40,...}
level = payload.get("level", "")
delta = int(cfg.get(level, base)) if level else base
elif mode == "by_hours":
# payload: {"hours": 25} → cfg {"per":10,"monthly_cap":30}
per = int(cfg.get("per", 10) or 10)
hours = int(payload.get("hours", 0) or 0)
delta = base * (hours // per) if per > 0 else 0
else:
delta = base
# 单事件封顶(取绝对值比较,保留符号)
if cap > 0:
delta = max(-cap, min(cap, delta))
return delta
async def on_credit_event(
db: Database, *,
user_id: str,
event_code: str,
ref_type: str = "",
ref_id: str = "",
payload: dict | None = None,
reason: str = "",
source: str = "system",
operator_id: str = "",
skip_unique_check: bool = False,
) -> dict:
"""信用事件入口:规则匹配 → 落流水 → 重算 → 徽章评估 → 返回结果。
幂等性:
- 规则 unique_per_user=1(认证类):同一用户该事件只计一次(去重)
- 规则 unique_per_user=0:按 ref_id 去重(同一任务/课程只计一次)
"""
rule = await db.credit_rules.get(event_code)
if rule is None or not rule.get("is_active"):
log.info("credit event ignored: %s (rule missing/disabled)", event_code)
return {"ok": False, "reason": "rule_disabled"}
# 幂等去重
if not skip_unique_check:
if rule.get("unique_per_user"):
if await db.credit_ledger.has_event(user_id, event_code):
return {"ok": False, "reason": "already_counted", "event_code": event_code}
elif ref_id:
if await db.credit_ledger.has_event(user_id, event_code, ref_id):
return {"ok": False, "reason": "already_counted", "event_code": event_code}
delta = await _compute_delta(db, rule, payload)
dimension = rule.get("dimension", "system")
if not reason:
reason = rule.get("name", event_code)
entry = await db.credit_ledger.add(
user_id=user_id, event_code=event_code, delta=delta, dimension=dimension,
reason=reason, ref_type=ref_type, ref_id=ref_id,
source=source, operator_id=operator_id,
)
# 重算累计分 + 等级(同步 opc_profiles.credit_score 兼容字段)
recalc = await recalc_user(db, user_id)
# 徽章评估(信用分变化可能触发等级徽章)
granted = await evaluate_badges(db, user_id)
# 实时通知:信用分变化(含原因与当前值;不阻塞主流程)
try:
from .notification_service import notify as _n
total = recalc.get("total_score", 0)
delta_txt = f"+{delta}" if delta >= 0 else str(delta)
await _n(
db, user_id, "credit", "信用分变化",
f"信用分 {delta_txt}{reason}),当前 {total}",
event_code="credit.changed",
level="success" if delta >= 0 else "warning",
link="/opc/credit", ref_type=ref_type or "credit", ref_id=ref_id or "",
)
for bg in granted or []:
await _n(
db, user_id, "credit", "获得徽章",
f"你获得了「{bg.get('name') or bg.get('badge_code') or '新徽章'}」徽章",
event_code="credit.badge_granted", level="success",
link="/opc/credit", ref_type="badge", ref_id=bg.get("badge_code") or "",
)
except Exception: # noqa: BLE001
pass
return {"ok": True, "entry": entry, "granted_badges": granted}
async def recalc_user(db: Database, user_id: str) -> dict:
"""重算单个用户:累计分(流水汇总)+ 等级 + 六维画像。"""
total = await db.credit_ledger.sum_delta(user_id)
level_info = await db.credit_levels.level_for(total)
dimensions = await _calc_dimensions(db, user_id, total)
rating_stats = await _rating_stats(db, user_id)
task_stats = await _task_stats(db, user_id)
percentile = await _calc_percentile(db, total)
await db.credit_scores.upsert(
user_id, total_score=total, level=level_info["level"], dimensions=dimensions,
rating_avg=rating_stats["avg"], rating_count=rating_stats["count"],
task_count=task_stats["completed"], on_time_rate=task_stats["on_time_rate"],
percentile=percentile,
)
# 同步旧字段(opc_profiles.credit_score),兼容 /me/credit 等旧接口
await db.opc_profiles.upsert(user_id, credit_score=total)
return {
"user_id": user_id, "total_score": total,
"level": level_info["level"], "level_name": level_info.get("name", ""),
"dimensions": dimensions,
"rating_avg": rating_stats["avg"], "rating_count": rating_stats["count"],
"task_count": task_stats["completed"], "on_time_rate": task_stats["on_time_rate"],
"percentile": percentile,
}
async def _calc_dimensions(db: Database, user_id: str, total_score: int) -> list[dict]:
"""六维画像(0-100 归一化,相对水平展示;不参与总分)。
口径(v1.1,可后续细化):
- capability 能力:按有效认证数 + 技能测试 + 培训完成换算
- reputation 好评:贝叶斯修正均分 → 0-100
- performance 履约:按时交付率 → 0-100
- compliance 合规:负向扣分事件数 → 从 100 起扣
- domain 领域:按领域认证/订单占比(简化为领域认证数)
- activity 活跃:按活跃类事件加分累计换算
"""
dims_cfg = {d["code"]: d for d in await db.credit_dimensions.list_active()}
result: list[dict] = []
# 能力:认证 + 培训 + 技能测试
try:
certs = await db.certifications.list(user_id=user_id, status="active")
cert_count = len(certs)
except Exception: # noqa: BLE001
cert_count = 0
training_done = 0
try:
training_done = len(await db.course_certificates.list_by_user(user_id))
except Exception: # noqa: BLE001
training_done = 0
cap_score = min(100, 10 + cert_count * 15 + training_done * 5)
# 好评:贝叶斯修正
rating_stats = await _rating_stats(db, user_id)
avg = rating_stats["avg"]
n = rating_stats["count"]
C, m = 5, 4.0
bayes = (C * m + avg * n) / (C + n) if n else m
rep_score = min(100, bayes / 5 * 100)
# 履约:按时交付率(简化:用流水中的正负履约事件)
per_score = await _performance_score(db, user_id)
# 合规:从 100 起按负向扣
comp_score = await _compliance_score(db, user_id)
# 领域:领域认证数量
try:
certs_all = await db.certifications.list(user_id=user_id, status="active")
domain_certs = sum(1 for c in certs_all if (c.get("cert_category") or c.get("cert_type", "")) == "domain")
except Exception: # noqa: BLE001
domain_certs = 0
domain_score = min(100, 10 + domain_certs * 20)
# 活跃:按活跃事件加分
act_score = await _activity_score(db, user_id)
for code, name, weight, desc in [
("performance", "履约", 0.30, "按时交付率、一次验收通过率、完成率"),
("reputation", "好评", 0.20, "贝叶斯修正均分、好评率、评价数量"),
("capability", "能力", 0.20, "认证数量与等级、技能测试、培训完成"),
("domain", "领域", 0.15, "同领域深耕度、领域认证"),
("compliance", "合规", 0.10, "实名认证、违规记录、处罚情况"),
("activity", "活跃", 0.05, "登录频次、接单活跃度"),
]:
score = {"performance": per_score, "reputation": rep_score, "capability": cap_score,
"domain": domain_score, "compliance": comp_score, "activity": act_score}[code]
cfg = dims_cfg.get(code, {})
result.append({
"code": code,
"name": cfg.get("name", name),
"score": int(round(score)),
"weight": cfg.get("weight", weight),
"description": cfg.get("description", desc),
})
return result
async def _rating_stats(db: Database, user_id: str) -> dict:
"""好评维度统计:平均分 + 评价数(ratings 表,用户作为被评对象)。"""
try:
rows = await db.ratings.list_for(user_id)
if not rows:
return {"avg": 0.0, "count": 0}
total = sum(float(r.get("score", 0)) for r in rows)
return {"avg": round(total / len(rows), 2), "count": len(rows)}
except Exception: # noqa: BLE001
return {"avg": 0.0, "count": 0}
async def _task_stats(db: Database, user_id: str) -> dict:
"""履约统计:已完成任务数 + 按时交付率(基于信用流水统计)。"""
completed = 0
on_time = 0
total_done = 0
try:
rows = await db.credit_ledger.list_all(user_id=user_id, limit=1000)
for r in rows:
if r["event_code"] == "task.complete.on_time":
completed += 1
on_time += 1
total_done += 1
elif r["event_code"] == "task.complete.overdue":
completed += 1
total_done += 1
elif r["event_code"] == "task.cancel.provider":
total_done += 1
except Exception: # noqa: BLE001
pass
return {
"completed": completed,
"on_time_rate": round(on_time / total_done, 3) if total_done else 0.0,
}
async def _performance_score(db: Database, user_id: str) -> int:
"""履约画像:按时交付 +10 / 逾期 -15 / 弃单 -20100 为基线上限。"""
on_time = 0
overdue = 0
cancel = 0
try:
rows = await db.credit_ledger.list_all(user_id=user_id, limit=1000)
for r in rows:
if r["event_code"] == "task.complete.on_time":
on_time += 1
elif r["event_code"] == "task.complete.overdue":
overdue += 1
elif r["event_code"] == "task.cancel.provider":
cancel += 1
except Exception: # noqa: BLE001
pass
if on_time + overdue + cancel == 0:
return 50
score = on_time * 2 - overdue * 5 - cancel * 8
return max(0, min(100, score + 50))
async def _compliance_score(db: Database, user_id: str) -> int:
"""合规画像:从 100 起按负向事件扣。"""
penalty = 0
try:
rows = await db.credit_ledger.list_all(user_id=user_id, limit=1000)
for r in rows:
if r["event_code"] in ("violation.penalty", "violation.fraud", "violation.complaint",
"dispute.lost", "deposit.deducted"):
penalty += abs(r["delta"])
except Exception: # noqa: BLE001
pass
return max(0, min(100, 100 - penalty // 10))
async def _activity_score(db: Database, user_id: str) -> int:
"""活跃画像:按活跃类事件累计。"""
act = 0
try:
rows = await db.credit_ledger.list_all(user_id=user_id, limit=1000)
for r in rows:
if r["event_code"] in ("behavior.daily", "task.accept", "service.publish"):
act += abs(r["delta"])
except Exception: # noqa: BLE001
pass
return max(0, min(100, act * 2))
async def _calc_percentile(db: Database, total_score: int) -> int:
"""同平台百分位估算:以 credit_scores 表分布计算。"""
try:
rows = await db.credit_scores.list_top(limit=100000)
if not rows:
return 0
below = sum(1 for r in rows if r["total_score"] < total_score)
return int(below / len(rows) * 100)
except Exception: # noqa: BLE001
return 0
async def full_recompute(db: Database) -> dict:
"""全量重算:所有用户按历史数据回填事件 → 重算总分/等级/徽章。
回填策略(source='backfill',可审计区分):
- 已有认证(certifications status active/approved)→ cert.* 事件(按认证类型)
- 已有技能测试通过 → skill.test.passed
- 已有培训结业(training_certificates)→ training.completed
- 已有评价(ratings)→ task.review.positive/negative
- 已有完成任务 → task.complete.on_time
随后逐用户 recalc + 徽章评估。返回统计。
"""
stats = {"users": 0, "backfilled_events": 0, "badges_granted": 0}
# 1. 收集所有用户
user_ids = [u.get("id") for u in await db.users.list() if u.get("id")]
for uid in user_ids:
stats["users"] += 1
await _backfill_user(db, uid)
await recalc_user(db, uid)
granted = await evaluate_badges(db, uid)
stats["badges_granted"] += len(granted)
# 2. 更新等级徽章(等级变化回收/授予由 evaluate_badges 处理)
log.info("full_recompute done: %s", stats)
return stats
async def _backfill_user(db: Database, user_id: str) -> int:
"""按历史数据回填事件(幂等:已存在的流水不重复)。"""
count = 0
# 认证回填
try:
certs = await db.certifications.list(user_id=user_id, status="active")
for c in certs:
ctype = c.get("cert_category", "") or c.get("cert_type", "")
event_map = {
"identity": "cert.identity",
"merchant": "cert.merchant",
"enterprise": "cert.enterprise",
"education": "cert.education",
"qualification": "cert.qualification",
"skill": "cert.skill",
"opc": "cert.opc",
}
code = event_map.get(ctype, "")
if not code:
continue
if await db.credit_ledger.has_event(user_id, code, c.get("id", "")):
continue
await on_credit_event(
db, user_id=user_id, event_code=code,
ref_type="certification", ref_id=c.get("id", ""),
source="backfill", skip_unique_check=True,
)
count += 1
except Exception as exc: # noqa: BLE001
log.warning("backfill cert failed for %s: %s", user_id, exc)
# 培训回填
try:
if hasattr(db.course_certificates, "list_by_user"):
certs2 = await db.course_certificates.list_by_user(user_id)
for cc in certs2:
cid = cc.get("id", "")
if await db.credit_ledger.has_event(user_id, "training.completed", cid):
continue
await on_credit_event(
db, user_id=user_id, event_code="training.completed",
ref_type="training_cert", ref_id=cid,
source="backfill", skip_unique_check=True,
)
count += 1
except Exception as exc: # noqa: BLE001
log.warning("backfill training failed for %s: %s", user_id, exc)
# 评价回填
try:
ratings = await db.ratings.list_for(user_id)
for r in ratings:
rid = r.get("id", "")
score = float(r.get("score", 0) or 0)
code = "task.review.positive" if score >= _POSITIVE_REVIEW_MIN else "task.review.negative"
if await db.credit_ledger.has_event(user_id, code, rid):
continue
await on_credit_event(
db, user_id=user_id, event_code=code,
payload={"score": int(score)},
ref_type="task", ref_id=r.get("task_id", "") or rid,
reason=f"历史评价 {score} 星(回填)",
source="backfill", skip_unique_check=True,
)
count += 1
except Exception as exc: # noqa: BLE001
log.warning("backfill rating failed for %s: %s", user_id, exc)
return count
async def ensure_user_profile(db: Database, user_id: str) -> dict:
"""确保用户有信用档案(新用户初始 0 分 = L1)。"""
existing = await db.credit_scores.get(user_id)
if existing is not None:
return existing
await recalc_user(db, user_id)
return await db.credit_scores.get(user_id) or {
"user_id": user_id, "total_score": 0, "level": "L1",
"dimensions": [], "rating_avg": 0.0, "rating_count": 0,
"task_count": 0, "on_time_rate": 0.0, "percentile": 0, "updated_at": "",
}