refactor: migrate workflow to v2 core and archive legacy modules

This commit is contained in:
2026-03-04 21:52:24 +08:00
parent e1ce17f2aa
commit fa61b11b02
156 changed files with 1781 additions and 2066 deletions

View File

@@ -199,14 +199,18 @@ def get_customers(limit: int = 100) -> List[Dict]:
def get_conversation(customer_id: str, limit: int = 200) -> List[Dict]:
"""返回某客户的全部对话记录(按时间升序)"""
"""返回某客户的最近对话记录(按时间升序)"""
with _get_conn() as conn:
# 核心修复:先取最新的 limit 条,再按时间正序排列
rows = conn.execute(_sql("""
SELECT id, direction, message, msg_type, timestamp, acc_id
FROM chat_logs
WHERE customer_id = ?
SELECT * FROM (
SELECT id, direction, message, msg_type, timestamp, acc_id
FROM chat_logs
WHERE customer_id = ?
ORDER BY timestamp DESC, id DESC
LIMIT ?
) AS recent
ORDER BY timestamp ASC, id ASC
LIMIT ?
"""), (customer_id, limit)).fetchall()
return [dict(r) for r in rows]

Binary file not shown.

View File

@@ -1,336 +0,0 @@
"""客户风控数据库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()

View File

@@ -1,246 +0,0 @@
# -*- coding: utf-8 -*-
"""
成交/未成交记录 - 用于日报与数据分析
"""
import sqlite3
import os
from datetime import datetime
from typing import List, Dict, Optional
_DB_PATH = os.path.join(os.path.dirname(__file__), "deal_outcome_db", "outcomes.db")
_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")
class _CompatResult:
def __init__(self, rows=None, rowcount: int = 0, lastrowid: int = 0):
self._rows = rows or []
self.rowcount = rowcount
self.lastrowid = lastrowid
def fetchall(self):
return self._rows
def fetchone(self):
return self._rows[0] if self._rows else None
class _PyMySQLCompatConn:
def __init__(self, conn):
self._conn = conn
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
if exc_type:
try:
self._conn.rollback()
except Exception:
pass
self._conn.close()
def execute(self, query: str, args=None):
cur = self._conn.cursor()
cur.execute(query, args or ())
rows = cur.fetchall() if cur.description else []
res = _CompatResult(rows=rows, rowcount=cur.rowcount, lastrowid=getattr(cur, "lastrowid", 0))
cur.close()
return res
def commit(self):
self._conn.commit()
def _is_mysql() -> bool:
return _DB_TYPE in ("mysql", "mariadb")
def _sql(query: str) -> str:
return query.replace("?", "%s") if _is_mysql() else query
def _get_conn() -> sqlite3.Connection:
if _is_mysql():
import pymysql
conn = 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,
)
return _PyMySQLCompatConn(conn)
os.makedirs(os.path.dirname(_DB_PATH), exist_ok=True)
conn = sqlite3.connect(_DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def _init_db():
with _get_conn() as conn:
if _is_mysql():
conn.execute("""
CREATE TABLE IF NOT EXISTS deal_outcomes (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
customer_id VARCHAR(128) NOT NULL,
customer_name VARCHAR(255) DEFAULT '',
acc_id VARCHAR(128) DEFAULT '',
platform VARCHAR(64) DEFAULT '',
date DATE NOT NULL,
outcome VARCHAR(16) NOT NULL,
reason TEXT,
order_id VARCHAR(128) DEFAULT '',
amount REAL DEFAULT 0,
discount_given INTEGER DEFAULT 0,
timestamp DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
idx_rows = conn.execute("SHOW INDEX FROM deal_outcomes").fetchall()
exists = {str(r.get("Key_name", "")) for r in idx_rows}
if "idx_deal_date" not in exists:
conn.execute("CREATE INDEX idx_deal_date ON deal_outcomes(date)")
if "idx_deal_customer" not in exists:
conn.execute("CREATE INDEX idx_deal_customer ON deal_outcomes(customer_id)")
if "idx_deal_acc" not in exists:
conn.execute("CREATE INDEX idx_deal_acc ON deal_outcomes(acc_id)")
if "idx_deal_outcome" not in exists:
conn.execute("CREATE INDEX idx_deal_outcome ON deal_outcomes(outcome)")
else:
conn.execute("""
CREATE TABLE IF NOT EXISTS deal_outcomes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id TEXT NOT NULL,
customer_name TEXT DEFAULT '',
acc_id TEXT DEFAULT '',
platform TEXT DEFAULT '',
date TEXT NOT NULL,
outcome TEXT NOT NULL CHECK(outcome IN ('成交','未成交')),
reason TEXT DEFAULT '',
order_id TEXT DEFAULT '',
amount REAL DEFAULT 0,
discount_given INTEGER DEFAULT 0,
timestamp TEXT NOT NULL
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_deal_date ON deal_outcomes(date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_deal_customer ON deal_outcomes(customer_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_deal_acc ON deal_outcomes(acc_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_deal_outcome ON deal_outcomes(outcome)")
conn.commit()
_init_db()
def record_deal(
customer_id: str,
outcome: str,
reason: str = "",
customer_name: str = "",
acc_id: str = "",
platform: str = "",
order_id: str = "",
amount: float = 0,
discount_given: bool = False,
):
"""记录一笔成交或未成交"""
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
date = datetime.now().strftime("%Y-%m-%d")
with _get_conn() as conn:
conn.execute(
_sql("""INSERT INTO deal_outcomes
(customer_id, customer_name, acc_id, platform, date, outcome, reason,
order_id, amount, discount_given, timestamp)
VALUES (?,?,?,?,?,?,?,?,?,?,?)"""),
(
customer_id,
customer_name or "",
acc_id or "",
platform or "",
date,
outcome,
reason or "",
order_id or "",
amount,
1 if discount_given else 0,
ts,
),
)
conn.commit()
def get_daily_outcomes(date: str = "") -> List[Dict]:
"""获取指定日期的成交/未成交记录,用于日报"""
if not date:
date = datetime.now().strftime("%Y-%m-%d")
with _get_conn() as conn:
rows = conn.execute(
_sql("""
SELECT customer_id, customer_name, acc_id, outcome, reason,
order_id, amount, discount_given, timestamp
FROM deal_outcomes
WHERE date = ?
ORDER BY timestamp ASC
"""),
(date,),
).fetchall()
return [dict(r) for r in rows]
def get_daily_summary(date: str = "") -> Dict:
"""获取指定日期的成交/未成交汇总统计"""
outcomes = get_daily_outcomes(date)
success = [o for o in outcomes if o["outcome"] == "成交"]
fail = [o for o in outcomes if o["outcome"] == "未成交"]
# 按原因分组
fail_by_reason: Dict[str, int] = {}
for o in fail:
r = o.get("reason") or "其他"
fail_by_reason[r] = fail_by_reason.get(r, 0) + 1
return {
"date": date or datetime.now().strftime("%Y-%m-%d"),
"成交数": len(success),
"未成交数": len(fail),
"成交金额": sum(o.get("amount") or 0 for o in success),
"成交明细": success,
"未成交明细": fail,
"未成交原因分布": fail_by_reason,
}
def export_for_analysis(start_date: str = "", end_date: str = "") -> List[Dict]:
"""
导出成交/未成交记录,供数据库分析。
日期格式 YYYY-MM-DD留空则查全部。
"""
with _get_conn() as conn:
if start_date and end_date:
rows = conn.execute(
_sql("""SELECT * FROM deal_outcomes
WHERE date BETWEEN ? AND ?
ORDER BY date, timestamp"""),
(start_date, end_date),
).fetchall()
elif start_date:
rows = conn.execute(
_sql("""SELECT * FROM deal_outcomes WHERE date >= ? ORDER BY date, timestamp"""),
(start_date,),
).fetchall()
elif end_date:
rows = conn.execute(
_sql("""SELECT * FROM deal_outcomes WHERE date <= ? ORDER BY date, timestamp"""),
(end_date,),
).fetchall()
else:
rows = conn.execute(
"""SELECT * FROM deal_outcomes ORDER BY date, timestamp"""
).fetchall()
return [dict(r) for r in rows]

View File

@@ -1,279 +0,0 @@
# -*- coding: utf-8 -*-
"""
设计师派单数据库SQLite
同一设计师在不同店铺对应不同 group_id派单时从在线设计师中轮询。
企微群「上线」/「下线」通过 update_online(wechat_user_id, is_online) 更新。
"""
import sqlite3
import os
from typing import Optional
_DB_PATH = os.path.join(os.path.dirname(__file__), "designer_roster_db", "roster.db")
_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")
class _CompatResult:
def __init__(self, rows=None, rowcount: int = 0, lastrowid: int = 0):
self._rows = rows or []
self.rowcount = rowcount
self.lastrowid = lastrowid
def fetchall(self):
return self._rows
def fetchone(self):
return self._rows[0] if self._rows else None
class _PyMySQLCompatConn:
def __init__(self, conn):
self._conn = conn
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
if exc_type:
try:
self._conn.rollback()
except Exception:
pass
self._conn.close()
def execute(self, query: str, args=None):
cur = self._conn.cursor()
cur.execute(query, args or ())
rows = cur.fetchall() if cur.description else []
res = _CompatResult(rows=rows, rowcount=cur.rowcount, lastrowid=getattr(cur, "lastrowid", 0))
cur.close()
return res
def commit(self):
self._conn.commit()
def _is_mysql() -> bool:
return _DB_TYPE in ("mysql", "mariadb")
def _sql(query: str) -> str:
return query.replace("?", "%s") if _is_mysql() else query
def _get_conn() -> sqlite3.Connection:
if _is_mysql():
import pymysql
conn = 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,
)
return _PyMySQLCompatConn(conn)
os.makedirs(os.path.dirname(_DB_PATH), exist_ok=True)
conn = sqlite3.connect(_DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
with _get_conn() as conn:
if _is_mysql():
conn.execute("""
CREATE TABLE IF NOT EXISTS designers (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
wechat_user_id VARCHAR(128) UNIQUE NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS designer_shops (
designer_id INTEGER NOT NULL,
shop_id VARCHAR(128) NOT NULL,
group_id VARCHAR(128) NOT NULL,
PRIMARY KEY (designer_id, shop_id),
FOREIGN KEY (designer_id) REFERENCES designers(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS designer_online (
wechat_user_id VARCHAR(128) PRIMARY KEY,
is_online INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS round_robin (
shop_id VARCHAR(128) PRIMARY KEY,
last_index INTEGER NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
else:
conn.execute("""
CREATE TABLE IF NOT EXISTS designers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
wechat_user_id TEXT UNIQUE NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS designer_shops (
designer_id INTEGER NOT NULL,
shop_id TEXT NOT NULL,
group_id TEXT NOT NULL,
PRIMARY KEY (designer_id, shop_id),
FOREIGN KEY (designer_id) REFERENCES designers(id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS designer_online (
wechat_user_id TEXT PRIMARY KEY,
is_online INTEGER NOT NULL DEFAULT 0,
updated_at TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS round_robin (
shop_id TEXT PRIMARY KEY,
last_index INTEGER NOT NULL DEFAULT 0
)
""")
conn.commit()
init_db()
# ========== 设计师管理 ==========
def add_designer(name: str, wechat_user_id: str) -> int:
"""添加设计师,返回 id"""
with _get_conn() as conn:
if _is_mysql():
conn.execute(
"INSERT IGNORE INTO designers (name, wechat_user_id) VALUES (%s, %s)",
(name, wechat_user_id),
)
else:
conn.execute(
"INSERT OR IGNORE INTO designers (name, wechat_user_id) VALUES (?, ?)",
(name, wechat_user_id),
)
conn.commit()
row = conn.execute(_sql("SELECT id FROM designers WHERE wechat_user_id = ?"), (wechat_user_id,)).fetchone()
return row["id"] if row else 0
def set_designer_shop(designer_id: int, shop_id: str, group_id: str):
"""设置设计师在某店铺的分组 ID同一设计师不同店铺不同 group_id"""
with _get_conn() as conn:
if _is_mysql():
conn.execute(
"REPLACE INTO designer_shops (designer_id, shop_id, group_id) VALUES (%s, %s, %s)",
(designer_id, shop_id, group_id),
)
else:
conn.execute(
"INSERT OR REPLACE INTO designer_shops (designer_id, shop_id, group_id) VALUES (?, ?, ?)",
(designer_id, shop_id, group_id),
)
conn.commit()
def update_online(wechat_user_id: str, is_online: bool):
"""更新设计师在线状态(企微群「上线」/「下线」解析后调用)"""
from datetime import datetime
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with _get_conn() as conn:
if _is_mysql():
conn.execute(
"REPLACE INTO designer_online (wechat_user_id, is_online, updated_at) VALUES (%s, %s, %s)",
(wechat_user_id, 1 if is_online else 0, ts),
)
else:
conn.execute(
"INSERT OR REPLACE INTO designer_online (wechat_user_id, is_online, updated_at) VALUES (?, ?, ?)",
(wechat_user_id, 1 if is_online else 0, ts),
)
conn.commit()
# ========== 派单 ==========
def get_transfer_group_for_shop(shop_id: str) -> Optional[str]:
"""
为店铺轮询派单,返回分组 ID。
从该店铺的在线设计师中轮询选一个,返回其在该店铺的 group_id。
无人在线则返回 None。
"""
with _get_conn() as conn:
rows = conn.execute(_sql("""
SELECT d.wechat_user_id, ds.group_id
FROM designer_shops ds
JOIN designers d ON d.id = ds.designer_id
JOIN designer_online o ON o.wechat_user_id = d.wechat_user_id AND o.is_online = 1
WHERE ds.shop_id = ?
"""), (shop_id,)).fetchall()
if not rows:
return None
with _get_conn() as conn:
rr = conn.execute(_sql("SELECT last_index FROM round_robin WHERE shop_id = ?"), (shop_id,)).fetchone()
last = rr["last_index"] if rr else 0
idx = last % len(rows)
chosen = rows[idx]
if _is_mysql():
conn.execute(
"REPLACE INTO round_robin (shop_id, last_index) VALUES (%s, %s)",
(shop_id, idx + 1),
)
else:
conn.execute(
"INSERT OR REPLACE INTO round_robin (shop_id, last_index) VALUES (?, ?)",
(shop_id, idx + 1),
)
conn.commit()
return chosen["group_id"]
# ========== 查询 ==========
def get_all_wechat_user_ids() -> list:
"""获取所有设计师的 wechat_user_id用于同步在线状态"""
with _get_conn() as conn:
rows = conn.execute("SELECT wechat_user_id FROM designers").fetchall()
return [r["wechat_user_id"] for r in rows]
def list_designers():
"""列出所有设计师及其店铺分组"""
with _get_conn() as conn:
designers = conn.execute("SELECT id, name, wechat_user_id FROM designers").fetchall()
result = []
for d in designers:
shops = conn.execute(
_sql("SELECT shop_id, group_id FROM designer_shops WHERE designer_id = ?"),
(d["id"],),
).fetchall()
online = conn.execute(
_sql("SELECT is_online FROM designer_online WHERE wechat_user_id = ?"),
(d["wechat_user_id"],),
).fetchone()
result.append({
"id": d["id"],
"name": d["name"],
"wechat_user_id": d["wechat_user_id"],
"shops": {s["shop_id"]: s["group_id"] for s in shops},
"is_online": bool(online and online["is_online"]),
})
return result

BIN
db/image_tasks.db Normal file

Binary file not shown.

View File

@@ -1,480 +1,113 @@
# -*- coding: utf-8 -*-
"""
图片任务数据库管理
支持客户后续增加需求细节
"""
import sqlite3
import json
import logging
import uuid
import os
from typing import Optional, List, Dict
from pathlib import Path
from datetime import datetime
from enum import Enum
import os
logger = logging.getLogger(__name__)
_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")
def _sql(query: str) -> str:
return query.replace("?", "%s") if _is_mysql() else query
def _now_str() -> str:
if _is_mysql():
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return datetime.now().isoformat()
class TaskStatus(Enum):
"""任务状态"""
PENDING = "pending" # 待付款
PAID = "paid" # 已付款,待处理
PROCESSING = "processing" # 处理中
AWAITING_CONFIRM = "awaiting_confirm" # 已完成,待客户确认
COMPLETED = "completed" # 已完成
FAILED = "failed" # 失败
CANCELLED = "cancelled" # 已取消
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
class ImageTaskManager:
"""图片任务管理器"""
def __init__(self, db_path: str = None):
if db_path is None:
db_path = Path(__file__).parent / "image_tasks.db"
self.db_path = db_path
self._init_db()
logger.info(f"图片任务管理器初始化完成,数据库:{self.db_path}")
def _init_db(self):
"""初始化数据库"""
if _is_mysql():
conn = self._get_conn()
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS image_tasks (
task_id VARCHAR(128) PRIMARY KEY,
customer_id VARCHAR(128) NOT NULL,
customer_name VARCHAR(255),
original_image TEXT NOT NULL,
operation VARCHAR(64) DEFAULT 'enhance',
requirements TEXT,
customer_notes TEXT,
status VARCHAR(32) DEFAULT 'pending',
created_at DATETIME,
paid_at DATETIME,
started_at DATETIME,
completed_at DATETIME,
result_image TEXT,
error_message TEXT,
retry_count INT DEFAULT 0,
acc_id VARCHAR(128),
acc_type VARCHAR(64) DEFAULT 'AliWorkbench'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS task_requirement_changes (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
task_id VARCHAR(128) NOT NULL,
change_type VARCHAR(64),
old_value TEXT,
new_value TEXT,
changed_at DATETIME,
changed_by VARCHAR(32),
FOREIGN KEY (task_id) REFERENCES image_tasks(task_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
''')
cursor.execute("SHOW INDEX FROM image_tasks")
exists = {str(r.get("Key_name", "")) for r in cursor.fetchall()}
if "idx_customer" not in exists:
cursor.execute('CREATE INDEX idx_customer ON image_tasks(customer_id)')
if "idx_status" not in exists:
cursor.execute('CREATE INDEX idx_status ON image_tasks(status)')
if "idx_created" not in exists:
cursor.execute('CREATE INDEX idx_created ON image_tasks(created_at)')
conn.commit()
conn.close()
else:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS image_tasks (
task_id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
customer_name TEXT,
original_image TEXT NOT NULL,
operation TEXT DEFAULT 'enhance',
requirements TEXT,
customer_notes TEXT,
status TEXT DEFAULT 'pending',
created_at TEXT,
paid_at TEXT,
started_at TEXT,
completed_at TEXT,
result_image TEXT,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
acc_id TEXT,
acc_type TEXT DEFAULT 'AliWorkbench'
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS task_requirement_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
change_type TEXT,
old_value TEXT,
new_value TEXT,
changed_at TEXT,
changed_by TEXT,
FOREIGN KEY (task_id) REFERENCES image_tasks(task_id)
)
''')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_customer ON image_tasks(customer_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_status ON image_tasks(status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_created ON image_tasks(created_at)')
conn.commit()
conn.close()
logger.info("数据库表初始化完成")
def _get_conn(self):
"""获取数据库连接"""
if _is_mysql():
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,
self.db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
# 核心任务表
cursor.execute('''
CREATE TABLE IF NOT EXISTS image_tasks (
task_id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
platform TEXT DEFAULT 'qianniu',
original_image TEXT NOT NULL,
operation TEXT DEFAULT 'enhance',
requirements TEXT,
status TEXT DEFAULT 'pending',
result_image TEXT,
price REAL DEFAULT 0.0,
outcome TEXT DEFAULT 'pending',
created_at TEXT,
updated_at TEXT
)
''')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_status ON image_tasks(status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_cust_plat ON image_tasks(customer_id, platform)')
conn.commit()
conn.close()
def _get_conn(self):
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def create_task(self, task_id: str, customer_id: str, customer_name: str,
original_image: str, operation: str = 'enhance',
requirements: dict = None, acc_id: str = '', acc_type: str = 'AliWorkbench') -> bool:
"""创建图片任务"""
def add_task(self, customer_id: str, platform: str, original_image: str, operation: str, requirements: str = "", status: str = "pending") -> str:
task_id = str(uuid.uuid4())
now = datetime.now().isoformat()
try:
conn = self._get_conn()
cursor = conn.cursor()
requirements_json = json.dumps(requirements) if requirements else None
cursor.execute(_sql('''
INSERT INTO image_tasks (
task_id, customer_id, customer_name, original_image,
operation, requirements, customer_notes, status,
created_at, acc_id, acc_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
'''), (
task_id,
customer_id,
customer_name,
original_image,
operation,
requirements_json,
'', # 初始备注为空
TaskStatus.PENDING.value,
_now_str(),
acc_id,
acc_type
))
cursor.execute('''
INSERT INTO image_tasks (task_id, customer_id, platform, original_image, operation, requirements, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (task_id, customer_id, platform, original_image, operation, requirements, status, now, now))
conn.commit()
conn.close()
logger.info(f"图片任务创建成功:{task_id}")
return True
return task_id
except Exception as e:
logger.error(f"创建图片任务失败:{e}")
return False
def get_task(self, task_id: str) -> Optional[dict]:
"""查询任务"""
logger.error(f"Failed to add task: {e}")
return ""
def update_status(self, task_id: str, status: str, result_image: str = ""):
now = datetime.now().isoformat()
try:
conn = self._get_conn()
cursor = conn.cursor()
cursor.execute(_sql('SELECT * FROM image_tasks WHERE task_id = ?'), (task_id,))
row = cursor.fetchone()
conn.close()
if row:
task = dict(row)
# 解析 JSON 字段
if task.get('requirements'):
task['requirements'] = json.loads(task['requirements'])
return task
return None
except Exception as e:
logger.error(f"查询任务失败:{e}")
return None
def get_customer_tasks(self, customer_id: str, status: str = None) -> List[dict]:
"""查询客户的任务列表"""
try:
conn = self._get_conn()
cursor = conn.cursor()
if status:
cursor.execute(_sql('''
SELECT * FROM image_tasks
WHERE customer_id = ? AND status = ?
ORDER BY created_at DESC
'''), (customer_id, status))
if result_image:
cursor.execute('UPDATE image_tasks SET status = ?, result_image = ?, updated_at = ? WHERE task_id = ?',
(status, result_image, now, task_id))
else:
cursor.execute(_sql('''
SELECT * FROM image_tasks
WHERE customer_id = ?
ORDER BY created_at DESC
'''), (customer_id,))
rows = cursor.fetchall()
conn.close()
tasks = []
for row in rows:
task = dict(row)
if task.get('requirements'):
task['requirements'] = json.loads(task['requirements'])
tasks.append(task)
return tasks
except Exception as e:
logger.error(f"查询客户任务失败:{e}")
return []
def update_status(self, task_id: str, status: TaskStatus):
"""更新任务状态"""
try:
conn = self._get_conn()
cursor = conn.cursor()
placeholder = "%s" if _is_mysql() else "?"
updates = [f'status = {placeholder}']
params = [status.value]
# 根据状态设置时间
if status == TaskStatus.PAID:
updates.append(f'paid_at = {placeholder}')
params.append(_now_str())
elif status == TaskStatus.PROCESSING:
updates.append(f'started_at = {placeholder}')
params.append(_now_str())
elif status in [TaskStatus.COMPLETED, TaskStatus.FAILED]:
updates.append(f'completed_at = {placeholder}')
params.append(_now_str())
params.append(task_id)
cursor.execute(_sql(f'''
UPDATE image_tasks
SET {', '.join(updates)}
WHERE task_id = ?
'''), params)
cursor.execute('UPDATE image_tasks SET status = ?, updated_at = ? WHERE task_id = ?',
(status, now, task_id))
conn.commit()
conn.close()
logger.info(f"任务状态更新:{task_id} -> {status.value}")
except Exception as e:
logger.error(f"更新任务状态失败:{e}")
def update_result(self, task_id: str, result_image: str, error_message: str = None):
"""更新处理结果"""
logger.error(f"Failed to update task status: {e}")
def update_outcome(self, customer_id: str, platform: str, outcome: str):
"""记录任务的最终结局(用于训练样本分类)"""
now = datetime.now().isoformat()
try:
conn = self._get_conn()
cursor = conn.cursor()
cursor.execute(_sql('''
cursor.execute('''
UPDATE image_tasks
SET result_image = ?, error_message = ?
WHERE task_id = ?
'''), (result_image, error_message, task_id))
SET outcome = ?, updated_at = ?
WHERE task_id = (
SELECT task_id FROM image_tasks
WHERE customer_id = ? AND platform = ?
ORDER BY created_at DESC LIMIT 1
)
''', (outcome, now, customer_id, platform))
conn.commit()
conn.close()
logger.info(f"任务结果更新:{task_id}")
logger.info(f"[DB] 客户 {customer_id} 任务结局更新为: {outcome}")
except Exception as e:
logger.error(f"更新任务结果失败:{e}")
def add_customer_note(self, task_id: str, note: str, changed_by: str = 'customer') -> bool:
"""
客户添加需求备注(支持后续增加细节)
Args:
task_id: 任务 ID
note: 备注内容
changed_by: 修改者customer/staff
Returns:
bool: 是否成功
"""
try:
conn = self._get_conn()
cursor = conn.cursor()
# 获取旧备注
cursor.execute(_sql('SELECT customer_notes FROM image_tasks WHERE task_id = ?'), (task_id,))
row = cursor.fetchone()
old_note = row['customer_notes'] if row else ''
# 更新备注
new_note = f"{old_note}\n[{datetime.now().strftime('%m-%d %H:%M')}] {note}" if old_note else f"[{datetime.now().strftime('%m-%d %H:%M')}] {note}"
cursor.execute(_sql('''
UPDATE image_tasks
SET customer_notes = ?
WHERE task_id = ?
'''), (new_note, task_id))
# 记录变更历史
cursor.execute(_sql('''
INSERT INTO task_requirement_changes (
task_id, change_type, old_value, new_value, changed_at, changed_by
) VALUES (?, ?, ?, ?, ?, ?)
'''), (
task_id,
'add_note',
old_note or '',
note,
_now_str(),
changed_by
))
conn.commit()
conn.close()
logger.info(f"客户添加备注成功:{task_id}")
return True
except Exception as e:
logger.error(f"添加客户备注失败:{e}")
return False
def modify_operation(self, task_id: str, new_operation: str, changed_by: str = 'customer') -> bool:
"""
修改操作类型(客户后续修改需求)
Args:
task_id: 任务 ID
new_operation: 新操作类型
changed_by: 修改者
Returns:
bool: 是否成功
"""
try:
conn = self._get_conn()
cursor = conn.cursor()
# 获取旧操作
cursor.execute(_sql('SELECT operation FROM image_tasks WHERE task_id = ?'), (task_id,))
row = cursor.fetchone()
old_operation = row['operation'] if row else ''
# 更新操作
cursor.execute(_sql('''
UPDATE image_tasks
SET operation = ?
WHERE task_id = ?
'''), (new_operation, task_id))
# 记录变更历史
cursor.execute(_sql('''
INSERT INTO task_requirement_changes (
task_id, change_type, old_value, new_value, changed_at, changed_by
) VALUES (?, ?, ?, ?, ?, ?)
'''), (
task_id,
'modify_operation',
old_operation,
new_operation,
_now_str(),
changed_by
))
conn.commit()
conn.close()
logger.info(f"修改操作类型成功:{task_id} -> {new_operation}")
return True
except Exception as e:
logger.error(f"修改操作类型失败:{e}")
return False
def get_requirement_history(self, task_id: str) -> List[dict]:
"""获取需求变更历史"""
try:
conn = self._get_conn()
cursor = conn.cursor()
cursor.execute(_sql('''
SELECT * FROM task_requirement_changes
WHERE task_id = ?
ORDER BY changed_at DESC
'''), (task_id,))
rows = cursor.fetchall()
conn.close()
return [dict(row) for row in rows]
except Exception as e:
logger.error(f"查询需求历史失败:{e}")
return []
def get_pending_tasks(self) -> List[dict]:
"""获取所有待处理任务"""
return self.get_customer_tasks('', 'pending')
def increment_retry(self, task_id: str) -> int:
"""增加重试次数"""
try:
conn = self._get_conn()
cursor = conn.cursor()
cursor.execute(_sql('''
UPDATE image_tasks
SET retry_count = retry_count + 1
WHERE task_id = ?
'''), (task_id,))
cursor.execute(_sql('SELECT retry_count FROM image_tasks WHERE task_id = ?'), (task_id,))
row = cursor.fetchone()
conn.close()
return row['retry_count'] if row else 0
except Exception as e:
logger.error(f"增加重试次数失败:{e}")
return 999
logger.error(f"Failed to update outcome: {e}")
# 单例
_task_manager: Optional[ImageTaskManager] = None
def get_image_task_manager() -> ImageTaskManager:
"""获取图片任务管理器单例"""
global _task_manager
if _task_manager is None:
_task_manager = ImageTaskManager()
return _task_manager
db = ImageTaskManager()