Files
live-hub-py/web/backend/services/weibo_service.py
T

606 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""微博微豆账号导入、刷新与绑定 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