feat: add mysql-backed customer risk tools and manual do-not-serve gate

This commit is contained in:
2026-03-01 13:50:20 +08:00
parent 1c266f2887
commit abe5886b5d
3 changed files with 466 additions and 0 deletions

336
db/customer_risk_db.py Normal file
View File

@@ -0,0 +1,336 @@
"""客户风控数据库MySQL 优先SQLite 兜底)"""
import os
import sqlite3
import json
from datetime import datetime
from pathlib import Path
from typing import Dict, Any
from dotenv import load_dotenv
load_dotenv()
_DB_TYPE = os.getenv("DB_TYPE", "sqlite").lower()
_MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
_MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306"))
_MYSQL_USER = os.getenv("MYSQL_USER", "root")
_MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "")
_MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "ai_cs")
def _is_mysql() -> bool:
return _DB_TYPE in ("mysql", "mariadb")
class CustomerRiskDB:
def __init__(self, sqlite_path: str = "db/customer_risk_db/risk.db"):
self.sqlite_path = Path(sqlite_path)
self.backend = "mysql" if _is_mysql() else "sqlite"
self._sqlite_in_memory = False
try:
self._ensure_db()
except Exception:
# MySQL 不可用时自动回退,避免主流程被数据库连接拖垮
self.backend = "sqlite"
try:
self._ensure_sqlite_db()
except Exception:
# 最后兜底:内存 SQLite保证模块可导入
self._sqlite_in_memory = True
self._ensure_sqlite_db()
def _get_mysql_conn(self):
import pymysql
return pymysql.connect(
host=_MYSQL_HOST,
port=_MYSQL_PORT,
user=_MYSQL_USER,
password=_MYSQL_PASSWORD,
database=_MYSQL_DATABASE,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=False,
)
def _get_sqlite_conn(self):
if self._sqlite_in_memory:
conn = sqlite3.connect(":memory:")
else:
self.sqlite_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(self.sqlite_path))
conn.row_factory = sqlite3.Row
return conn
def _ensure_db(self):
if self.backend == "mysql":
with self._get_mysql_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS customer_risk_profile (
customer_id VARCHAR(128) PRIMARY KEY,
do_not_serve TINYINT(1) NOT NULL DEFAULT 0,
risk_level VARCHAR(16) NOT NULL DEFAULT 'low',
risk_score INT NOT NULL DEFAULT 0,
note TEXT,
tags_json TEXT,
updated_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS customer_risk_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
customer_id VARCHAR(128) NOT NULL,
event_type VARCHAR(32) NOT NULL,
event_count INT NOT NULL DEFAULT 1,
note TEXT,
created_at DATETIME NOT NULL,
INDEX idx_customer_time (customer_id, created_at),
INDEX idx_event_type (event_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
)
conn.commit()
return
self._ensure_sqlite_db()
def _ensure_sqlite_db(self):
with self._get_sqlite_conn() as conn:
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS customer_risk_profile (
customer_id TEXT PRIMARY KEY,
do_not_serve INTEGER NOT NULL DEFAULT 0,
risk_level TEXT NOT NULL DEFAULT 'low',
risk_score INTEGER NOT NULL DEFAULT 0,
note TEXT,
tags_json TEXT,
updated_at TEXT NOT NULL
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS customer_risk_event (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id TEXT NOT NULL,
event_type TEXT NOT NULL,
event_count INTEGER NOT NULL DEFAULT 1,
note TEXT,
created_at TEXT NOT NULL
)
"""
)
cur.execute("CREATE INDEX IF NOT EXISTS idx_customer_time ON customer_risk_event(customer_id, created_at)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_event_type ON customer_risk_event(event_type)")
conn.commit()
def record_event(self, customer_id: str, event_type: str, event_count: int = 1, note: str = ""):
if not customer_id or not event_type:
return
now = datetime.now()
if self.backend == "mysql":
with self._get_mysql_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO customer_risk_event (customer_id, event_type, event_count, note, created_at)
VALUES (%s, %s, %s, %s, %s)
""",
(customer_id, event_type, int(max(1, event_count)), note, now.strftime("%Y-%m-%d %H:%M:%S")),
)
conn.commit()
return
with self._get_sqlite_conn() as conn:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO customer_risk_event (customer_id, event_type, event_count, note, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(customer_id, event_type, int(max(1, event_count)), note, now.isoformat()),
)
conn.commit()
def set_profile(
self,
customer_id: str,
*,
do_not_serve: bool = False,
risk_level: str = "low",
risk_score: int = 0,
note: str = "",
tags: list | None = None,
):
if not customer_id:
return
tags_json = json.dumps(tags or [], ensure_ascii=False)
now = datetime.now()
if self.backend == "mysql":
with self._get_mysql_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
REPLACE INTO customer_risk_profile
(customer_id, do_not_serve, risk_level, risk_score, note, tags_json, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
customer_id,
1 if do_not_serve else 0,
risk_level,
int(max(0, risk_score)),
note,
tags_json,
now.strftime("%Y-%m-%d %H:%M:%S"),
),
)
conn.commit()
return
with self._get_sqlite_conn() as conn:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO customer_risk_profile
(customer_id, do_not_serve, risk_level, risk_score, note, tags_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(customer_id) DO UPDATE SET
do_not_serve=excluded.do_not_serve,
risk_level=excluded.risk_level,
risk_score=excluded.risk_score,
note=excluded.note,
tags_json=excluded.tags_json,
updated_at=excluded.updated_at
""",
(
customer_id,
1 if do_not_serve else 0,
risk_level,
int(max(0, risk_score)),
note,
tags_json,
now.isoformat(),
),
)
conn.commit()
def _sum_events(self, customer_id: str, event_type: str, days: int) -> int:
if self.backend == "mysql":
with self._get_mysql_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT COALESCE(SUM(event_count), 0) AS total
FROM customer_risk_event
WHERE customer_id=%s
AND event_type=%s
AND created_at >= (NOW() - INTERVAL %s DAY)
""",
(customer_id, event_type, int(max(1, days))),
)
row = cur.fetchone() or {}
return int(row.get("total") or 0)
with self._get_sqlite_conn() as conn:
cur = conn.cursor()
cur.execute(
"""
SELECT COALESCE(SUM(event_count), 0) AS total
FROM customer_risk_event
WHERE customer_id=?
AND event_type=?
AND created_at >= datetime('now', ?)
""",
(customer_id, event_type, f"-{int(max(1, days))} day"),
)
row = cur.fetchone()
return int((row["total"] if row else 0) or 0)
def get_profile(self, customer_id: str) -> Dict[str, Any]:
out = {
"customer_id": customer_id,
"do_not_serve": False,
"risk_level": "low",
"risk_score": 0,
"note": "",
"tags": [],
}
if self.backend == "mysql":
with self._get_mysql_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT customer_id, do_not_serve, risk_level, risk_score, note, tags_json
FROM customer_risk_profile
WHERE customer_id=%s
LIMIT 1
""",
(customer_id,),
)
row = cur.fetchone()
if not row:
return out
out.update(
{
"do_not_serve": bool(row.get("do_not_serve")),
"risk_level": str(row.get("risk_level") or "low"),
"risk_score": int(row.get("risk_score") or 0),
"note": str(row.get("note") or ""),
"tags": json.loads(row.get("tags_json") or "[]"),
}
)
return out
with self._get_sqlite_conn() as conn:
cur = conn.cursor()
cur.execute(
"""
SELECT customer_id, do_not_serve, risk_level, risk_score, note, tags_json
FROM customer_risk_profile
WHERE customer_id=?
LIMIT 1
""",
(customer_id,),
)
row = cur.fetchone()
if not row:
return out
out.update(
{
"do_not_serve": bool(row["do_not_serve"]),
"risk_level": str(row["risk_level"] or "low"),
"risk_score": int(row["risk_score"] or 0),
"note": str(row["note"] or ""),
"tags": json.loads(row["tags_json"] or "[]"),
}
)
return out
def evaluate_customer(self, customer_id: str) -> Dict[str, Any]:
profile = self.get_profile(customer_id)
refund_30d = self._sum_events(customer_id, "refund", 30)
unpaid_7d = self._sum_events(customer_id, "unpaid_order", 7)
bad_review_90d = self._sum_events(customer_id, "bad_review", 90)
score = int(profile.get("risk_score") or 0)
score += refund_30d * 20
score += unpaid_7d * 8
score += bad_review_90d * 15
level = "low"
if score >= 70:
level = "high"
elif score >= 35:
level = "medium"
return {
**profile,
"refund_30d": refund_30d,
"unpaid_7d": unpaid_7d,
"bad_review_90d": bad_review_90d,
"computed_score": score,
"computed_level": level,
}
risk_db = CustomerRiskDB()