Files
tw/db/customer_risk_db.py

337 lines
12 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.
"""客户风控数据库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()