606 lines
18 KiB
Python
606 lines
18 KiB
Python
"""微博微豆账号导入、刷新与绑定 Scheme 服务。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import UTC, datetime
|
||
|
||
from sqlalchemy import or_
|
||
from sqlalchemy.orm import Session
|
||
|
||
from core.weibo import (
|
||
WeiboAuthError,
|
||
WeiboClient,
|
||
WeiboError,
|
||
WeiboExchangeResult,
|
||
WeiboHomeSnapshot,
|
||
WeiboPaymentStatus,
|
||
WeiboRechargeOrder,
|
||
normalize_weibo_cookie,
|
||
)
|
||
|
||
from ..models import User, WeiboAccount, WeiboRechargeRecord, WeiboWorkbenchAccount
|
||
from .weibo_image_service import embed_weibo_goods_images
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class WeiboImportIssue:
|
||
line: int
|
||
message: str
|
||
account_id: int | None = None
|
||
uid: str = ""
|
||
assigned_user_id: int | None = None
|
||
assigned_username: str = ""
|
||
workbench_owner_id: int | None = None
|
||
workbench_owner_username: str = ""
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class WeiboImportDuplicate:
|
||
line: int
|
||
uid: str
|
||
nickname: str
|
||
message: str
|
||
|
||
|
||
def _now() -> datetime:
|
||
return datetime.now(UTC)
|
||
|
||
|
||
def _error_status(exc: Exception) -> str:
|
||
return "invalid" if isinstance(exc, WeiboAuthError) else "error"
|
||
|
||
|
||
def _apply_snapshot(
|
||
account: WeiboAccount,
|
||
snapshot: WeiboHomeSnapshot,
|
||
cookie: str,
|
||
) -> None:
|
||
account.uid = snapshot.uid
|
||
account.nickname = snapshot.nickname
|
||
account.cookie = cookie
|
||
account.cookie_updated_at = _now()
|
||
account.is_bound = snapshot.is_bound
|
||
account.game_role_name = snapshot.role_name
|
||
account.can_rebind = snapshot.can_rebind
|
||
account.rebind_remain_days = snapshot.rebind_remain_days
|
||
account.weidou_balance = snapshot.weidou_balance
|
||
if snapshot.activity:
|
||
account.activity = snapshot.activity
|
||
account.ck_check_status = "valid"
|
||
account.ck_check_message = ""
|
||
account.ck_checked_at = _now()
|
||
|
||
|
||
def import_weibo_cookies(
|
||
db: Session,
|
||
text: str,
|
||
tag: str = "",
|
||
activity: str = "",
|
||
update_existing: bool = False,
|
||
current_user_id: int | None = None,
|
||
can_update_all: bool = False,
|
||
force_update: bool = False,
|
||
) -> tuple[
|
||
list[WeiboAccount],
|
||
list[WeiboImportIssue],
|
||
int,
|
||
list[WeiboImportDuplicate],
|
||
]:
|
||
"""逐行验证并导入微博 CK。
|
||
|
||
返回 ``(成功账号, 错误列表, 跳过数, 重复明细)``。单行 CK 失效或网络失败
|
||
只记录该行,不阻断其余账号导入。
|
||
"""
|
||
|
||
accounts: list[WeiboAccount] = []
|
||
errors: list[WeiboImportIssue] = []
|
||
duplicates: list[WeiboImportDuplicate] = []
|
||
skipped = 0
|
||
seen_uids: set[str] = set()
|
||
clean_tag = (tag or "").strip()
|
||
|
||
for line_no, raw_line in enumerate((text or "").splitlines(), 1):
|
||
raw = raw_line.strip()
|
||
if not raw:
|
||
continue
|
||
try:
|
||
cookie = normalize_weibo_cookie(raw)
|
||
client = WeiboClient(cookie)
|
||
snapshot = client.fetch_home(activity)
|
||
client.check_payment_access(activity, home=snapshot)
|
||
except (ValueError, WeiboError) as exc:
|
||
errors.append(WeiboImportIssue(line=line_no, message=str(exc)))
|
||
continue
|
||
|
||
if snapshot.uid in seen_uids:
|
||
skipped += 1
|
||
duplicates.append(
|
||
WeiboImportDuplicate(
|
||
line=line_no,
|
||
uid=snapshot.uid,
|
||
nickname=snapshot.nickname,
|
||
message="本次粘贴内容中该 UID 重复",
|
||
)
|
||
)
|
||
continue
|
||
seen_uids.add(snapshot.uid)
|
||
|
||
account = (
|
||
db.query(WeiboAccount).filter(WeiboAccount.uid == snapshot.uid).first()
|
||
)
|
||
if account is not None and update_existing and current_user_id is not None:
|
||
assigned_user = (
|
||
db.query(User).filter(User.id == account.assigned_to).first()
|
||
if account.assigned_to is not None
|
||
else None
|
||
)
|
||
workbench_owner = (
|
||
db.query(User)
|
||
.join(
|
||
WeiboWorkbenchAccount,
|
||
WeiboWorkbenchAccount.user_id == User.id,
|
||
)
|
||
.filter(
|
||
WeiboWorkbenchAccount.account_id == account.id,
|
||
WeiboWorkbenchAccount.exclusive_slot.is_not(None),
|
||
)
|
||
.first()
|
||
)
|
||
assigned_conflict = (
|
||
not can_update_all
|
||
and account.assigned_to is not None
|
||
and account.assigned_to != current_user_id
|
||
)
|
||
workbench_conflict = (
|
||
workbench_owner is not None and workbench_owner.id != current_user_id
|
||
)
|
||
if not force_update and (assigned_conflict or workbench_conflict):
|
||
assigned_label = (
|
||
assigned_user.username if assigned_user else account.assigned_to
|
||
)
|
||
workbench_label = workbench_owner.username if workbench_owner else ""
|
||
if (
|
||
assigned_conflict
|
||
and workbench_conflict
|
||
and workbench_owner is not None
|
||
):
|
||
conflict_message = (
|
||
"该账号已分配给其他客服:账号归属客服 "
|
||
f"{assigned_label}(ID {account.assigned_to}),"
|
||
f"且个人工作台排他槽位归 {workbench_label}"
|
||
f"(ID {workbench_owner.id})"
|
||
)
|
||
elif assigned_conflict:
|
||
conflict_message = (
|
||
"该账号已分配给其他客服:账号归属客服 "
|
||
f"{assigned_label}(ID {account.assigned_to})"
|
||
)
|
||
else:
|
||
assert workbench_owner is not None
|
||
conflict_message = (
|
||
"该账号已被其他人的工作台占用:个人工作台排他槽位归 "
|
||
f"{workbench_label}"
|
||
f"(ID {workbench_owner.id})"
|
||
)
|
||
errors.append(
|
||
WeiboImportIssue(
|
||
line=line_no,
|
||
message=(
|
||
conflict_message
|
||
+ ",不能更新 CK。超级管理员可勾选“强制更新”覆盖,原归属保持不变"
|
||
),
|
||
account_id=account.id,
|
||
uid=account.uid,
|
||
assigned_user_id=account.assigned_to,
|
||
assigned_username=(
|
||
assigned_user.username if assigned_user else ""
|
||
),
|
||
workbench_owner_id=(
|
||
workbench_owner.id if workbench_owner else None
|
||
),
|
||
workbench_owner_username=(
|
||
workbench_owner.username if workbench_owner else ""
|
||
),
|
||
)
|
||
)
|
||
continue
|
||
if account is not None and not update_existing:
|
||
skipped += 1
|
||
duplicates.append(
|
||
WeiboImportDuplicate(
|
||
line=line_no,
|
||
uid=snapshot.uid,
|
||
nickname=account.nickname or snapshot.nickname,
|
||
message="账号池中已存在,未勾选更新 CK",
|
||
)
|
||
)
|
||
continue
|
||
if account is None:
|
||
account = WeiboAccount(uid=snapshot.uid, cookie=cookie)
|
||
db.add(account)
|
||
_apply_snapshot(account, snapshot, cookie)
|
||
if clean_tag:
|
||
account.tag = clean_tag
|
||
accounts.append(account)
|
||
|
||
if accounts:
|
||
db.commit()
|
||
for account in accounts:
|
||
db.refresh(account)
|
||
return accounts, errors, skipped, duplicates
|
||
|
||
|
||
def refresh_weibo_account(
|
||
db: Session,
|
||
account: WeiboAccount,
|
||
*,
|
||
activity: str = "",
|
||
commit: bool = True,
|
||
) -> WeiboHomeSnapshot:
|
||
"""刷新一个微博账号;失败时保留旧资料并记录本次检查状态。"""
|
||
|
||
try:
|
||
client = WeiboClient(account.cookie or "")
|
||
snapshot = client.fetch_home(activity)
|
||
client.check_payment_access(activity, home=snapshot)
|
||
except (WeiboError, ValueError) as exc:
|
||
mark_weibo_account_refresh_failure(db, account, exc, commit=False)
|
||
if commit:
|
||
db.commit()
|
||
raise
|
||
|
||
_apply_snapshot(account, snapshot, account.cookie or "")
|
||
if commit:
|
||
db.commit()
|
||
db.refresh(account)
|
||
return snapshot
|
||
|
||
|
||
def mark_weibo_account_refresh_failure(
|
||
db: Session,
|
||
account: WeiboAccount,
|
||
exc: Exception,
|
||
*,
|
||
commit: bool = True,
|
||
) -> bool:
|
||
"""把一次刷新失败写回账号;业务校验失败不应被误标成 CK 失效。"""
|
||
|
||
if not isinstance(exc, (WeiboError, ValueError)):
|
||
return False
|
||
account.ck_check_status = _error_status(exc)
|
||
account.ck_check_message = str(exc)
|
||
account.ck_checked_at = _now()
|
||
if commit:
|
||
db.commit()
|
||
db.refresh(account)
|
||
return True
|
||
|
||
|
||
def update_weibo_account_metadata(
|
||
db: Session,
|
||
account: WeiboAccount,
|
||
*,
|
||
tag: str | None = None,
|
||
remark: str | None = None,
|
||
) -> dict[str, dict[str, str]]:
|
||
"""更新人工维护的账号标签与备注。"""
|
||
|
||
changes: dict[str, dict[str, str]] = {}
|
||
if tag is not None:
|
||
clean_tag = tag.strip()
|
||
if clean_tag != (account.tag or ""):
|
||
changes["tag"] = {"old": account.tag or "", "new": clean_tag}
|
||
account.tag = clean_tag
|
||
if remark is not None:
|
||
clean_remark = remark.strip()
|
||
if clean_remark != (account.remark or ""):
|
||
changes["remark"] = {
|
||
"old": account.remark or "",
|
||
"new": clean_remark,
|
||
}
|
||
account.remark = clean_remark
|
||
if changes:
|
||
db.commit()
|
||
db.refresh(account)
|
||
return changes
|
||
|
||
|
||
def disable_weibo_account(
|
||
db: Session,
|
||
account: WeiboAccount,
|
||
*,
|
||
reason: str = "",
|
||
) -> None:
|
||
"""停用账号;保留 CK 和资料,后续可重新启用。"""
|
||
|
||
if not account.is_enabled:
|
||
return
|
||
account.is_enabled = False
|
||
account.disabled_at = _now()
|
||
account.disabled_reason = reason.strip()[:256]
|
||
db.commit()
|
||
db.refresh(account)
|
||
|
||
|
||
def enable_weibo_account(db: Session, account: WeiboAccount) -> None:
|
||
"""启用已通过完整 CK 检测的账号。"""
|
||
|
||
if account.is_enabled:
|
||
return
|
||
account.is_enabled = True
|
||
account.disabled_at = None
|
||
account.disabled_reason = ""
|
||
db.commit()
|
||
db.refresh(account)
|
||
|
||
|
||
def generate_weibo_bind_schemes(
|
||
account: WeiboAccount,
|
||
bind_url: str,
|
||
) -> list[dict[str, str]]:
|
||
"""分别生成微信、QQ Scheme;单渠道失败不阻断另一渠道。"""
|
||
|
||
client = WeiboClient()
|
||
result: list[dict[str, str]] = []
|
||
for channel, label in (("wx", "微信"), ("qq", "QQ")):
|
||
try:
|
||
scheme = client.generate_bind_scheme(bind_url, channel)
|
||
result.append(
|
||
{"channel": channel, "label": label, "scheme": scheme, "error": ""}
|
||
)
|
||
except (WeiboError, ValueError) as exc:
|
||
result.append(
|
||
{
|
||
"channel": channel,
|
||
"label": label,
|
||
"scheme": "",
|
||
"error": str(exc),
|
||
}
|
||
)
|
||
return result
|
||
|
||
|
||
def generate_weibo_bind_qrcode(
|
||
account: WeiboAccount,
|
||
bind_url: str,
|
||
channel: str,
|
||
) -> dict[str, str]:
|
||
"""生成指定渠道的小程序码,并尽力附带可复制的 Scheme。"""
|
||
|
||
_ = account
|
||
label = {"wx": "微信", "qq": "QQ"}[channel]
|
||
client = WeiboClient()
|
||
try:
|
||
qrcode = client.generate_bind_qrcode(bind_url, channel)
|
||
except (WeiboError, ValueError) as exc:
|
||
return {
|
||
"channel": channel,
|
||
"label": label,
|
||
"scheme": "",
|
||
"qrcode_image": "",
|
||
"qrcode_token": "",
|
||
"error": str(exc),
|
||
}
|
||
|
||
scheme = ""
|
||
try:
|
||
scheme = client.generate_bind_scheme(bind_url, channel)
|
||
except (WeiboError, ValueError):
|
||
# 二维码图片是主结果,Scheme 仅用于复制,不影响扫码绑定。
|
||
pass
|
||
return {
|
||
"channel": channel,
|
||
"label": label,
|
||
"scheme": scheme,
|
||
"qrcode_image": qrcode.image_src,
|
||
"qrcode_token": qrcode.token,
|
||
"error": "",
|
||
}
|
||
|
||
|
||
def _effective_activity(account: WeiboAccount, fallback: str) -> str:
|
||
return account.activity or fallback
|
||
|
||
|
||
def _mark_manual_failure(
|
||
db: Session,
|
||
account: WeiboAccount,
|
||
exc: Exception,
|
||
) -> None:
|
||
"""只有明确登录态失效才改动 CK 状态,业务失败不能把有效 CK 标坏。"""
|
||
|
||
if isinstance(exc, WeiboAuthError):
|
||
account.ck_check_status = "invalid"
|
||
account.ck_check_message = str(exc)
|
||
account.ck_checked_at = _now()
|
||
db.commit()
|
||
|
||
|
||
def query_weibo_mall(
|
||
db: Session,
|
||
account: WeiboAccount,
|
||
*,
|
||
activity: str = "",
|
||
embed_images: bool = False,
|
||
) -> dict:
|
||
try:
|
||
data = WeiboClient(account.cookie or "").list_mall(
|
||
_effective_activity(account, activity)
|
||
)
|
||
except (WeiboError, ValueError) as exc:
|
||
_mark_manual_failure(db, account, exc)
|
||
raise
|
||
goods = data.get("list")
|
||
if embed_images and isinstance(goods, list):
|
||
embed_weibo_goods_images(
|
||
[item for item in goods if isinstance(item, dict)],
|
||
account_id=account.id,
|
||
)
|
||
if isinstance(data.get("weidou_balance"), int):
|
||
account.weidou_balance = data["weidou_balance"]
|
||
db.commit()
|
||
return data
|
||
|
||
|
||
def query_weibo_exchange_records(
|
||
db: Session,
|
||
account: WeiboAccount,
|
||
*,
|
||
page: int,
|
||
page_size: int,
|
||
activity: str = "",
|
||
) -> dict:
|
||
try:
|
||
return WeiboClient(account.cookie or "").list_exchange_records(
|
||
_effective_activity(account, activity),
|
||
page=page,
|
||
page_size=page_size,
|
||
)
|
||
except (WeiboError, ValueError) as exc:
|
||
_mark_manual_failure(db, account, exc)
|
||
raise
|
||
|
||
|
||
def exchange_weibo_goods(
|
||
db: Session,
|
||
account: WeiboAccount,
|
||
*,
|
||
goods_id: int,
|
||
expected_price: int,
|
||
activity: str = "",
|
||
) -> WeiboExchangeResult:
|
||
"""预检并执行一次微博微豆兑换,然后刷新账号余额。"""
|
||
|
||
try:
|
||
result = WeiboClient(account.cookie or "").exchange_goods(
|
||
goods_id,
|
||
expected_price=expected_price,
|
||
activity=_effective_activity(account, activity),
|
||
)
|
||
except (WeiboError, ValueError) as exc:
|
||
_mark_manual_failure(db, account, exc)
|
||
raise
|
||
if result.balance_after is not None:
|
||
account.weidou_balance = result.balance_after
|
||
db.commit()
|
||
return result
|
||
|
||
|
||
def query_weibo_point_history(
|
||
db: Session,
|
||
account: WeiboAccount,
|
||
*,
|
||
page: int,
|
||
page_size: int,
|
||
activity: str = "",
|
||
) -> dict:
|
||
try:
|
||
return WeiboClient(account.cookie or "").list_point_history(
|
||
_effective_activity(account, activity),
|
||
page=page,
|
||
page_size=page_size,
|
||
)
|
||
except (WeiboError, ValueError) as exc:
|
||
_mark_manual_failure(db, account, exc)
|
||
raise
|
||
|
||
|
||
def create_weibo_recharge_order(
|
||
db: Session,
|
||
account: WeiboAccount,
|
||
*,
|
||
amount_yuan: int,
|
||
activity: str = "",
|
||
) -> WeiboRechargeOrder:
|
||
try:
|
||
return WeiboClient(account.cookie or "").create_recharge_order(
|
||
amount_yuan=amount_yuan,
|
||
activity=_effective_activity(account, activity),
|
||
)
|
||
except (WeiboError, ValueError) as exc:
|
||
_mark_manual_failure(db, account, exc)
|
||
raise
|
||
|
||
|
||
def persist_weibo_recharge_order(
|
||
db: Session,
|
||
account: WeiboAccount,
|
||
order: WeiboRechargeOrder,
|
||
*,
|
||
created_by: int | None,
|
||
) -> WeiboRechargeRecord:
|
||
"""持久化 Web 充值下单结果。"""
|
||
row = (
|
||
db.query(WeiboRechargeRecord)
|
||
.filter(WeiboRechargeRecord.order_id == order.order_id)
|
||
.first()
|
||
)
|
||
if row is None:
|
||
row = WeiboRechargeRecord(
|
||
account_id=account.id,
|
||
order_id=order.order_id,
|
||
amount_yuan=order.amount_yuan,
|
||
single_reward=order.single_reward,
|
||
created_by=created_by,
|
||
)
|
||
db.add(row)
|
||
row.account_id = account.id
|
||
row.pay_id = order.pay_id
|
||
row.amount_yuan = order.amount_yuan
|
||
row.single_reward = order.single_reward
|
||
row.status = "created"
|
||
db.commit()
|
||
db.refresh(row)
|
||
return row
|
||
|
||
|
||
def persist_weibo_payment_status(
|
||
db: Session,
|
||
account: WeiboAccount,
|
||
status: WeiboPaymentStatus,
|
||
) -> WeiboRechargeRecord | None:
|
||
"""把充值支付查询结果写回对应记录。"""
|
||
row = (
|
||
db.query(WeiboRechargeRecord)
|
||
.filter(
|
||
WeiboRechargeRecord.account_id == account.id,
|
||
or_(
|
||
WeiboRechargeRecord.pay_id == status.pay_id,
|
||
WeiboRechargeRecord.order_id == status.out_pay_id,
|
||
),
|
||
)
|
||
.order_by(WeiboRechargeRecord.id.desc())
|
||
.first()
|
||
)
|
||
if row is None:
|
||
return None
|
||
normalized = {
|
||
"PAY_STATUS_SUCCESS": "paid",
|
||
"PAY_STATUS_PENDING": "pending",
|
||
"PAY_STATUS_FAILED": "failed",
|
||
"PAY_STATUS_CLOSED": "closed",
|
||
}.get(status.status, "unknown")
|
||
now = _now()
|
||
row.status = normalized
|
||
row.sub_status = status.sub_status
|
||
row.status_checked_at = now
|
||
if normalized == "paid" and row.paid_at is None:
|
||
row.paid_at = now
|
||
db.commit()
|
||
db.refresh(row)
|
||
return row
|
||
|
||
|
||
def query_weibo_payment_status(
|
||
db: Session,
|
||
account: WeiboAccount,
|
||
*,
|
||
pay_id: str,
|
||
) -> WeiboPaymentStatus:
|
||
try:
|
||
return WeiboClient(account.cookie or "").get_payment_status(pay_id)
|
||
except Exception as exc:
|
||
_mark_manual_failure(db, account, exc)
|
||
raise
|