Merge branch 'main' into dev: 同步分配管理优化、登录异常分型及历史数据刷正
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
"""同步历史重登失败分型:根据最新 classify_failure 规则刷正 accounts 和 login_tasks
|
||||
|
||||
## 背景
|
||||
|
||||
新增了「必须绑定手机号」与「账号注销/封禁」分型,历史已存在报错文案的账号需要回写正确的 failure_kind,
|
||||
使「登录异常一键筛选」与列表角标统计准确。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from web.backend.services.login_failure import classify_failure
|
||||
|
||||
revision: str = "20261008_0052"
|
||||
down_revision: str | None = "20261004_0051"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# 1. 查询 accounts 中有 relogin_last_error 的记录
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id, relogin_last_error, relogin_failure_kind "
|
||||
"FROM accounts "
|
||||
"WHERE relogin_last_error IS NOT NULL AND relogin_last_error != ''"
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
for row in rows:
|
||||
acc_id, err_text, current_kind = row[0], row[1], row[2]
|
||||
new_kind = classify_failure(err_text)
|
||||
if new_kind and new_kind != current_kind:
|
||||
conn.execute(
|
||||
sa.text("UPDATE accounts SET relogin_failure_kind = :k WHERE id = :id"),
|
||||
{"k": new_kind, "id": acc_id},
|
||||
)
|
||||
|
||||
# 2. 查询 login_tasks 中有 message 的记录
|
||||
# 检查 login_tasks 是否有 relogin_failure_kind 列
|
||||
col_exists = conn.execute(
|
||||
sa.text(
|
||||
"SELECT COUNT(*) FROM information_schema.columns "
|
||||
"WHERE table_schema = DATABASE() "
|
||||
"AND table_name = 'login_tasks' AND column_name = 'relogin_failure_kind'"
|
||||
)
|
||||
).scalar()
|
||||
if col_exists:
|
||||
task_rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id, message, relogin_failure_kind "
|
||||
"FROM login_tasks "
|
||||
"WHERE message IS NOT NULL AND message != ''"
|
||||
)
|
||||
).fetchall()
|
||||
for row in task_rows:
|
||||
task_id, msg, current_kind = row[0], row[1], row[2]
|
||||
new_kind = classify_failure(msg)
|
||||
if new_kind and new_kind != current_kind:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE login_tasks SET relogin_failure_kind = :k WHERE id = :id"
|
||||
),
|
||||
{"k": new_kind, "id": task_id},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
+2
-2
@@ -25,8 +25,8 @@ from __future__ import annotations
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20261007_0052"
|
||||
down_revision: str | None = "20261004_0051"
|
||||
revision: str = "20261008_0053"
|
||||
down_revision: str | None = "20261008_0052"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
+159
-16
@@ -1,8 +1,8 @@
|
||||
"""账号管理路由"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from sqlalchemy import and_, case, func, or_, select, text
|
||||
from sqlalchemy.orm import Session, defer, joinedload
|
||||
|
||||
@@ -24,6 +24,7 @@ from ..schemas import (
|
||||
AccountAutomationUpdate,
|
||||
AccountBulkSelection,
|
||||
AccountBulkTag,
|
||||
AccountExportRequest,
|
||||
AccountImport,
|
||||
AccountLifecycleUpdate,
|
||||
AccountOut,
|
||||
@@ -69,6 +70,7 @@ from ..services.account_value import (
|
||||
)
|
||||
from ..services.audit_service import record_account_action, record_audit
|
||||
from ..services.douyu_service import cookie_holding_filter
|
||||
from ..services.login_failure import classify_failure, failure_kind_label
|
||||
|
||||
router = APIRouter(prefix="/api/accounts", tags=["账号管理"])
|
||||
|
||||
@@ -104,6 +106,10 @@ def _query_str(value) -> str:
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _query_bool(value) -> bool:
|
||||
return value is True
|
||||
|
||||
|
||||
def _filter_accounts_query(
|
||||
db: Session,
|
||||
query,
|
||||
@@ -117,6 +123,7 @@ def _filter_accounts_query(
|
||||
search: str = "",
|
||||
status: str = "",
|
||||
ck_status: str = "",
|
||||
failure_kind: str = "",
|
||||
bind_status: str = "",
|
||||
high_value_filter: str = "",
|
||||
min_points: int | None = None,
|
||||
@@ -128,9 +135,9 @@ def _filter_accounts_query(
|
||||
role_bind_status: str = "",
|
||||
):
|
||||
"""复用列表筛选条件,供分页列表和全量批量操作保持一致。"""
|
||||
if has_cookie:
|
||||
query = query.filter(Account.id.in_(cookie_account_ids_query(db)))
|
||||
if assigned_only and user_has_permission(current, "account:view_all"):
|
||||
if _query_bool(has_cookie):
|
||||
query = query.filter(Account.cookie.isnot(None), Account.cookie != "")
|
||||
if _query_bool(assigned_only) and user_has_permission(current, "account:view_all"):
|
||||
query = query.filter(Account.assigned_to.isnot(None))
|
||||
# 归属筛选:未分配 / 已分配,再叠加「指定客服」。与其它条件(标签、CK、
|
||||
# 搜索…)是**同时生效**的,这正是「综合筛选」的用法。
|
||||
@@ -155,6 +162,9 @@ def _filter_accounts_query(
|
||||
ck_value = _query_str(ck_status)
|
||||
if ck_value:
|
||||
query = query.filter(Account.ck_check_status == ck_value)
|
||||
fk_value = _query_str(failure_kind)
|
||||
if fk_value:
|
||||
query = query.filter(Account.relogin_failure_kind == fk_value)
|
||||
bind_value = _query_str(bind_status)
|
||||
if bind_value:
|
||||
# 三个作用域任一命中即算(列表上「绑定」是一列,不分开筛)
|
||||
@@ -252,6 +262,7 @@ def _selected_account_ids(
|
||||
search=req.search,
|
||||
status=req.status,
|
||||
ck_status=req.ck_status,
|
||||
failure_kind=req.failure_kind,
|
||||
bind_status=req.bind_status,
|
||||
high_value_filter=req.high_value_filter,
|
||||
min_points=req.min_points,
|
||||
@@ -405,6 +416,10 @@ def list_accounts(
|
||||
ck_status: str = Query(
|
||||
"", description="按 CK 健康结论筛选:valid / invalid / error"
|
||||
),
|
||||
failure_kind: str = Query(
|
||||
"",
|
||||
description="按最近登录失败分型筛选:must_bind_mobile/captcha/credential/email_code/email_quota/network",
|
||||
),
|
||||
bind_status: str = Query(
|
||||
"", description="按角色绑定筛选:unknown/unbound/pending/bound"
|
||||
),
|
||||
@@ -445,6 +460,7 @@ def list_accounts(
|
||||
search=search,
|
||||
status=status,
|
||||
ck_status=ck_status,
|
||||
failure_kind=failure_kind,
|
||||
bind_status=bind_status,
|
||||
high_value_filter=high_value_filter,
|
||||
min_points=min_points,
|
||||
@@ -565,6 +581,12 @@ def list_accounts(
|
||||
|
||||
result = []
|
||||
for acc in accounts:
|
||||
eff_kind = acc.relogin_failure_kind or ""
|
||||
if acc.relogin_last_error:
|
||||
refined = classify_failure(acc.relogin_last_error)
|
||||
if not eff_kind or (eff_kind == "network" and refined != "network"):
|
||||
eff_kind = refined
|
||||
|
||||
item = AccountOut(
|
||||
id=acc.id,
|
||||
username=acc.username,
|
||||
@@ -577,6 +599,9 @@ def list_accounts(
|
||||
status_label=STATUS_LABELS.get(acc.status or "", acc.status or ""),
|
||||
status_reason=acc.status_reason or "",
|
||||
auto_managed=bool(acc.auto_managed),
|
||||
relogin_failure_kind=eff_kind,
|
||||
relogin_failure_label=failure_kind_label(eff_kind),
|
||||
relogin_last_error=acc.relogin_last_error or "",
|
||||
# 关键列:全部是 accounts 行上的字段,无 JOIN / 无聚合
|
||||
has_cookie=bool(acc.cookie),
|
||||
ck_check_status=acc.ck_check_status or "",
|
||||
@@ -646,6 +671,19 @@ def accounts_summary(
|
||||
)
|
||||
by_status = {str(row[0] or ""): int(row[1] or 0) for row in status_rows}
|
||||
visible_total = sum(by_status.values())
|
||||
|
||||
# 登录失败分型统计(不含空值与归档)
|
||||
failure_rows = (
|
||||
visible.filter(
|
||||
Account.relogin_failure_kind != "",
|
||||
Account.relogin_failure_kind.isnot(None),
|
||||
)
|
||||
.with_entities(Account.relogin_failure_kind, func.count(Account.id))
|
||||
.group_by(Account.relogin_failure_kind)
|
||||
.all()
|
||||
)
|
||||
login_failures = {str(row[0] or ""): int(row[1] or 0) for row in failure_rows}
|
||||
|
||||
# 高价值及核心资产分布(单次聚合,供前端快捷胶囊展示)
|
||||
bairimeng_expr = suit_owned_expr(Account.high_value_owned, SUIT_BAIRIMENG)
|
||||
mengtu_expr = suit_owned_expr(Account.high_value_owned, SUIT_MENGTU)
|
||||
@@ -681,6 +719,8 @@ def accounts_summary(
|
||||
"visible_total": visible_total,
|
||||
# 生命周期分布(键是 6 状态的枚举值)
|
||||
"by_status": by_status,
|
||||
# 登录异常分类统计
|
||||
"login_failures": login_failures,
|
||||
# 高价值与核心资产统计
|
||||
"high_value": high_value_summary,
|
||||
# 需要人关注的:CK 已被判失效的账号数(含待人工)
|
||||
@@ -702,6 +742,99 @@ def accounts_summary(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/export")
|
||||
def export_accounts(
|
||||
req: AccountExportRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""导出账号:支持纯文本账号名单(txt)或完整详情(csv)。"""
|
||||
selected_ids = _selected_account_ids(db, current, req)
|
||||
if not selected_ids:
|
||||
if req.format == "csv":
|
||||
content = "\ufeff账号,生命周期状态,登录异常分类,最新报错,标签,归属客服,鱼翅,积分,精英宝典,电竞手册,CK状态,备注\n"
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="accounts.csv"'},
|
||||
)
|
||||
return Response(
|
||||
content="",
|
||||
media_type="text/plain; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="accounts.txt"'},
|
||||
)
|
||||
|
||||
accounts = (
|
||||
db.query(Account)
|
||||
.options(joinedload(Account.assigned_user))
|
||||
.filter(Account.id.in_(selected_ids))
|
||||
.order_by(Account.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
if req.format == "txt":
|
||||
# 纯账号名字,换行分隔
|
||||
lines = [acc.username for acc in accounts if acc.username]
|
||||
text_content = "\n".join(lines)
|
||||
return Response(
|
||||
content=text_content,
|
||||
media_type="text/plain; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="accounts.txt"'},
|
||||
)
|
||||
|
||||
# 导出 CSV (带 UTF-8 BOM,防止 Excel 打开乱码)
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(
|
||||
[
|
||||
"账号",
|
||||
"生命周期状态",
|
||||
"登录异常分类",
|
||||
"最新报错",
|
||||
"标签",
|
||||
"归属客服",
|
||||
"鱼翅",
|
||||
"积分",
|
||||
"精英宝典",
|
||||
"电竞手册",
|
||||
"CK状态",
|
||||
"备注",
|
||||
]
|
||||
)
|
||||
for acc in accounts:
|
||||
eff_kind = acc.relogin_failure_kind or ""
|
||||
if acc.relogin_last_error:
|
||||
refined = classify_failure(acc.relogin_last_error)
|
||||
if not eff_kind or (eff_kind == "network" and refined != "network"):
|
||||
eff_kind = refined
|
||||
writer.writerow(
|
||||
[
|
||||
acc.username or "",
|
||||
STATUS_LABELS.get(acc.status or "", acc.status or ""),
|
||||
failure_kind_label(eff_kind) if eff_kind else "",
|
||||
acc.relogin_last_error or "",
|
||||
acc.tag or "",
|
||||
acc.assigned_user.username if acc.assigned_user else "未分配",
|
||||
acc.gold_balance if acc.gold_balance is not None else "",
|
||||
acc.points if acc.points is not None else "",
|
||||
"已开通"
|
||||
if acc.elite_opened is True
|
||||
else ("未开通" if acc.elite_opened is False else "未知"),
|
||||
"已开通"
|
||||
if acc.esports_opened is True
|
||||
else ("未开通" if acc.esports_opened is False else "未知"),
|
||||
acc.ck_check_status or "",
|
||||
acc.remark or "",
|
||||
]
|
||||
)
|
||||
csv_content = "\ufeff" + output.getvalue()
|
||||
return Response(
|
||||
content=csv_content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="accounts.csv"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
def import_accounts(
|
||||
req: AccountImport,
|
||||
@@ -897,30 +1030,40 @@ def assignments_summary(
|
||||
current: User = Depends(require_permission("account:assign")),
|
||||
):
|
||||
"""分配概览:每个客服分配了多少账号(仅统计当前仍持有 CK 的账号)。"""
|
||||
cookie_subq = select(Account.id).where(cookie_holding_filter())
|
||||
cookie_accounts = db.query(Account).filter(Account.id.in_(cookie_subq)).subquery()
|
||||
|
||||
results = (
|
||||
db.query(
|
||||
User.id, User.username, func.count(cookie_accounts.c.id).label("count")
|
||||
db.query(User.id, User.username, func.count(Account.id).label("count"))
|
||||
.outerjoin(
|
||||
Account,
|
||||
and_(
|
||||
Account.assigned_to == User.id,
|
||||
Account.cookie.isnot(None),
|
||||
Account.cookie != "",
|
||||
),
|
||||
)
|
||||
.outerjoin(cookie_accounts, cookie_accounts.c.assigned_to == User.id)
|
||||
.filter(User.role == "support")
|
||||
.group_by(User.id, User.username)
|
||||
.order_by(func.count(cookie_accounts.c.id).desc())
|
||||
.order_by(func.count(Account.id).desc())
|
||||
.all()
|
||||
)
|
||||
total_unassigned = (
|
||||
db.query(func.count(cookie_accounts.c.id))
|
||||
.filter(cookie_accounts.c.assigned_to.is_(None))
|
||||
db.query(func.count(Account.id))
|
||||
.filter(
|
||||
Account.assigned_to.is_(None),
|
||||
Account.cookie.isnot(None),
|
||||
Account.cookie != "",
|
||||
)
|
||||
.scalar()
|
||||
) or 0
|
||||
total_assigned = sum(int(cnt or 0) for _, _, cnt in results)
|
||||
total_accounts = total_assigned + total_unassigned
|
||||
return {
|
||||
"support_users": [
|
||||
{"id": uid, "username": uname, "assigned_count": cnt}
|
||||
{"id": uid, "username": uname, "assigned_count": int(cnt or 0)}
|
||||
for uid, uname, cnt in results
|
||||
],
|
||||
"unassigned_count": total_unassigned,
|
||||
"assigned_count": total_assigned,
|
||||
"total_count": total_accounts,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ class AccountBulkSelection(BaseModel):
|
||||
has_cookie: bool = False
|
||||
status: str = ""
|
||||
ck_status: str = ""
|
||||
failure_kind: str = ""
|
||||
bind_status: str = ""
|
||||
high_value_filter: str = ""
|
||||
min_points: int | None = None
|
||||
@@ -149,6 +150,12 @@ class AccountBulkSelection(BaseModel):
|
||||
role_bind_status: str = ""
|
||||
|
||||
|
||||
class AccountExportRequest(AccountBulkSelection):
|
||||
"""账号导出请求:支持纯账号名单(txt)或结构化明细(csv)。"""
|
||||
|
||||
format: str = Field("txt", pattern="^(txt|csv)$")
|
||||
|
||||
|
||||
class BatchAssign(AccountBulkSelection):
|
||||
"""批量分配 / 取消分配。
|
||||
|
||||
@@ -206,6 +213,10 @@ class AccountOut(BaseModel):
|
||||
status_label: str = ""
|
||||
status_reason: str = ""
|
||||
auto_managed: bool = True
|
||||
# 登录失败分型(2026-10-07):客服排查重登/登录异常
|
||||
relogin_failure_kind: str = ""
|
||||
relogin_failure_label: str = ""
|
||||
relogin_last_error: str = ""
|
||||
# 列表页的「关键列」:全部直接来自 accounts 行,不需要任何 JOIN 或聚合
|
||||
# (客服一眼要能看出「这账号能不能用、要不要我处理」)。
|
||||
has_cookie: bool = False
|
||||
@@ -250,6 +261,9 @@ class AccountOut(BaseModel):
|
||||
"status_label": self.status_label,
|
||||
"status_reason": self.status_reason,
|
||||
"auto_managed": self.auto_managed,
|
||||
"relogin_failure_kind": self.relogin_failure_kind,
|
||||
"relogin_failure_label": self.relogin_failure_label,
|
||||
"relogin_last_error": self.relogin_last_error,
|
||||
"has_cookie": self.has_cookie,
|
||||
"ck_check_status": self.ck_check_status,
|
||||
"ck_checked_at": _isoformat(self.ck_checked_at),
|
||||
|
||||
@@ -71,7 +71,7 @@ AUTOMATABLE_STATES: tuple[str, ...] = DERIVED_STATES
|
||||
|
||||
# 重登失败分型 → 需要人工(自动重试只会加重风控 / 重试多少次都一样)。
|
||||
# 放在这里而不是 job 里:`derive_status` 要用它,service 不该反过来依赖 job。
|
||||
MANUAL_KINDS = frozenset({"captcha", "credential"})
|
||||
MANUAL_KINDS = frozenset({"captcha", "credential", "must_bind_mobile"})
|
||||
|
||||
STATUS_LABELS = {
|
||||
STATUS_IMPORTED: "未登录",
|
||||
|
||||
@@ -24,6 +24,7 @@ from __future__ import annotations
|
||||
|
||||
#: 型别 → 中文标签(导出 CSV 与页面上用同一份,避免两处各写一遍漂移)。
|
||||
FAILURE_KIND_LABELS: dict[str, str] = {
|
||||
"must_bind_mobile": "必须绑定手机号",
|
||||
"captcha": "极验/验证码拦截",
|
||||
"credential": "账号凭证问题",
|
||||
"email_code": "邮箱取码失败",
|
||||
@@ -39,20 +40,36 @@ NOT_AN_ACCOUNT_FAULT: frozenset[str] = frozenset({"interrupted"})
|
||||
def classify_failure(message: str | None) -> str:
|
||||
"""把重登/登录失败消息映射成分型(见规划 §7.4.3)。
|
||||
|
||||
顺序很重要:「邮箱验证码发送次数已达上限」既含「验证码」也含「上限」,
|
||||
必须先在 ``email_quota`` 命中,否则会被归到可重试的 ``email_code`` 上,
|
||||
导致次日才该重试的账号被按小时反复撞配额。
|
||||
顺序很重要:
|
||||
1. 「必须绑定手机号 / 跳过手机号绑定失败」优先归入 ``must_bind_mobile``;
|
||||
2. 「邮箱验证码发送次数已达上限」既含「验证码」也含「上限」,必须先在 ``email_quota`` 命中;
|
||||
3. 极验滑块命中 ``captcha``。
|
||||
"""
|
||||
text = message or ""
|
||||
if "必须绑定手机号" in text or "跳过手机号绑定失败" in text or "绑定手机" in text:
|
||||
return "must_bind_mobile"
|
||||
if "上限" in text or "配额" in text:
|
||||
return "email_quota"
|
||||
if "极验" in text or "验证" in text and "滑块" in text:
|
||||
if "极验" in text or ("验证" in text and "滑块" in text):
|
||||
return "captcha"
|
||||
if "验证码" in text:
|
||||
if "验证码" in text or "取码" in text:
|
||||
return "email_code"
|
||||
if "中断" in text or "已停止" in text or "任务已停止" in text:
|
||||
return "interrupted"
|
||||
if "密码" in text or "账号不存在" in text or "缺少密码或邮箱" in text:
|
||||
if any(
|
||||
k in text
|
||||
for k in (
|
||||
"密码",
|
||||
"账号不存在",
|
||||
"缺少密码或邮箱",
|
||||
"注销",
|
||||
"已注销",
|
||||
"封禁",
|
||||
"冻结",
|
||||
"限制登录",
|
||||
"禁止登录",
|
||||
)
|
||||
):
|
||||
return "credential"
|
||||
# 请求失败/超时/连接异常,以及未知文案:都按可重试处理。
|
||||
# 宁可多给一次机会(有每日上限兜底),也不要把可能只是抖动的账号推给人工。
|
||||
|
||||
@@ -27,7 +27,7 @@ from .account_events_service import (
|
||||
EVENT_RELOGIN_OK,
|
||||
emit,
|
||||
)
|
||||
from .account_lifecycle import on_ck_verdict, on_login_success
|
||||
from .account_lifecycle import on_ck_verdict, on_login_success, sync_status
|
||||
from .cookie_check_service import check_douyu_cookie
|
||||
from .douyu_service import LOGIN_ATTEMPT_IN_FLIGHT_STATUSES
|
||||
from .login_failure import classify_failure, failure_kind_label, is_account_fault
|
||||
@@ -155,6 +155,9 @@ def _mark_attempt_failed(
|
||||
if db is None or account is None:
|
||||
# 没有会话(纯内存构造的用例)或没有账号:跳过旁路事件,绝不影响主流程。
|
||||
return
|
||||
account.relogin_failure_kind = kind
|
||||
account.relogin_last_error = (message or "")[:512]
|
||||
sync_status(db, account, occurred_at=completed_at)
|
||||
emit(
|
||||
db,
|
||||
account_id=account.id,
|
||||
|
||||
Reference in New Issue
Block a user