feat(黑名单): 新增管理功能

This commit is contained in:
GuDong
2026-06-03 14:23:49 +08:00
parent a1c5c65712
commit f87dc1f15f
6 changed files with 1512 additions and 2 deletions
+137
View File
@@ -3359,6 +3359,81 @@ class XianyuLive:
delay_task = asyncio.create_task(self._delayed_lock_release(lock_key, delay_minutes=delay_minutes))
self._lock_hold_info[lock_key]['task'] = delay_task
def _resolve_blacklist_user_id(self) -> Optional[int]:
"""获取当前账号归属用户,用于黑名单隔离。"""
if self.user_id:
try:
return int(self.user_id)
except (TypeError, ValueError):
pass
try:
cookie_details = db_manager.get_cookie_details(self.cookie_id) or {}
user_id = cookie_details.get('user_id')
return int(user_id) if user_id else None
except Exception as e:
logger.warning(f"{self.cookie_id}】解析黑名单用户归属失败: {self._safe_str(e)}")
return None
def _format_blacklist_block_reason(self, hit: Dict[str, Any], action: str = '自动动作') -> str:
scope_label = {
'item': '商品级',
'account': '账号级',
'user': '用户级',
}.get((hit or {}).get('scope'), (hit or {}).get('scope') or '未知级别')
reason = str((hit or {}).get('reason') or '').strip()
reason_part = f",原因:{reason}" if reason else ''
buyer_id = (hit or {}).get('buyer_id') or '未知买家'
return f"买家 {buyer_id} 命中个人黑名单 scope={scope_label}{reason_part},跳过{action}"
def _check_buyer_blacklist_for_action(self, buyer_id: str = None, item_id: str = None,
order_id: str = None, buyer_nick: str = None,
action: str = '自动动作', channel: str = 'auto',
log_delivery: bool = False) -> Optional[Dict[str, Any]]:
"""检查买家黑名单,命中时可记录发货跳过日志。"""
normalized_buyer_id = str(buyer_id or '').strip()
if not normalized_buyer_id:
return None
try:
user_id = self._resolve_blacklist_user_id()
if not user_id:
return None
hit = db_manager.is_buyer_blacklisted(
user_id=user_id,
buyer_id=normalized_buyer_id,
cookie_id=self.cookie_id,
item_id=str(item_id or '').strip() or None,
)
if not hit:
return None
block_reason = self._format_blacklist_block_reason(hit, action=action)
logger.warning(
f"{self.cookie_id}】买家 {normalized_buyer_id} 命中个人黑名单,"
f"scope={hit.get('scope')}, item_id={item_id or ''}, order_id={order_id or ''},跳过{action}"
)
if log_delivery:
self._record_delivery_log(
order_id=order_id,
item_id=item_id,
buyer_id=normalized_buyer_id,
buyer_nick=buyer_nick,
status='skipped',
reason=block_reason,
channel=channel,
rule_meta={'match_mode': 'blacklist'},
)
return hit
except Exception as e:
logger.warning(
f"{self.cookie_id}】检查买家黑名单失败: buyer_id={normalized_buyer_id}, "
f"item_id={item_id or ''}, error={self._safe_str(e)}"
)
return None
def _record_delivery_log(self, order_id: str = None, item_id: str = None, buyer_id: str = None,
buyer_nick: str = None, status: str = 'failed', reason: str = None,
channel: str = 'auto', rule_meta: dict = None):
@@ -4513,6 +4588,16 @@ class XianyuLive:
)
return
if self._check_buyer_blacklist_for_action(
buyer_id=user_id,
item_id=item_id,
order_id=order_id,
action='自动发货',
channel='auto',
log_delivery=True,
):
return
# 检查订单是否已发货
if not self.can_auto_delivery(order_id):
logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 订单 {order_id} 在冷却期内,跳过发货')
@@ -4863,6 +4948,16 @@ class XianyuLive:
)
return
if self._check_buyer_blacklist_for_action(
buyer_id=send_user_id,
item_id=item_id,
buyer_nick=send_user_name,
action='自动发货',
channel='auto',
log_delivery=True,
):
return
# 提取订单ID(传递原始消息数据以便在解密消息中找不到时进行备用搜索)
order_id = self._extract_order_id(message, message_data)
@@ -5039,6 +5134,17 @@ class XianyuLive:
item_id = existing_item_id
logger.info(f'[{msg_time}] 【{self.cookie_id}】订单一致性校验补全商品ID: {item_id}')
if self._check_buyer_blacklist_for_action(
buyer_id=send_user_id,
item_id=item_id,
order_id=order_id,
buyer_nick=send_user_name,
action='自动发货',
channel='auto',
log_delivery=True,
):
return
logger.info(f'[{msg_time}] 【{self.cookie_id}】提取到订单ID: {order_id},将在自动发货时处理确认发货')
# 使用订单ID作为锁的键
@@ -9062,6 +9168,15 @@ class XianyuLive:
async def get_ai_reply(self, send_user_name: str, send_user_id: str, send_message: str, item_id: str, chat_id: str):
"""获取AI回复"""
try:
if self._check_buyer_blacklist_for_action(
buyer_id=send_user_id,
item_id=item_id,
buyer_nick=send_user_name,
action='AI回复',
log_delivery=False,
):
return None
from ai_reply_engine import ai_reply_engine
# 检查是否启用AI回复
@@ -14636,6 +14751,16 @@ class XianyuLive:
logger.info(f"[{msg_time}] 【{self.cookie_id}】【系统】chat_id {chat_id} 自动回复已暂停,剩余时间: {remaining_minutes}{remaining_seconds}")
return
blacklist_hit = self._check_buyer_blacklist_for_action(
buyer_id=send_user_id,
item_id=item_id,
buyer_nick=send_user_name,
action='自动回复',
log_delivery=False,
)
if blacklist_hit:
return
reply = None
reply_source = None
@@ -15393,6 +15518,18 @@ class XianyuLive:
# 继续执行亦凡API调用(带账号)
try:
if self._check_buyer_blacklist_for_action(
buyer_id=send_user_id,
item_id=item_id_saved,
order_id=order_id_saved,
buyer_nick=send_user_name,
action='亦凡账号确认自动发货',
channel='auto',
log_delivery=True,
):
logger.info(f"{self.cookie_id}】[{msg_id}] 亦凡账号确认发货被黑名单拦截")
return
# 直接调用亦凡API下单
delivery_content = await self._call_yifan_api_with_account(
rule, account, order_id_saved, item_id_saved, send_user_id, chat_id
+193
View File
@@ -0,0 +1,193 @@
"""Blacklist service helpers for personal/platform blacklist flows."""
import io
from typing import Any, Dict, List, Optional
import pandas as pd
from loguru import logger
from db_manager import db_manager
PERSONAL_BLACKLIST_EXPORT_COLUMNS = [
'账号ID',
'买家ID',
'买家昵称',
'商品ID',
'拉黑原因',
'是否启用',
]
class BlacklistService:
def __init__(self, db=db_manager):
self.db = db
def _normalize_bool(self, value: Any, default: bool = True) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
text = str(value).strip().lower()
if text in {'1', 'true', 'yes', 'y', 'on', '启用', '', '开启'}:
return True
if text in {'0', 'false', 'no', 'n', 'off', '禁用', '', '关闭'}:
return False
return default
def _clean_text(self, value: Any) -> str:
if value is None:
return ''
if isinstance(value, float) and pd.isna(value):
return ''
return str(value).strip()
def create_personal(
self,
user_id: int,
buyer_ids: Any,
cookie_id: Optional[str] = None,
item_id: Optional[str] = None,
reason: str = '',
is_enabled: bool = True,
buyer_nick: str = '',
) -> Dict[str, Any]:
return self.db.create_personal_blacklist(
user_id=user_id,
buyer_ids=buyer_ids,
cookie_id=cookie_id,
item_id=item_id,
reason=reason,
is_enabled=is_enabled,
buyer_nick=buyer_nick,
)
def list_personal(
self,
user_id: int,
buyer_id: Optional[str] = None,
buyer_nick: Optional[str] = None,
page: int = 1,
page_size: int = 20,
) -> Dict[str, Any]:
return self.db.list_personal_blacklist(
user_id=user_id,
buyer_id=buyer_id,
buyer_nick=buyer_nick,
page=page,
page_size=page_size,
)
def delete_personal(self, record_id: int, user_id: int) -> bool:
return self.db.delete_personal_blacklist(record_id, user_id)
def batch_delete_personal(self, ids: List[int], user_id: int) -> int:
return self.db.batch_delete_personal_blacklist(ids, user_id)
def toggle_personal(self, record_id: int, user_id: int, is_enabled: bool) -> bool:
return self.db.toggle_personal_blacklist(record_id, user_id, is_enabled)
def list_platform(self, user_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
return self.db.list_platform_blacklist(user_id=user_id, page=page, page_size=page_size)
def is_buyer_blacklisted(
self,
user_id: int,
buyer_id: str,
cookie_id: Optional[str] = None,
item_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
return self.db.is_buyer_blacklisted(
user_id=user_id,
buyer_id=buyer_id,
cookie_id=cookie_id,
item_id=item_id,
)
def is_buyer_blacklisted_by_cookie(
self,
cookie_id: str,
buyer_id: str,
item_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
cookie_details = self.db.get_cookie_details(cookie_id) if cookie_id else None
user_id = cookie_details.get('user_id') if cookie_details else None
if not user_id:
return None
return self.is_buyer_blacklisted(
user_id=user_id,
buyer_id=buyer_id,
cookie_id=cookie_id,
item_id=item_id,
)
def export_personal_xlsx(self, user_id: int) -> bytes:
records = self.db.list_personal_blacklist(user_id=user_id, page=1, page_size=100000).get('data', [])
rows = []
for record in records:
rows.append({
'账号ID': record.get('cookie_id') or '',
'买家ID': record.get('buyer_id') or '',
'买家昵称': record.get('buyer_nick') or '',
'商品ID': record.get('item_id') or '',
'拉黑原因': record.get('reason') or '',
'是否启用': '' if record.get('is_enabled') else '',
})
df = pd.DataFrame(rows, columns=PERSONAL_BLACKLIST_EXPORT_COLUMNS)
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
df.to_excel(writer, sheet_name='个人黑名单', index=False)
output.seek(0)
return output.getvalue()
def import_personal_xlsx(self, user_id: int, file_bytes: bytes) -> Dict[str, Any]:
df = pd.read_excel(io.BytesIO(file_bytes), engine='openpyxl')
missing_columns = [col for col in ['买家ID'] if col not in df.columns]
if missing_columns:
raise ValueError(f"缺少必需表头: {', '.join(missing_columns)}")
total_rows = 0
created = 0
skipped = 0
errors = []
records = []
for row_index, row in df.iterrows():
total_rows += 1
buyer_id = self._clean_text(row.get('买家ID'))
if not buyer_id:
skipped += 1
errors.append(f"{row_index + 2} 行缺少买家ID,已跳过")
continue
try:
result = self.create_personal(
user_id=user_id,
buyer_ids=[buyer_id],
cookie_id=self._clean_text(row.get('账号ID')) or None,
item_id=self._clean_text(row.get('商品ID')) or None,
reason=self._clean_text(row.get('拉黑原因')),
is_enabled=self._normalize_bool(row.get('是否启用'), True),
buyer_nick=self._clean_text(row.get('买家昵称')),
)
created += int(result.get('created') or 0)
skipped += int(result.get('skipped') or 0)
records.extend(result.get('records') or [])
except Exception as exc:
logger.warning(f"导入个人黑名单第 {row_index + 2} 行失败: {exc}")
skipped += 1
errors.append(f"{row_index + 2} 行导入失败: {exc}")
return {
'total': total_rows,
'created': created,
'skipped': skipped,
'records': records,
'errors': errors,
}
blacklist_service = BlacklistService()
+353
View File
@@ -337,6 +337,40 @@ class DBManager:
''')
# 创建个人黑名单表
cursor.execute('''
CREATE TABLE IF NOT EXISTS xy_personal_blacklist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
cookie_id TEXT,
buyer_id TEXT NOT NULL,
buyer_nick TEXT DEFAULT '',
item_id TEXT,
reason TEXT DEFAULT '',
is_enabled INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (cookie_id) REFERENCES cookies(id) ON DELETE CASCADE
)
''')
self._execute_sql(cursor, "CREATE INDEX IF NOT EXISTS idx_xy_personal_blacklist_user_buyer ON xy_personal_blacklist(user_id, buyer_id)")
self._execute_sql(cursor, "CREATE INDEX IF NOT EXISTS idx_xy_personal_blacklist_scope ON xy_personal_blacklist(user_id, buyer_id, cookie_id, item_id, is_enabled)")
# 创建平台黑名单表(预留平台同步能力)
cursor.execute('''
CREATE TABLE IF NOT EXISTS xy_platform_blacklist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
buyer_id TEXT NOT NULL,
buyer_nick TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
''')
self._execute_sql(cursor, "CREATE INDEX IF NOT EXISTS idx_xy_platform_blacklist_user_buyer ON xy_platform_blacklist(user_id, buyer_id)")
# 创建keywords表
cursor.execute('''
CREATE TABLE IF NOT EXISTS keywords (
@@ -10487,6 +10521,325 @@ Cookie数量: {cookie_count}
logger.error(f"获取全量会话列表失败: {e}")
return []
def _normalize_blacklist_scope_value(self, value: Any) -> Optional[str]:
if value is None:
return None
text = str(value).strip()
return text or None
def _normalize_blacklist_buyer_ids(self, buyer_ids: Any) -> List[str]:
if buyer_ids is None:
return []
if isinstance(buyer_ids, str):
raw_values = re.split(r'[\n,]+', buyer_ids)
else:
raw_values = list(buyer_ids)
normalized = []
seen = set()
for raw_value in raw_values:
buyer_id = str(raw_value or '').strip()
if not buyer_id or buyer_id in seen:
continue
normalized.append(buyer_id)
seen.add(buyer_id)
return normalized
def _personal_blacklist_row_to_dict(self, row: tuple, columns: List[str]) -> Dict[str, Any]:
record = dict(zip(columns, row))
record['is_enabled'] = bool(record.get('is_enabled'))
scope = 'user'
if self._normalize_blacklist_scope_value(record.get('item_id')):
scope = 'item'
elif self._normalize_blacklist_scope_value(record.get('cookie_id')):
scope = 'account'
record['scope'] = scope
return record
def create_personal_blacklist(
self,
user_id: int,
buyer_ids: List[str],
cookie_id: str = None,
item_id: str = None,
reason: str = "",
is_enabled: bool = True,
buyer_nick: str = "",
) -> Dict[str, Any]:
"""创建个人黑名单记录,重复 scope 会跳过。"""
normalized_buyer_ids = self._normalize_blacklist_buyer_ids(buyer_ids)
normalized_cookie_id = self._normalize_blacklist_scope_value(cookie_id)
normalized_item_id = self._normalize_blacklist_scope_value(item_id)
normalized_reason = str(reason or '').strip()
normalized_buyer_nick = str(buyer_nick or '').strip()
result = {
'created': 0,
'skipped': 0,
'records': [],
'skipped_buyer_ids': [],
}
if not user_id or not normalized_buyer_ids:
return result
with self.lock:
cursor = self.conn.cursor()
try:
if normalized_cookie_id:
self._execute_sql(cursor, "SELECT user_id FROM cookies WHERE id = ?", (normalized_cookie_id,))
cookie_owner = cursor.fetchone()
if not cookie_owner or int(cookie_owner[0]) != int(user_id):
result['skipped'] = len(normalized_buyer_ids)
result['skipped_buyer_ids'] = normalized_buyer_ids
return result
for buyer_id in normalized_buyer_ids:
self._execute_sql(cursor, """
SELECT id FROM xy_personal_blacklist
WHERE user_id = ?
AND buyer_id = ?
AND COALESCE(cookie_id, '') = ?
AND COALESCE(item_id, '') = ?
LIMIT 1
""", (
user_id,
buyer_id,
normalized_cookie_id or '',
normalized_item_id or '',
))
if cursor.fetchone():
result['skipped'] += 1
result['skipped_buyer_ids'].append(buyer_id)
continue
self._execute_sql(cursor, """
INSERT INTO xy_personal_blacklist
(user_id, cookie_id, buyer_id, buyer_nick, item_id, reason, is_enabled, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (
user_id,
normalized_cookie_id,
buyer_id,
normalized_buyer_nick,
normalized_item_id,
normalized_reason,
1 if is_enabled else 0,
))
record_id = cursor.lastrowid
self._execute_sql(cursor, """
SELECT * FROM xy_personal_blacklist WHERE id = ?
""", (record_id,))
columns = [desc[0] for desc in cursor.description]
row = cursor.fetchone()
if row:
result['records'].append(self._personal_blacklist_row_to_dict(row, columns))
result['created'] += 1
self.conn.commit()
return result
except Exception as e:
self.conn.rollback()
logger.error(f"创建个人黑名单失败: {e}")
return result
def list_personal_blacklist(
self,
user_id: int,
buyer_id: str = None,
buyer_nick: str = None,
page: int = 1,
page_size: int = 20,
) -> Dict[str, Any]:
"""分页查询个人黑名单。"""
safe_page = max(int(page or 1), 1)
safe_page_size = min(max(int(page_size or 20), 1), 200)
offset = (safe_page - 1) * safe_page_size
where_clauses = ["user_id = ?"]
params: List[Any] = [user_id]
normalized_buyer_id = self._normalize_blacklist_scope_value(buyer_id)
if normalized_buyer_id:
where_clauses.append("buyer_id LIKE ?")
params.append(f"%{normalized_buyer_id}%")
normalized_buyer_nick = self._normalize_blacklist_scope_value(buyer_nick)
if normalized_buyer_nick:
where_clauses.append("buyer_nick LIKE ?")
params.append(f"%{normalized_buyer_nick}%")
where_sql = " AND ".join(where_clauses)
with self.lock:
cursor = self.conn.cursor()
try:
self._execute_sql(cursor, f"SELECT COUNT(*) FROM xy_personal_blacklist WHERE {where_sql}", tuple(params))
total = int(cursor.fetchone()[0] or 0)
self._execute_sql(cursor, f"""
SELECT * FROM xy_personal_blacklist
WHERE {where_sql}
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?
""", tuple(params + [safe_page_size, offset]))
rows = cursor.fetchall()
columns = [desc[0] for desc in cursor.description]
data = [self._personal_blacklist_row_to_dict(row, columns) for row in rows]
return {
'data': data,
'total': total,
'page': safe_page,
'page_size': safe_page_size,
}
except Exception as e:
logger.error(f"查询个人黑名单失败: {e}")
return {'data': [], 'total': 0, 'page': safe_page, 'page_size': safe_page_size}
def delete_personal_blacklist(self, record_id: int, user_id: int) -> bool:
"""删除单条个人黑名单。"""
with self.lock:
cursor = self.conn.cursor()
try:
self._execute_sql(cursor, "DELETE FROM xy_personal_blacklist WHERE id = ? AND user_id = ?", (record_id, user_id))
deleted = cursor.rowcount > 0
self.conn.commit()
return deleted
except Exception as e:
self.conn.rollback()
logger.error(f"删除个人黑名单失败: {e}")
return False
def batch_delete_personal_blacklist(self, ids: List[int], user_id: int) -> int:
"""批量删除个人黑名单,返回删除数量。"""
safe_ids = []
for raw_id in ids or []:
try:
record_id = int(raw_id)
except (TypeError, ValueError):
continue
if record_id > 0 and record_id not in safe_ids:
safe_ids.append(record_id)
if not safe_ids:
return 0
placeholders = ','.join(['?'] * len(safe_ids))
with self.lock:
cursor = self.conn.cursor()
try:
self._execute_sql(
cursor,
f"DELETE FROM xy_personal_blacklist WHERE user_id = ? AND id IN ({placeholders})",
tuple([user_id] + safe_ids),
)
deleted = cursor.rowcount
self.conn.commit()
return deleted
except Exception as e:
self.conn.rollback()
logger.error(f"批量删除个人黑名单失败: {e}")
return 0
def toggle_personal_blacklist(self, record_id: int, user_id: int, is_enabled: bool) -> bool:
"""启用或禁用个人黑名单。"""
with self.lock:
cursor = self.conn.cursor()
try:
self._execute_sql(cursor, """
UPDATE xy_personal_blacklist
SET is_enabled = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?
""", (1 if is_enabled else 0, record_id, user_id))
updated = cursor.rowcount > 0
self.conn.commit()
return updated
except Exception as e:
self.conn.rollback()
logger.error(f"更新个人黑名单状态失败: {e}")
return False
def is_buyer_blacklisted(
self,
user_id: int,
buyer_id: str,
cookie_id: str = None,
item_id: str = None,
) -> Optional[Dict[str, Any]]:
"""按商品级 > 账号级 > 用户级匹配个人黑名单。"""
normalized_buyer_id = self._normalize_blacklist_scope_value(buyer_id)
if not user_id or not normalized_buyer_id:
return None
normalized_cookie_id = self._normalize_blacklist_scope_value(cookie_id)
normalized_item_id = self._normalize_blacklist_scope_value(item_id)
with self.lock:
cursor = self.conn.cursor()
try:
self._execute_sql(cursor, """
SELECT * FROM xy_personal_blacklist
WHERE user_id = ?
AND buyer_id = ?
AND is_enabled = 1
AND (
(COALESCE(cookie_id, '') = '' AND COALESCE(item_id, '') = '')
OR (COALESCE(cookie_id, '') = ? AND COALESCE(item_id, '') = '')
OR (COALESCE(item_id, '') = ? AND (COALESCE(cookie_id, '') = '' OR COALESCE(cookie_id, '') = ?))
)
ORDER BY
CASE
WHEN COALESCE(item_id, '') != '' THEN 3
WHEN COALESCE(cookie_id, '') != '' THEN 2
ELSE 1
END DESC,
updated_at DESC,
id DESC
LIMIT 1
""", (
user_id,
normalized_buyer_id,
normalized_cookie_id or '',
normalized_item_id or '',
normalized_cookie_id or '',
))
row = cursor.fetchone()
if not row:
return None
columns = [desc[0] for desc in cursor.description]
return self._personal_blacklist_row_to_dict(row, columns)
except Exception as e:
logger.error(f"匹配个人黑名单失败: {e}")
return None
def list_platform_blacklist(self, user_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
"""分页查询平台黑名单(当前仅预留展示)。"""
safe_page = max(int(page or 1), 1)
safe_page_size = min(max(int(page_size or 20), 1), 200)
offset = (safe_page - 1) * safe_page_size
with self.lock:
cursor = self.conn.cursor()
try:
self._execute_sql(cursor, "SELECT COUNT(*) FROM xy_platform_blacklist WHERE user_id = ?", (user_id,))
total = int(cursor.fetchone()[0] or 0)
self._execute_sql(cursor, """
SELECT id, user_id, buyer_id, buyer_nick, created_at, updated_at
FROM xy_platform_blacklist
WHERE user_id = ?
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?
""", (user_id, safe_page_size, offset))
rows = cursor.fetchall()
columns = [desc[0] for desc in cursor.description]
return {
'data': [dict(zip(columns, row)) for row in rows],
'total': total,
'page': safe_page,
'page_size': safe_page_size,
}
except Exception as e:
logger.error(f"查询平台黑名单失败: {e}")
return {'data': [], 'total': 0, 'page': safe_page, 'page_size': safe_page_size}
# 全局单例
db_manager = DBManager()
+275 -1
View File
@@ -1,6 +1,6 @@
from fastapi import FastAPI, HTTPException, Depends, status, UploadFile, File, Form, Request, BackgroundTasks
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse, StreamingResponse
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse, StreamingResponse, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from typing import List, Tuple, Optional, Dict, Any, Callable, Awaitable
@@ -29,6 +29,7 @@ from db_manager import db_manager
from config import RISK_CONTROL
from file_log_collector import setup_file_logging, get_file_log_collector
from ai_reply_engine import ai_reply_engine
from blacklist_service import blacklist_service
from utils.qr_login import qr_login_manager
from utils.qr_login_lite import qrcode_login_lite
from utils.xianyu_utils import trans_cookies
@@ -1021,6 +1022,37 @@ def log_with_user(level: str, message: str, user_info: Dict[str, Any] = None):
logger.info(full_message)
def _get_blacklist_block_by_cookie(cookie_id: str, buyer_id: str, item_id: str = None) -> Optional[Dict[str, Any]]:
"""按 cookie 归属检查买家是否命中个人黑名单。"""
try:
normalized_cookie_id = str(cookie_id or '').strip()
normalized_buyer_id = str(buyer_id or '').strip()
if not normalized_cookie_id or not normalized_buyer_id:
return None
cookie_details = db_manager.get_cookie_details(normalized_cookie_id)
user_id = cookie_details.get('user_id') if cookie_details else None
if not user_id:
return None
return blacklist_service.is_buyer_blacklisted(
user_id=user_id,
buyer_id=normalized_buyer_id,
cookie_id=normalized_cookie_id,
item_id=str(item_id or '').strip() or None,
)
except Exception as e:
logger.warning(f"检查黑名单失败: cookie_id={cookie_id}, buyer_id={buyer_id}, error={mask_sensitive_text(e)}")
return None
def _format_blacklist_block_message(hit: Dict[str, Any]) -> str:
if not hit:
return '买家命中黑名单,已拦截'
scope_label = {'item': '商品级', 'account': '账号级', 'user': '用户级'}.get(hit.get('scope'), hit.get('scope') or '未知级别')
reason = str(hit.get('reason') or '').strip()
reason_part = f",原因:{reason}" if reason else ''
return f"买家 {hit.get('buyer_id') or ''} 命中{scope_label}黑名单{reason_part},已拦截"
def match_reply(cookie_id: str, message: str) -> Optional[str]:
"""根据 cookie_id 及消息内容匹配回复
只有启用的账号才会匹配关键字回复
@@ -1066,6 +1098,23 @@ class ResponseModel(BaseModel):
data: ResponseData
class PersonalBlacklistCreateRequest(BaseModel):
buyer_ids: Any
cookie_id: Optional[str] = None
item_id: Optional[str] = None
buyer_nick: Optional[str] = ''
reason: Optional[str] = ''
is_enabled: bool = True
class PersonalBlacklistBatchDeleteRequest(BaseModel):
ids: List[int]
class PersonalBlacklistToggleRequest(BaseModel):
is_enabled: bool
app = FastAPI(
title="Xianyu Management API",
version="1.0.0",
@@ -2321,6 +2370,12 @@ async def send_message_api(request: SendMessageRequest):
message=f"参数 {param_name} 不能为空"
)
blacklist_hit = _get_blacklist_block_by_cookie(cleaned_cookie_id, cleaned_to_user_id)
if blacklist_hit:
block_message = _format_blacklist_block_message(blacklist_hit)
logger.warning(f"API发送消息被黑名单拦截: cookie_id={cleaned_cookie_id}, buyer_id={cleaned_to_user_id}, scope={blacklist_hit.get('scope')}")
return SendMessageResponse(success=False, message=block_message)
# 直接获取XianyuLive实例,跳过cookie_manager检查
from XianyuAutoAsync import XianyuLive, ConnectionState
live_instance = XianyuLive.get_instance(cleaned_cookie_id)
@@ -2390,6 +2445,23 @@ async def send_message_api(request: SendMessageRequest):
@app.post("/xianyu/reply", response_model=ResponseModel)
async def xianyu_reply(req: RequestModel):
blacklist_hit = _get_blacklist_block_by_cookie(req.cookie_id, req.send_user_id, req.item_id)
if blacklist_hit:
logger.warning(
f"/xianyu/reply 被黑名单拦截: cookie_id={req.cookie_id}, buyer_id={req.send_user_id}, "
f"item_id={req.item_id}, scope={blacklist_hit.get('scope')}"
)
return JSONResponse(
status_code=403,
content={
'success': False,
'blocked': True,
'reason': 'buyer_blacklisted',
'message': _format_blacklist_block_message(blacklist_hit),
'blacklist': blacklist_hit,
},
)
msg_template = match_reply(req.cookie_id, req.send_message)
is_default_reply = False
@@ -2432,6 +2504,168 @@ async def xianyu_reply(req: RequestModel):
return {"code": 200, "data": {"send_msg": send_msg}}
# ------------------------- 黑名单接口 -------------------------
@app.get('/api/blacklist/personal')
def get_personal_blacklist(
buyer_id: str = None,
buyer_nick: str = None,
page: int = 1,
page_size: int = 20,
current_user: Dict[str, Any] = Depends(get_current_user),
):
try:
result = blacklist_service.list_personal(
user_id=current_user['user_id'],
buyer_id=buyer_id,
buyer_nick=buyer_nick,
page=page,
page_size=page_size,
)
return {'success': True, **result}
except Exception as e:
log_with_user('error', f"查询个人黑名单失败: {mask_sensitive_text(e)}", current_user)
raise HTTPException(status_code=500, detail='查询个人黑名单失败')
@app.post('/api/blacklist/personal')
def create_personal_blacklist(
request: PersonalBlacklistCreateRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
try:
cookie_id = str(request.cookie_id or '').strip() or None
if cookie_id:
cookie_id = _ensure_cookie_access(cookie_id, current_user)
result = blacklist_service.create_personal(
user_id=current_user['user_id'],
buyer_ids=request.buyer_ids,
cookie_id=cookie_id,
item_id=str(request.item_id or '').strip() or None,
reason=str(request.reason or '').strip(),
is_enabled=bool(request.is_enabled),
buyer_nick=str(request.buyer_nick or '').strip(),
)
created = int(result.get('created') or 0)
skipped = int(result.get('skipped') or 0)
message = f"成功添加 {created} 条黑名单"
if skipped:
message += f",跳过 {skipped}"
log_with_user('info', f"新增个人黑名单: created={created}, skipped={skipped}", current_user)
return {'success': True, 'message': message, 'data': {'count': created, 'skipped': skipped, 'records': result.get('records') or []}}
except HTTPException:
raise
except Exception as e:
log_with_user('error', f"新增个人黑名单失败: {mask_sensitive_text(e)}", current_user)
raise HTTPException(status_code=500, detail='新增个人黑名单失败')
@app.post('/api/blacklist/personal/batch-delete')
def batch_delete_personal_blacklist(
request: PersonalBlacklistBatchDeleteRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
try:
deleted = blacklist_service.batch_delete_personal(request.ids, current_user['user_id'])
return {'success': True, 'message': f'成功删除 {deleted} 条黑名单', 'data': {'deleted': deleted}}
except Exception as e:
log_with_user('error', f"批量删除个人黑名单失败: {mask_sensitive_text(e)}", current_user)
raise HTTPException(status_code=500, detail='批量删除个人黑名单失败')
@app.patch('/api/blacklist/personal/{record_id}/toggle')
def toggle_personal_blacklist(
record_id: int,
request: PersonalBlacklistToggleRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
try:
success = blacklist_service.toggle_personal(record_id, current_user['user_id'], request.is_enabled)
if not success:
raise HTTPException(status_code=404, detail='黑名单记录不存在')
return {'success': True, 'message': '状态已更新'}
except HTTPException:
raise
except Exception as e:
log_with_user('error', f"更新个人黑名单状态失败: {mask_sensitive_text(e)}", current_user)
raise HTTPException(status_code=500, detail='更新个人黑名单状态失败')
@app.delete('/api/blacklist/personal/{record_id}')
def delete_personal_blacklist(
record_id: int,
current_user: Dict[str, Any] = Depends(get_current_user),
):
try:
success = blacklist_service.delete_personal(record_id, current_user['user_id'])
if not success:
raise HTTPException(status_code=404, detail='黑名单记录不存在')
return {'success': True, 'message': '删除成功'}
except HTTPException:
raise
except Exception as e:
log_with_user('error', f"删除个人黑名单失败: {mask_sensitive_text(e)}", current_user)
raise HTTPException(status_code=500, detail='删除个人黑名单失败')
@app.get('/api/blacklist/personal/export')
def export_personal_blacklist(current_user: Dict[str, Any] = Depends(get_current_user)):
try:
content = blacklist_service.export_personal_xlsx(current_user['user_id'])
filename = f"personal_blacklist_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
headers = {'Content-Disposition': f'attachment; filename="{filename}"'}
return Response(
content=content,
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers=headers,
)
except Exception as e:
log_with_user('error', f"导出个人黑名单失败: {mask_sensitive_text(e)}", current_user)
raise HTTPException(status_code=500, detail='导出个人黑名单失败')
@app.post('/api/blacklist/personal/import')
async def import_personal_blacklist(
file: UploadFile = File(...),
current_user: Dict[str, Any] = Depends(get_current_user),
):
try:
filename = file.filename or ''
if not filename.lower().endswith('.xlsx'):
raise HTTPException(status_code=400, detail='仅支持 .xlsx 文件')
content = await file.read()
result = blacklist_service.import_personal_xlsx(current_user['user_id'], content)
return {
'success': True,
'message': f"导入完成:新增 {result.get('created', 0)} 条,跳过 {result.get('skipped', 0)}",
'data': result,
}
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
log_with_user('error', f"导入个人黑名单失败: {mask_sensitive_text(e)}", current_user)
raise HTTPException(status_code=500, detail='导入个人黑名单失败')
@app.get('/api/blacklist/platform')
def get_platform_blacklist(
page: int = 1,
page_size: int = 20,
current_user: Dict[str, Any] = Depends(get_current_user),
):
try:
result = blacklist_service.list_platform(current_user['user_id'], page=page, page_size=page_size)
return {'success': True, **result}
except Exception as e:
log_with_user('error', f"查询平台黑名单失败: {mask_sensitive_text(e)}", current_user)
raise HTTPException(status_code=500, detail='查询平台黑名单失败')
# ------------------------- 账号 / 关键字管理接口 -------------------------
@@ -12987,6 +13221,20 @@ async def chat_send_message(
if not live_instance.ws:
raise HTTPException(status_code=400, detail="WebSocket连接未就绪")
item_id_for_blacklist = None
try:
recent_order = db_manager.get_recent_order_by_sid(req.chat_id, cookie_id, minutes=60)
if recent_order and str(recent_order.get('buyer_id') or '') == str(req.to_user_id or ''):
item_id_for_blacklist = recent_order.get('item_id')
except Exception:
item_id_for_blacklist = None
blacklist_hit = _get_blacklist_block_by_cookie(cookie_id, req.to_user_id, item_id_for_blacklist)
if blacklist_hit:
block_message = _format_blacklist_block_message(blacklist_hit)
logger.warning(f"客服发送消息被黑名单拦截: cookie_id={cookie_id}, buyer_id={req.to_user_id}, scope={blacklist_hit.get('scope')}")
return {'success': False, 'blocked': True, 'message': block_message, 'blacklist': blacklist_hit}
await _run_live_instance_on_manager_loop(
cookie_id,
lambda: live_instance.send_msg(
@@ -13251,6 +13499,32 @@ async def manual_deliver_order(order_id: str, current_user: Dict[str, Any] = Dep
if not buyer_id:
return {"success": False, "delivered": False, "message": "订单缺少买家信息,无法发送消息"}
blacklist_hit = blacklist_service.is_buyer_blacklisted(
user_id=user_id,
buyer_id=buyer_id,
cookie_id=cookie_id,
item_id=item_id,
)
if blacklist_hit:
block_message = _format_blacklist_block_message(blacklist_hit)
db_manager.create_delivery_log(
user_id=user_id,
cookie_id=cookie_id,
order_id=order_id,
item_id=item_id,
buyer_id=buyer_id,
buyer_nick=order.get('buyer_nick'),
rule_id=None,
rule_keyword=None,
card_type=None,
match_mode='blacklist',
channel='manual',
status='skipped',
reason=block_message,
)
log_with_user('warning', f"手动发货被黑名单拦截: order_id={order_id}, buyer_id={buyer_id}, scope={blacklist_hit.get('scope')}", current_user)
return {"success": False, "delivered": False, "blocked": True, "message": block_message, "blacklist": blacklist_hit}
# 获取商品标题
item_info = db_manager.get_item_info(cookie_id, item_id)
item_title = item_info.get('item_title', '') if item_info else ''
+154
View File
@@ -143,6 +143,12 @@
<span>在线客服</span>
</a>
</div>
<div class="nav-item" data-menu-id="blacklist">
<a href="javascript:void(0)" class="nav-link" onclick="showSection('blacklist')">
<i class="bi bi-person-x"></i>
<span>黑名单管理</span>
</a>
</div>
<div class="nav-item" data-menu-id="system-settings">
<a href="javascript:void(0)" class="nav-link" onclick="showSection('system-settings')">
<i class="bi bi-gear"></i>
@@ -3138,6 +3144,154 @@
</div>
</div>
<!-- 黑名单管理内容 -->
<div id="blacklist-section" class="content-section">
<div class="content-header">
<div class="d-flex justify-content-between align-items-center w-100 flex-wrap gap-2">
<div>
<h2 class="mb-0">
<i class="bi bi-person-x me-2"></i>
黑名单管理
</h2>
<p class="text-muted mb-0">按用户、账号或商品范围拦截指定买家的自动回复、客服发送和自动发货</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<button type="button" class="btn btn-outline-primary" onclick="document.getElementById('blacklistImportFile').click()">
<i class="bi bi-upload me-1"></i>导入
</button>
<button type="button" class="btn btn-outline-success" onclick="exportPersonalBlacklist()">
<i class="bi bi-download me-1"></i>导出
</button>
<input type="file" id="blacklistImportFile" accept=".xlsx" class="d-none" onchange="importPersonalBlacklistFile()">
</div>
</div>
</div>
<div class="content-body">
<div class="row g-4">
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header">
<i class="bi bi-plus-circle me-2"></i>新增个人黑名单
</div>
<div class="card-body">
<form id="personalBlacklistForm" onsubmit="event.preventDefault(); createPersonalBlacklist(); return false;">
<div class="mb-3">
<label for="blacklistBuyerIds" class="form-label">买家ID <span class="text-danger">*</span></label>
<textarea class="form-control" id="blacklistBuyerIds" rows="4" placeholder="每行一个买家ID,也支持逗号分隔" required></textarea>
</div>
<div class="mb-3">
<label for="blacklistCookieId" class="form-label">作用账号</label>
<select class="form-select" id="blacklistCookieId">
<option value="">全部账号</option>
</select>
<div class="form-text">选择账号后仅拦截该账号;留空为用户级。</div>
</div>
<div class="mb-3">
<label for="blacklistItemId" class="form-label">商品ID</label>
<input type="text" class="form-control" id="blacklistItemId" placeholder="可选,填写后仅拦截该商品">
</div>
<div class="mb-3">
<label for="blacklistBuyerNick" class="form-label">买家昵称</label>
<input type="text" class="form-control" id="blacklistBuyerNick" placeholder="可选,便于识别">
</div>
<div class="mb-3">
<label for="blacklistReason" class="form-label">拉黑原因</label>
<textarea class="form-control" id="blacklistReason" rows="2" placeholder="可选,会显示在拦截提示和发货日志中"></textarea>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="blacklistEnabled" checked>
<label class="form-check-label" for="blacklistEnabled">立即启用</label>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary flex-fill">
<i class="bi bi-check-circle me-1"></i>保存
</button>
<button type="button" class="btn btn-outline-secondary" onclick="resetPersonalBlacklistForm()">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<i class="bi bi-list-check me-2"></i>个人黑名单
<span class="text-muted small ms-2" id="blacklistTotalText">共 0 条</span>
</div>
<button type="button" class="btn btn-outline-danger btn-sm" id="blacklistBatchDeleteBtn" onclick="batchDeletePersonalBlacklist()" disabled>
<i class="bi bi-trash me-1"></i>批量删除
</button>
</div>
</div>
<div class="card-body">
<div class="row g-2 align-items-end mb-3">
<div class="col-md-4">
<label for="blacklistFilterBuyerId" class="form-label small mb-1">买家ID</label>
<input type="text" class="form-control form-control-sm" id="blacklistFilterBuyerId" placeholder="搜索买家ID" onkeydown="handleBlacklistFilterKeydown(event)">
</div>
<div class="col-md-4">
<label for="blacklistFilterBuyerNick" class="form-label small mb-1">买家昵称</label>
<input type="text" class="form-control form-control-sm" id="blacklistFilterBuyerNick" placeholder="搜索昵称" onkeydown="handleBlacklistFilterKeydown(event)">
</div>
<div class="col-md-2">
<label for="blacklistPageSize" class="form-label small mb-1">每页</label>
<select class="form-select form-select-sm" id="blacklistPageSize" onchange="loadPersonalBlacklist(1)">
<option value="10">10</option>
<option value="20" selected>20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div class="col-md-2 d-flex gap-2">
<button type="button" class="btn btn-primary btn-sm flex-fill" onclick="loadPersonalBlacklist(1)">
<i class="bi bi-search"></i>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="resetBlacklistFilters()">
<i class="bi bi-x-circle"></i>
</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th style="width: 36px;"><input class="form-check-input" type="checkbox" id="blacklistSelectAll" onchange="toggleBlacklistSelectAll(this.checked)"></th>
<th>范围</th>
<th>买家</th>
<th>目标</th>
<th>原因</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="blacklistTableBody">
<tr>
<td colspan="8" class="text-center py-4 text-muted">
<i class="bi bi-person-x fs-1 d-block mb-3"></i>
暂无黑名单记录
</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mt-3">
<div class="small text-muted" id="blacklistPageText">第 1 页</div>
<div class="btn-group" id="blacklistPagination" role="group" aria-label="黑名单分页"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 系统设置内容 -->
<div id="system-settings-section" class="content-section">
<div class="content-header">
+400 -1
View File
@@ -62,6 +62,12 @@ let orderHistorySyncPollingTimer = null;
let activeOrderHistorySyncJobId = '';
let orderHistorySyncNotifiedJobId = '';
let orderHistorySyncAccounts = [];
let blacklistState = {
page: 1,
pageSize: 20,
total: 0,
accountsLoaded: false
};
let loadingRequestCount = 0;
let loadingShowTimer = null;
const LOADING_SHOW_DELAY = 120;
@@ -172,6 +178,9 @@ function showSection(sectionName) {
case 'online-im': // 【在线客服菜单】
loadOnlineIm();
break;
case 'blacklist': // 【黑名单管理菜单】
loadBlacklistPage();
break;
case 'data-management': // 【数据管理菜单】
loadDataManagement();
break;
@@ -3376,9 +3385,398 @@ async function fetchJSON(url, opts = {}) {
}
// ================================
// 账号保活诊断
// 【黑名单管理菜单】相关功能
// ================================
async function loadBlacklistPage() {
await loadBlacklistAccountOptions();
await loadPersonalBlacklist(blacklistState.page || 1);
}
async function loadBlacklistAccountOptions(force = false) {
const accountSelect = document.getElementById('blacklistCookieId');
if (!accountSelect) return;
if (blacklistState.accountsLoaded && !force) return;
try {
const currentValue = accountSelect.value;
const accounts = await fetchJSON(`${apiBase}/cookies/details`);
const safeAccounts = Array.isArray(accounts) ? accounts : [];
accountSelect.innerHTML = '<option value="">全部账号</option>' + safeAccounts.map(account => {
const accountId = String(account.id || '').trim();
const remark = String(account.remark || '').trim();
const label = remark ? `${accountId}${remark}` : accountId;
return `<option value="${escapeHtml(accountId)}">${escapeHtml(label)}</option>`;
}).join('');
if (currentValue && safeAccounts.some(account => String(account.id || '') === currentValue)) {
accountSelect.value = currentValue;
}
blacklistState.accountsLoaded = true;
} catch (error) {
console.error('加载黑名单账号选项失败:', error);
}
}
function handleBlacklistFilterKeydown(event) {
if (event.key === 'Enter') {
event.preventDefault();
loadPersonalBlacklist(1);
}
}
async function loadPersonalBlacklist(page = 1) {
const tableBody = document.getElementById('blacklistTableBody');
if (!tableBody) return;
const pageSizeSelect = document.getElementById('blacklistPageSize');
const pageSize = Math.max(1, parseInt(pageSizeSelect?.value || '20', 10) || 20);
const safePage = Math.max(1, parseInt(page, 10) || 1);
const params = new URLSearchParams({
page: String(safePage),
page_size: String(pageSize)
});
const buyerId = document.getElementById('blacklistFilterBuyerId')?.value?.trim();
const buyerNick = document.getElementById('blacklistFilterBuyerNick')?.value?.trim();
if (buyerId) params.set('buyer_id', buyerId);
if (buyerNick) params.set('buyer_nick', buyerNick);
try {
const result = await fetchJSON(`${apiBase}/api/blacklist/personal?${params.toString()}`);
const records = Array.isArray(result?.data) ? result.data : [];
blacklistState.page = Number(result?.page || safePage);
blacklistState.pageSize = Number(result?.page_size || pageSize);
blacklistState.total = Number(result?.total || 0);
renderPersonalBlacklist(records);
renderBlacklistPagination();
} catch (error) {
console.error('加载个人黑名单失败:', error);
tableBody.innerHTML = `
<tr>
<td colspan="8" class="text-center py-4 text-danger">
<i class="bi bi-exclamation-triangle fs-1 d-block mb-3"></i>
加载黑名单失败
</td>
</tr>
`;
}
}
function getBlacklistScopeBadge(scope) {
const normalizedScope = String(scope || 'user');
const config = {
item: { text: '商品级', cls: 'bg-warning text-dark' },
account: { text: '账号级', cls: 'bg-info text-dark' },
user: { text: '用户级', cls: 'bg-secondary' }
}[normalizedScope] || { text: normalizedScope || '未知', cls: 'bg-secondary' };
return `<span class="badge ${config.cls}">${escapeHtml(config.text)}</span>`;
}
function getBlacklistTargetHtml(record) {
const cookieId = String(record?.cookie_id || '').trim();
const itemId = String(record?.item_id || '').trim();
const parts = [];
parts.push(cookieId ? `账号 ${cookieId}` : '全部账号');
if (itemId) parts.push(`商品 ${itemId}`);
return parts.map(part => `<div class="small text-muted text-nowrap" title="${escapeHtml(part)}">${escapeHtml(part)}</div>`).join('');
}
function renderPersonalBlacklist(records) {
const tableBody = document.getElementById('blacklistTableBody');
const totalText = document.getElementById('blacklistTotalText');
const selectAll = document.getElementById('blacklistSelectAll');
if (!tableBody) return;
if (totalText) {
totalText.textContent = `${blacklistState.total || 0}`;
}
if (selectAll) {
selectAll.checked = false;
selectAll.indeterminate = false;
}
if (!Array.isArray(records) || records.length === 0) {
tableBody.innerHTML = `
<tr>
<td colspan="8" class="text-center py-4 text-muted">
<i class="bi bi-person-x fs-1 d-block mb-3"></i>
暂无黑名单记录
</td>
</tr>
`;
updateBlacklistBatchDeleteState();
return;
}
tableBody.innerHTML = records.map(record => {
const recordId = Number(record.id || 0);
const buyerId = String(record.buyer_id || '').trim();
const buyerNick = String(record.buyer_nick || '').trim();
const reason = String(record.reason || '').trim();
const enabled = Boolean(record.is_enabled);
const createdAt = formatDateTime(record.created_at || record.updated_at || '');
return `
<tr>
<td>
<input class="form-check-input blacklist-row-check" type="checkbox" data-id="${recordId}" onchange="updateBlacklistBatchDeleteState()">
</td>
<td>${getBlacklistScopeBadge(record.scope)}</td>
<td>
<div class="fw-semibold" title="${escapeHtml(buyerId)}">${escapeHtml(buyerId)}</div>
${buyerNick ? `<div class="small text-muted" title="${escapeHtml(buyerNick)}">${escapeHtml(buyerNick)}</div>` : ''}
</td>
<td>${getBlacklistTargetHtml(record)}</td>
<td style="max-width: 220px;">
<span class="d-inline-block text-truncate" style="max-width: 100%;" title="${escapeHtml(reason)}">${escapeHtml(reason || '-')}</span>
</td>
<td>
<div class="form-check form-switch m-0" title="${enabled ? '点击禁用' : '点击启用'}">
<input class="form-check-input" type="checkbox" ${enabled ? 'checked' : ''} onchange="togglePersonalBlacklist(${recordId}, this.checked)">
</div>
</td>
<td><small class="text-muted text-nowrap">${escapeHtml(createdAt)}</small></td>
<td>
<button type="button" class="btn btn-outline-danger btn-sm" onclick="deletePersonalBlacklist(${recordId})" title="删除">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`;
}).join('');
updateBlacklistBatchDeleteState();
}
function renderBlacklistPagination() {
const pagination = document.getElementById('blacklistPagination');
const pageText = document.getElementById('blacklistPageText');
if (!pagination) return;
const pageSize = Math.max(1, Number(blacklistState.pageSize || 20));
const total = Math.max(0, Number(blacklistState.total || 0));
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(Math.max(1, Number(blacklistState.page || 1)), totalPages);
if (currentPage !== blacklistState.page && total > 0) {
loadPersonalBlacklist(currentPage);
return;
}
if (pageText) {
pageText.textContent = `${currentPage} / ${totalPages}`;
}
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, startPage + 4);
const buttons = [];
const addButton = (label, targetPage, disabled = false, active = false, title = '') => {
buttons.push(`
<button type="button" class="btn btn-sm ${active ? 'btn-primary' : 'btn-outline-secondary'}" ${disabled ? 'disabled' : ''} onclick="loadPersonalBlacklist(${targetPage})" title="${escapeHtml(title || label)}">
${label}
</button>
`);
};
addButton('<i class="bi bi-chevron-left"></i>', currentPage - 1, currentPage <= 1, false, '上一页');
for (let page = startPage; page <= endPage; page += 1) {
addButton(String(page), page, false, page === currentPage);
}
addButton('<i class="bi bi-chevron-right"></i>', currentPage + 1, currentPage >= totalPages, false, '下一页');
pagination.innerHTML = buttons.join('');
}
async function createPersonalBlacklist() {
const buyerIds = document.getElementById('blacklistBuyerIds')?.value?.trim() || '';
if (!buyerIds) {
showToast('请填写买家ID', 'warning');
return;
}
const payload = {
buyer_ids: buyerIds,
cookie_id: document.getElementById('blacklistCookieId')?.value?.trim() || null,
item_id: document.getElementById('blacklistItemId')?.value?.trim() || null,
buyer_nick: document.getElementById('blacklistBuyerNick')?.value?.trim() || '',
reason: document.getElementById('blacklistReason')?.value?.trim() || '',
is_enabled: Boolean(document.getElementById('blacklistEnabled')?.checked)
};
try {
const result = await fetchJSON(`${apiBase}/api/blacklist/personal`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
showToast(result?.message || '黑名单已保存', 'success');
resetPersonalBlacklistForm();
await loadPersonalBlacklist(1);
} catch (error) {
console.error('新增个人黑名单失败:', error);
}
}
function resetPersonalBlacklistForm() {
const form = document.getElementById('personalBlacklistForm');
if (form) form.reset();
const enabled = document.getElementById('blacklistEnabled');
if (enabled) enabled.checked = true;
}
async function togglePersonalBlacklist(recordId, isEnabled) {
try {
await fetchJSON(`${apiBase}/api/blacklist/personal/${recordId}/toggle`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_enabled: Boolean(isEnabled) })
});
showToast(isEnabled ? '黑名单已启用' : '黑名单已禁用', 'success');
} catch (error) {
console.error('更新黑名单状态失败:', error);
await loadPersonalBlacklist(blacklistState.page || 1);
}
}
async function deletePersonalBlacklist(recordId) {
if (!confirm('确定删除这条黑名单记录吗?')) return;
try {
const result = await fetchJSON(`${apiBase}/api/blacklist/personal/${recordId}`, {
method: 'DELETE'
});
showToast(result?.message || '黑名单已删除', 'success');
await loadPersonalBlacklist(blacklistState.page || 1);
} catch (error) {
console.error('删除个人黑名单失败:', error);
}
}
function getSelectedBlacklistIds() {
return Array.from(document.querySelectorAll('.blacklist-row-check:checked'))
.map(checkbox => parseInt(checkbox.dataset.id || '0', 10))
.filter(id => id > 0);
}
function toggleBlacklistSelectAll(checked) {
document.querySelectorAll('.blacklist-row-check').forEach(checkbox => {
checkbox.checked = Boolean(checked);
});
updateBlacklistBatchDeleteState();
}
function updateBlacklistBatchDeleteState() {
const selectedIds = getSelectedBlacklistIds();
const batchButton = document.getElementById('blacklistBatchDeleteBtn');
const selectAll = document.getElementById('blacklistSelectAll');
const rowChecks = Array.from(document.querySelectorAll('.blacklist-row-check'));
if (batchButton) {
batchButton.disabled = selectedIds.length === 0;
batchButton.innerHTML = selectedIds.length > 0
? `<i class="bi bi-trash me-1"></i>批量删除 (${selectedIds.length})`
: '<i class="bi bi-trash me-1"></i>批量删除';
}
if (selectAll) {
selectAll.checked = rowChecks.length > 0 && selectedIds.length === rowChecks.length;
selectAll.indeterminate = selectedIds.length > 0 && selectedIds.length < rowChecks.length;
}
}
async function batchDeletePersonalBlacklist() {
const selectedIds = getSelectedBlacklistIds();
if (selectedIds.length === 0) {
showToast('请先选择要删除的黑名单', 'warning');
return;
}
if (!confirm(`确定删除选中的 ${selectedIds.length} 条黑名单记录吗?`)) return;
try {
const result = await fetchJSON(`${apiBase}/api/blacklist/personal/batch-delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: selectedIds })
});
showToast(result?.message || '批量删除完成', 'success');
await loadPersonalBlacklist(blacklistState.page || 1);
} catch (error) {
console.error('批量删除个人黑名单失败:', error);
}
}
function resetBlacklistFilters() {
const buyerId = document.getElementById('blacklistFilterBuyerId');
const buyerNick = document.getElementById('blacklistFilterBuyerNick');
if (buyerId) buyerId.value = '';
if (buyerNick) buyerNick.value = '';
loadPersonalBlacklist(1);
}
async function exportPersonalBlacklist() {
toggleLoading(true);
try {
const response = await fetch(`${apiBase}/api/blacklist/personal/export`, {
headers: { 'Authorization': `Bearer ${getAuthToken()}` }
});
if (response.status === 401) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
if (!response.ok) {
let message = `导出失败: HTTP ${response.status}`;
try {
const errorText = await response.text();
if (errorText) message = errorText;
} catch {}
throw new Error(message);
}
const blob = await response.blob();
const disposition = response.headers.get('Content-Disposition') || '';
const filenameMatch = disposition.match(/filename="?([^";]+)"?/i);
const filename = filenameMatch ? filenameMatch[1] : `personal_blacklist_${Date.now()}.xlsx`;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
showToast('黑名单已导出', 'success');
} catch (error) {
console.error('导出个人黑名单失败:', error);
showToast(error.message || '导出个人黑名单失败', 'danger');
} finally {
toggleLoading(false);
}
}
async function importPersonalBlacklistFile() {
const input = document.getElementById('blacklistImportFile');
const file = input?.files?.[0];
if (!file) return;
if (!file.name.toLowerCase().endsWith('.xlsx')) {
showToast('仅支持 .xlsx 文件', 'warning');
input.value = '';
return;
}
const formData = new FormData();
formData.append('file', file);
try {
const result = await fetchJSON(`${apiBase}/api/blacklist/personal/import`, {
method: 'POST',
body: formData
});
showToast(result?.message || '黑名单导入完成', 'success');
await loadPersonalBlacklist(1);
} catch (error) {
console.error('导入个人黑名单失败:', error);
} finally {
input.value = '';
}
}
function getAboutDiagnosticsElements() {
return {
accountSelect: document.getElementById('aboutDiagnosticsAccount'),
@@ -9313,6 +9711,7 @@ const DEFAULT_MENU_ITEMS = [
{ id: 'notification-channels', name: '通知渠道', icon: 'bi-bell', required: false },
{ id: 'message-notifications', name: '消息通知', icon: 'bi-chat-dots', required: false },
{ id: 'online-im', name: '在线客服', icon: 'bi-headset', required: false },
{ id: 'blacklist', name: '黑名单管理', icon: 'bi-person-x', required: false },
{ id: 'system-settings', name: '系统设置', icon: 'bi-gear', required: true },
{ id: 'about', name: '关于', icon: 'bi-info-circle', required: true }
];