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

442 lines
18 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 -*-
"""信用体系核心服务(累加积分制 · 无上限 · 初始 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": "",
}