Files
live-hub-py/web/backend/routers/weibo.py
T

1482 lines
47 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.
"""微博微豆:CK 导入、账号刷新与绑定二维码。"""
from __future__ import annotations
import os
import re
from fastapi import APIRouter, Depends, HTTPException, Query
from loguru import logger
from sqlalchemy import func, or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, defer, joinedload
from core.weibo import WeiboAuthError, WeiboError, WeiboExchangeUncertainError
from ..database import get_db
from ..deps import get_current_user
from ..models import User, WeiboAccount, WeiboWorkbenchAccount
from ..permissions import user_has_permission
from ..schemas import (
WeiboAccountBatchTagRequest,
WeiboAccountCkOut,
WeiboAccountDisableRequest,
WeiboAccountOut,
WeiboAccountUpdate,
WeiboBatchAssignRequest,
WeiboBindQrOut,
WeiboBindSchemeOut,
WeiboCookieImport,
WeiboExchangeOut,
WeiboExchangeRecordItem,
WeiboExchangeRecordsOut,
WeiboExchangeRequest,
WeiboGoodsOut,
WeiboImportDuplicateOut,
WeiboImportError,
WeiboImportResult,
WeiboMallListOut,
WeiboPaymentStatusOut,
WeiboPaymentStatusRequest,
WeiboPointHistoryOut,
WeiboPointRecordItem,
WeiboRechargeOrderOut,
WeiboRechargeRequest,
WeiboWorkbenchAccountsRequest,
WeiboWorkbenchBatchImportItem,
WeiboWorkbenchBatchImportOut,
WeiboWorkbenchBatchImportRequest,
WeiboWorkbenchTransferToCollabOut,
WeiboWorkbenchTransferToCollabRequest,
)
from ..services.audit_service import record_audit
from ..services.weibo_service import (
create_weibo_recharge_order,
disable_weibo_account,
enable_weibo_account,
exchange_weibo_goods,
generate_weibo_bind_qrcode,
generate_weibo_bind_schemes,
import_weibo_cookies,
query_weibo_exchange_records,
query_weibo_mall,
query_weibo_payment_status,
query_weibo_point_history,
refresh_weibo_account,
update_weibo_account_metadata,
)
router = APIRouter(prefix="/api/weibo", tags=["微博微豆"])
WEIBO_DEFAULT_ACTIVITY = os.getenv("WEIBO_DEFAULT_ACTIVITY", "").strip()
WEIBO_WORKBENCH_BATCH_IMPORT_MAX = 200
def _require_weibo_perm(user: User, permission: str) -> None:
if not user_has_permission(user, permission):
raise HTTPException(status_code=403, detail="权限不足")
def _query_str(value) -> str:
return value.strip() if isinstance(value, str) else ""
def _int_or_none(value: object) -> int | None:
if value is None or value == "":
return None
try:
return int(str(value))
except (TypeError, ValueError):
return None
def _can_view_all_weibo_accounts(user: User) -> bool:
return user_has_permission(user, "weibo:assign")
def _visible_weibo_accounts_query(query, current: User):
if _can_view_all_weibo_accounts(current):
return query
return query.filter(WeiboAccount.assigned_to == current.id)
def _get_account(
db: Session,
account_id: int,
current: User | None = None,
) -> WeiboAccount:
query = db.query(WeiboAccount).filter(WeiboAccount.id == account_id)
if current is not None:
query = _visible_weibo_accounts_query(query, current)
account = query.first()
if account is None:
raise HTTPException(status_code=404, detail="微博账号不存在")
return account
def _account_out(account: WeiboAccount) -> WeiboAccountOut:
return WeiboAccountOut.model_validate(account).model_copy(
update={
"assigned_username": (
account.assigned_user.username if account.assigned_user else ""
)
}
)
def _apply_account_filters(
query,
*,
search: str,
tag: str,
bind_status: str,
enabled: bool | None,
assign_status: str = "",
assigned_user_id: int | None = None,
):
search_text = _query_str(search)
if search_text:
pattern = f"%{search_text}%"
query = query.filter(
or_(
WeiboAccount.uid.ilike(pattern),
WeiboAccount.nickname.ilike(pattern),
WeiboAccount.game_role_name.ilike(pattern),
WeiboAccount.tag.ilike(pattern),
WeiboAccount.remark.ilike(pattern),
)
)
tag_value = _query_str(tag)
if tag_value:
query = query.filter(WeiboAccount.tag == tag_value)
assign_value = _query_str(assign_status)
if assign_value == "unassigned":
query = query.filter(WeiboAccount.assigned_to.is_(None))
elif assign_value == "assigned":
query = query.filter(WeiboAccount.assigned_to.isnot(None))
target_user_id = _int_or_none(assigned_user_id)
if target_user_id is not None:
query = query.filter(WeiboAccount.assigned_to == target_user_id)
if isinstance(enabled, bool):
query = query.filter(WeiboAccount.is_enabled.is_(enabled))
bind_value = _query_str(bind_status)
if bind_value == "unbound":
query = query.filter(WeiboAccount.is_bound.is_(False))
elif bind_value == "bound":
query = query.filter(WeiboAccount.is_bound.is_(True))
elif bind_value == "rebindable":
query = query.filter(
WeiboAccount.is_bound.is_(True),
WeiboAccount.can_rebind.is_(True),
)
return query
def _paginated_accounts(query, *, page: int, page_size: int) -> dict:
total = query.order_by(None).count()
rows = (
query.options(
joinedload(WeiboAccount.assigned_user),
defer(WeiboAccount.cookie),
)
.order_by(WeiboAccount.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return {
"items": [_account_out(account) for account in rows],
"total": total,
"page": page,
"page_size": page_size,
}
def _ensure_enabled(account: WeiboAccount) -> None:
if not account.is_enabled:
raise HTTPException(status_code=409, detail="微博账号已停用,请先启用")
def _refresh_or_http(account: WeiboAccount, db: Session):
try:
return refresh_weibo_account(
db,
account,
activity=WEIBO_DEFAULT_ACTIVITY,
)
except WeiboAuthError as exc:
raise HTTPException(status_code=400, detail=f"CK 已失效: {exc}") from exc
except WeiboError as exc:
raise HTTPException(status_code=502, detail=f"刷新微博账号失败: {exc}") from exc
def _manual_http_error(exc: Exception) -> HTTPException:
if isinstance(exc, WeiboAuthError):
return HTTPException(status_code=400, detail=f"CK 已失效: {exc}")
return HTTPException(status_code=502, detail=str(exc))
def _record_audit_safely(
db: Session,
current: User,
*,
action: str,
target: str,
detail: dict,
) -> None:
"""审计是旁路,失败只记 warning,不回滚已经完成的账号操作。"""
try:
record_audit(
db,
current,
action=action,
target=target,
detail=detail,
)
db.commit()
except Exception as exc: # noqa: BLE001 旁路写入不能带崩主流程
db.rollback()
logger.warning(f"记录微博审计失败: {exc}")
@router.get("/accounts")
def list_accounts(
search: str = Query(""),
tag: str = Query(""),
bind_status: str = Query(""),
enabled: bool | None = Query(None),
assign_status: str = Query(""),
assigned_user_id: int | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=200),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""查看已导入的微博账号。"""
_require_weibo_perm(current, "weibo:view")
query = _apply_account_filters(
_visible_weibo_accounts_query(db.query(WeiboAccount), current),
search=search,
tag=tag,
bind_status=bind_status,
enabled=enabled,
assign_status=assign_status,
assigned_user_id=assigned_user_id,
)
return _paginated_accounts(query, page=page, page_size=page_size)
@router.get("/workbench/accounts")
def list_workbench_accounts(
search: str = Query(""),
tag: str = Query(""),
bind_status: str = Query(""),
enabled: bool | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=200),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""查看当前用户个人工作台中的微博账号。"""
_require_weibo_perm(current, "weibo:workbench")
query = (
db.query(WeiboAccount)
.join(
WeiboWorkbenchAccount,
WeiboWorkbenchAccount.account_id == WeiboAccount.id,
)
.filter(WeiboWorkbenchAccount.user_id == current.id)
)
if not _can_view_all_weibo_accounts(current):
query = query.filter(WeiboAccount.assigned_to == current.id)
query = _apply_account_filters(
query,
search=search,
tag=tag,
bind_status=bind_status,
enabled=enabled,
)
return _paginated_accounts(query, page=page, page_size=page_size)
@router.get("/workbench/pool")
def list_workbench_pool(
search: str = Query(""),
tag: str = Query(""),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=200),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""查看可加入当前用户工作台、且尚未加入的账号池账号。"""
_require_weibo_perm(current, "weibo:workbench")
occupied = db.query(WeiboWorkbenchAccount.account_id)
pool_query = db.query(WeiboAccount).filter(WeiboAccount.id.not_in(occupied))
if not _can_view_all_weibo_accounts(current):
pool_query = pool_query.filter(WeiboAccount.assigned_to == current.id)
query = _apply_account_filters(
pool_query,
search=search,
tag=tag,
bind_status="",
enabled=True,
)
return _paginated_accounts(query, page=page, page_size=page_size)
@router.post(
"/workbench/accounts/batch-import",
response_model=WeiboWorkbenchBatchImportOut,
)
def import_workbench_accounts_by_text(
req: WeiboWorkbenchBatchImportRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""按 UID/用户名/角色批量解析账号并加入当前用户工作台。"""
_require_weibo_perm(current, "weibo:workbench")
identifiers: list[str] = []
for token in re.split(r"[\s,,;;]+", req.text.strip()):
value = token.strip()
if value and value not in identifiers:
identifiers.append(value)
if not identifiers:
raise HTTPException(status_code=400, detail="请先粘贴微博用户名或 UID")
if len(identifiers) > WEIBO_WORKBENCH_BATCH_IMPORT_MAX:
raise HTTPException(
status_code=400,
detail=f"一次最多导入 {WEIBO_WORKBENCH_BATCH_IMPORT_MAX} 个账号",
)
visible_query = _visible_weibo_accounts_query(db.query(WeiboAccount), current)
accounts = visible_query.filter(
or_(
WeiboAccount.uid.in_(identifiers),
WeiboAccount.nickname.in_(identifiers),
WeiboAccount.game_role_name.in_(identifiers),
)
).all()
def grouped(field: str) -> dict[str, list[WeiboAccount]]:
result: dict[str, list[WeiboAccount]] = {}
for account in accounts:
value = str(getattr(account, field) or "").strip()
if value:
result.setdefault(value, []).append(account)
return result
by_uid = grouped("uid")
by_nickname = grouped("nickname")
by_role = grouped("game_role_name")
account_ids = [account.id for account in accounts]
occupancy = {
account_id: user_id
for account_id, user_id in db.query(
WeiboWorkbenchAccount.account_id,
WeiboWorkbenchAccount.user_id,
)
.filter(WeiboWorkbenchAccount.account_id.in_(account_ids))
.all()
}
items: list[WeiboWorkbenchBatchImportItem] = []
to_add: list[WeiboAccount] = []
planned_ids: set[int] = set()
for identifier in identifiers:
candidates = by_uid.get(identifier) or by_nickname.get(identifier) or []
if not candidates:
candidates = by_role.get(identifier) or []
if not candidates:
items.append(
WeiboWorkbenchBatchImportItem(
identifier=identifier,
status="not_found",
message="当前可见账号池中没有匹配项",
)
)
continue
if len(candidates) > 1 and identifier not in by_uid:
items.append(
WeiboWorkbenchBatchImportItem(
identifier=identifier,
status="ambiguous",
message=f"匹配到 {len(candidates)} 个账号,请改用 UID",
)
)
continue
account = candidates[0]
base = {
"identifier": identifier,
"account_id": account.id,
"uid": account.uid,
"nickname": account.nickname,
}
occupant = occupancy.get(account.id)
if not account.is_enabled:
items.append(
WeiboWorkbenchBatchImportItem(
status="disabled",
message="账号已停用",
**base,
)
)
elif occupant is not None and occupant != current.id:
items.append(
WeiboWorkbenchBatchImportItem(
status="unavailable",
message="账号已在其他人的工作台中使用",
**base,
)
)
elif occupant == current.id:
items.append(
WeiboWorkbenchBatchImportItem(
status="already_added",
message="账号已在本工作台",
**base,
)
)
elif account.id in planned_ids:
items.append(
WeiboWorkbenchBatchImportItem(
status="duplicate",
message="同一账号在本次名单中重复",
**base,
)
)
else:
planned_ids.add(account.id)
to_add.append(account)
items.append(
WeiboWorkbenchBatchImportItem(
status="added",
message="已加入工作台",
**base,
)
)
try:
db.add_all(
[
WeiboWorkbenchAccount(user_id=current.id, account_id=account.id)
for account in to_add
]
)
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(
status_code=409,
detail="部分账号刚被其他工作台占用,请刷新后重试",
) from exc
if to_add:
_record_audit_safely(
db,
current,
action="weibo:workbench:batch_import",
target=f"weibo_user:{current.id}",
detail={"account_ids": [account.id for account in to_add]},
)
added = sum(item.status == "added" for item in items)
already_added = sum(item.status == "already_added" for item in items)
failed = len(items) - added - already_added
return WeiboWorkbenchBatchImportOut(
requested=len(identifiers),
added=added,
already_added=already_added,
failed=failed,
account_ids=[account.id for account in to_add],
items=items,
)
@router.get("/workbench/tags")
def list_workbench_tags(
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""返回当前用户工作台中已有的标签。"""
_require_weibo_perm(current, "weibo:workbench")
tag_query = (
db.query(WeiboAccount.tag)
.join(
WeiboWorkbenchAccount,
WeiboWorkbenchAccount.account_id == WeiboAccount.id,
)
.filter(
WeiboWorkbenchAccount.user_id == current.id,
WeiboAccount.tag != "",
)
)
if not _can_view_all_weibo_accounts(current):
tag_query = tag_query.filter(WeiboAccount.assigned_to == current.id)
rows = tag_query.distinct().order_by(WeiboAccount.tag.asc()).all()
return {"tags": [tag for (tag,) in rows if tag]}
@router.post("/workbench/accounts")
def add_workbench_accounts(
req: WeiboWorkbenchAccountsRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""把账号池账号加入当前用户的工作台。"""
_require_weibo_perm(current, "weibo:workbench")
account_ids = sorted(
{account_id for account_id in req.account_ids if account_id > 0}
)
if not account_ids:
raise HTTPException(status_code=400, detail="请先选择账号")
existing_ids = {
account_id
for (account_id,) in db.query(WeiboAccount.id)
.filter(WeiboAccount.id.in_(account_ids))
.all()
}
missing = sorted(set(account_ids) - existing_ids)
if missing:
raise HTTPException(status_code=404, detail=f"账号不存在: {missing}")
if not _can_view_all_weibo_accounts(current):
unavailable = {
account_id
for (account_id,) in db.query(WeiboAccount.id)
.filter(
WeiboAccount.id.in_(account_ids),
or_(
WeiboAccount.assigned_to.is_(None),
WeiboAccount.assigned_to != current.id,
),
)
.all()
}
if unavailable:
raise HTTPException(
status_code=403,
detail="只能使用分配给自己的微博账号",
)
taken = {
account_id
for (account_id,) in db.query(WeiboWorkbenchAccount.account_id)
.filter(
WeiboWorkbenchAccount.user_id != current.id,
WeiboWorkbenchAccount.account_id.in_(account_ids),
)
.all()
}
if taken:
raise HTTPException(
status_code=409,
detail="部分账号已在其他人的工作台中使用,请先让原使用人移出",
)
selected_ids = {
account_id
for (account_id,) in db.query(WeiboWorkbenchAccount.account_id)
.filter(
WeiboWorkbenchAccount.user_id == current.id,
WeiboWorkbenchAccount.account_id.in_(account_ids),
)
.all()
}
db.add_all(
[
WeiboWorkbenchAccount(user_id=current.id, account_id=account_id)
for account_id in account_ids
if account_id not in selected_ids
]
)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(
status_code=409,
detail="账号已被其他工作台占用,请刷新后重试",
) from exc
_record_audit_safely(
db,
current,
action="weibo:workbench:add",
target=f"weibo_user:{current.id}",
detail={"account_ids": account_ids},
)
return {"account_ids": account_ids, "success": True}
@router.post(
"/workbench/accounts/transfer-to-collab",
response_model=WeiboWorkbenchTransferToCollabOut,
)
def transfer_workbench_to_collab(
req: WeiboWorkbenchTransferToCollabRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""把当前工作台账号移出,并引导到外部单协作新建订单。"""
_require_weibo_perm(current, "weibo:workbench")
membership = (
db.query(WeiboWorkbenchAccount)
.filter(
WeiboWorkbenchAccount.user_id == current.id,
WeiboWorkbenchAccount.account_id == req.account_id,
)
.first()
)
if membership is None:
raise HTTPException(status_code=404, detail="该账号不在当前微博工作台")
account = _get_account(db, req.account_id, current)
_ensure_enabled(account)
if not str(account.cookie or "").strip():
raise HTTPException(
status_code=400, detail="该账号没有可用 Cookie,无法转入外部单协作"
)
db.delete(membership)
db.commit()
_record_audit_safely(
db,
current,
action="weibo:workbench:transfer_collab",
target=f"weibo_account:{account.id}",
detail={
"account_id": account.id,
"uid": account.uid,
"nickname": account.nickname,
},
)
return WeiboWorkbenchTransferToCollabOut(
account_id=account.id,
uid=account.uid,
nickname=account.nickname,
game_role_name=account.game_role_name,
)
@router.post("/workbench/accounts/remove")
def remove_workbench_accounts(
req: WeiboWorkbenchAccountsRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""从当前用户工作台移出账号,不影响账号池和其他用户。"""
_require_weibo_perm(current, "weibo:workbench")
account_ids = sorted(
{account_id for account_id in req.account_ids if account_id > 0}
)
if not account_ids:
raise HTTPException(status_code=400, detail="请先选择账号")
db.query(WeiboWorkbenchAccount).filter(
WeiboWorkbenchAccount.user_id == current.id,
WeiboWorkbenchAccount.account_id.in_(account_ids),
).delete(synchronize_session=False)
db.commit()
_record_audit_safely(
db,
current,
action="weibo:workbench:remove",
target=f"weibo_user:{current.id}",
detail={"account_ids": account_ids},
)
return {"account_ids": account_ids, "success": True}
@router.post("/accounts/batch-tag")
def batch_tag_accounts(
req: WeiboAccountBatchTagRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""批量设置微博账号标签。"""
_require_weibo_perm(current, "weibo:manage")
account_ids = sorted(set(req.ids))
if not account_ids:
raise HTTPException(status_code=400, detail="请先选择账号")
accounts = (
_visible_weibo_accounts_query(db.query(WeiboAccount), current)
.filter(WeiboAccount.id.in_(account_ids))
.all()
)
actual_ids = {account.id for account in accounts}
missing = sorted(set(account_ids) - actual_ids)
if missing:
raise HTTPException(status_code=404, detail=f"账号不存在: {missing}")
tag = req.tag.strip()
previous = {account.id: account.tag or "" for account in accounts}
for account in accounts:
account.tag = tag
db.commit()
_record_audit_safely(
db,
current,
action="weibo:account:batch_tag",
target=f"weibo_accounts:{len(account_ids)}",
detail={
"account_ids": account_ids,
"tag": tag,
"previous_tags": previous,
},
)
return {"updated": len(accounts), "tag": tag, "success": True}
@router.post("/accounts/batch-assign")
def batch_assign_accounts(
req: WeiboBatchAssignRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""批量分配微博账号给客服,或取消分配。"""
_require_weibo_perm(current, "weibo:assign")
account_ids = sorted(set(req.account_ids))
if not account_ids:
raise HTTPException(status_code=400, detail="请先选择账号")
accounts = db.query(WeiboAccount).filter(WeiboAccount.id.in_(account_ids)).all()
actual_ids = {account.id for account in accounts}
missing = sorted(set(account_ids) - actual_ids)
if missing:
raise HTTPException(status_code=404, detail=f"账号不存在: {missing}")
target = None
if req.assigned_to is not None:
target = (
db.query(User)
.filter(
User.id == req.assigned_to,
User.role == "support",
User.is_active.is_(True),
)
.first()
)
if target is None:
raise HTTPException(status_code=404, detail="目标客服不存在或已停用")
previous_assignments = {account.id: account.assigned_to for account in accounts}
if target is None:
db.query(WeiboWorkbenchAccount).filter(
WeiboWorkbenchAccount.account_id.in_(actual_ids)
).delete(synchronize_session=False)
else:
db.query(WeiboWorkbenchAccount).filter(
WeiboWorkbenchAccount.account_id.in_(actual_ids),
WeiboWorkbenchAccount.user_id != target.id,
).delete(synchronize_session=False)
for account in accounts:
account.assigned_to = target.id if target else None
db.commit()
target_name = target.username if target else ""
_record_audit_safely(
db,
current,
action="weibo:account:assign",
target=f"weibo_accounts:{len(account_ids)}",
detail={
"account_ids": account_ids,
"assigned_to": target.id if target else None,
"assigned_username": target_name,
"previous_assignments": previous_assignments,
},
)
return {
"updated": len(accounts),
"assigned_to": target.id if target else None,
"assigned_username": target_name,
"success": True,
}
@router.get("/assignments/summary")
def assignments_summary(
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""微博账号分配概览。"""
_require_weibo_perm(current, "weibo:assign")
rows = (
db.query(User.id, User.username, func.count(WeiboAccount.id))
.outerjoin(WeiboAccount, WeiboAccount.assigned_to == User.id)
.filter(
User.role == "support",
User.is_active.is_(True),
)
.group_by(User.id, User.username)
.order_by(User.username.asc())
.all()
)
users = [
{
"id": user_id,
"username": username,
"assigned_count": assigned_count,
}
for user_id, username, assigned_count in rows
]
total_count = db.query(WeiboAccount).count()
unassigned_count = (
db.query(WeiboAccount).filter(WeiboAccount.assigned_to.is_(None)).count()
)
return {
"total_count": total_count,
"assigned_count": total_count - unassigned_count,
"unassigned_count": unassigned_count,
"support_users": users,
}
@router.get("/accounts/tags")
def list_account_tags(
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""返回全局账号池已有的标签。"""
_require_weibo_perm(current, "weibo:view")
tag_query = _visible_weibo_accounts_query(db.query(WeiboAccount.tag), current)
rows = (
tag_query.filter(WeiboAccount.tag != "")
.distinct()
.order_by(WeiboAccount.tag.asc())
.all()
)
return {"tags": [tag for (tag,) in rows if tag]}
@router.get("/accounts/ids")
def list_account_ids(
search: str = Query(""),
tag: str = Query(""),
bind_status: str = Query(""),
enabled: bool | None = Query(None),
assign_status: str = Query(""),
assigned_user_id: int | None = Query(None),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""返回当前用户可见且符合筛选条件的全部微博账号 ID。"""
_require_weibo_perm(current, "weibo:view")
query = _apply_account_filters(
_visible_weibo_accounts_query(db.query(WeiboAccount), current),
search=search,
tag=tag,
bind_status=bind_status,
enabled=enabled,
assign_status=assign_status,
assigned_user_id=assigned_user_id,
)
account_ids = [
account_id
for (account_id,) in query.with_entities(WeiboAccount.id)
.order_by(WeiboAccount.id.desc())
.all()
]
return {"account_ids": account_ids, "total": len(account_ids)}
@router.get("/accounts/{account_id}", response_model=WeiboAccountOut)
def get_account(
account_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""查看一个微博账号的本地资料。"""
_require_weibo_perm(current, "weibo:view")
return _account_out(_get_account(db, account_id, current))
@router.put("/accounts/{account_id}", response_model=WeiboAccountOut)
def update_account(
account_id: int,
req: WeiboAccountUpdate,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""更新账号的人工标签和备注。"""
_require_weibo_perm(current, "weibo:manage")
account = _get_account(db, account_id, current)
payload = req.model_dump(exclude_unset=True)
changes = update_weibo_account_metadata(
db,
account,
tag=payload.get("tag"),
remark=payload.get("remark"),
)
if changes:
_record_audit_safely(
db,
current,
action="weibo:account:update",
target=f"weibo_account:{account.id}",
detail={"account_id": account.id, "uid": account.uid, "changes": changes},
)
return _account_out(account)
@router.post("/accounts/{account_id}/disable", response_model=WeiboAccountOut)
def disable_account(
account_id: int,
req: WeiboAccountDisableRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""停用账号,保留 CK 和历史资料。"""
_require_weibo_perm(current, "weibo:manage")
account = _get_account(db, account_id, current)
disable_weibo_account(db, account, reason=req.reason)
_record_audit_safely(
db,
current,
action="weibo:account:disable",
target=f"weibo_account:{account.id}",
detail={
"account_id": account.id,
"uid": account.uid,
"reason": account.disabled_reason,
},
)
return _account_out(account)
@router.post("/accounts/{account_id}/enable", response_model=WeiboAccountOut)
def enable_account(
account_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""重新检测 CK 与支付风险,通过后启用账号。"""
_require_weibo_perm(current, "weibo:manage")
account = _get_account(db, account_id, current)
_refresh_or_http(account, db)
enable_weibo_account(db, account)
_record_audit_safely(
db,
current,
action="weibo:account:enable",
target=f"weibo_account:{account.id}",
detail={"account_id": account.id, "uid": account.uid},
)
return _account_out(account)
@router.post("/accounts/import-cookies", response_model=WeiboImportResult)
def import_cookies(
req: WeiboCookieImport,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""导入微博 CK;每个非空行一条完整 Cookie。"""
_require_weibo_perm(current, "weibo:import")
accounts, errors, skipped, duplicates = import_weibo_cookies(
db,
req.text,
req.tag,
WEIBO_DEFAULT_ACTIVITY,
req.update_existing,
current.id,
can_update_all=_can_view_all_weibo_accounts(current),
)
typed_errors = [
WeiboImportError(line=item.line, message=item.message) for item in errors
]
typed_duplicates = [
WeiboImportDuplicateOut(
line=item.line,
uid=item.uid,
nickname=item.nickname,
message=item.message,
)
for item in duplicates
]
if accounts and not _can_view_all_weibo_accounts(current):
changed = False
for account in accounts:
if account.assigned_to != current.id:
account.assigned_to = current.id
changed = True
if changed:
db.commit()
for account in accounts:
db.refresh(account)
if accounts:
_record_audit_safely(
db,
current,
action="weibo:account:import",
target=f"count:{len(accounts)}",
detail={
"uids": [account.uid for account in accounts],
"count": len(accounts),
"skipped": skipped,
"duplicate_uids": [item.uid for item in duplicates],
"errors": len(errors),
},
)
message = f"成功导入 {len(accounts)} 个微博账号"
if errors:
message += f",失败 {len(errors)} 条"
if skipped:
preview = "、".join(
f"{item.uid}({item.nickname or '无昵称'})" for item in duplicates[:3]
)
message += f",跳过 {skipped} 条重复"
if preview:
message += f":{preview}"
if len(duplicates) > 3:
message += f" 等 {len(duplicates)} 个"
return WeiboImportResult(
success=bool(accounts) or (skipped > 0 and not errors),
message=message,
count=len(accounts),
skipped=skipped,
errors=typed_errors,
duplicates=typed_duplicates,
accounts=[_account_out(account) for account in accounts],
)
@router.post("/accounts/{account_id}/refresh", response_model=WeiboAccountOut)
def refresh_account(
account_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""立即刷新一个微博账号的 CK 状态和资料。"""
_require_weibo_perm(current, "weibo:view")
account = _get_account(db, account_id, current)
_ensure_enabled(account)
_refresh_or_http(account, db)
_record_audit_safely(
db,
current,
action="weibo:account:refresh",
target=f"weibo_account:{account.id}",
detail={"account_id": account.id, "uid": account.uid},
)
return _account_out(account)
@router.post("/accounts/{account_id}/ck/copy", response_model=WeiboAccountCkOut)
def copy_account_ck(
account_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""复制已导入的微博 CK 明文;只走单独权限并保留审计。"""
_require_weibo_perm(current, "weibo:account:ck_export")
account = _get_account(db, account_id, current)
ck_text = (account.cookie or "").strip()
if not ck_text:
raise HTTPException(status_code=409, detail="该账号没有保存 CK")
_record_audit_safely(
db,
current,
action="weibo:account:ck_export",
target=f"weibo_account:{account.id}",
detail={"account_id": account.id, "uid": account.uid},
)
return WeiboAccountCkOut(
account_id=account.id,
uid=account.uid,
ck_text=ck_text,
)
@router.post("/accounts/{account_id}/bind-qrcodes", response_model=WeiboBindQrOut)
def create_bind_qrcodes(
account_id: int,
channel: str = Query("", pattern="^(wx|qq)?$"),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""生成微信和 QQ 绑定 Scheme,供前端直接渲染二维码。"""
_require_weibo_perm(current, "weibo:bind")
selected_channel = channel.strip() if isinstance(channel, str) else ""
account = _get_account(db, account_id, current)
_ensure_enabled(account)
snapshot = _refresh_or_http(account, db)
if snapshot.is_bound and not snapshot.can_rebind:
detail = (
f"账号已绑定角色「{snapshot.role_name}」,当前不可换绑"
if snapshot.role_name
else "账号已绑定角色,当前不可换绑"
)
if snapshot.rebind_remain_days:
detail += f",还需等待 {snapshot.rebind_remain_days} 天"
raise HTTPException(status_code=409, detail=detail)
if not snapshot.bind_url:
raise HTTPException(status_code=409, detail="微博没有返回当前账号的绑定链接")
if selected_channel:
items = [
generate_weibo_bind_qrcode(account, snapshot.bind_url, selected_channel)
]
else:
# 不传 channel 时保留旧接口:一次返回微信、QQ 两个 Scheme。
items = generate_weibo_bind_schemes(account, snapshot.bind_url)
schemes = [WeiboBindSchemeOut(**item) for item in items]
successful = [
item
for item in schemes
if (item.qrcode_image if selected_channel else item.scheme)
]
if not successful:
prefix = (
f"{schemes[0].label}绑定二维码生成失败"
if selected_channel and schemes
else "微信和 QQ 绑定二维码都生成失败"
)
raise HTTPException(
status_code=502,
detail=prefix
+ ": "
+ "; ".join(f"{item.label}={item.error}" for item in schemes if item.error),
)
_record_audit_safely(
db,
current,
action="weibo:account:bind_qrcode",
target=f"weibo_account:{account.id}",
detail={
"account_id": account.id,
"uid": account.uid,
"channels": [
{
"channel": item.channel,
"success": bool(item.scheme),
"error": item.error,
}
for item in schemes
],
},
)
return WeiboBindQrOut(
account_id=account.id,
is_bound=snapshot.is_bound,
can_rebind=snapshot.can_rebind,
rebind_remain_days=snapshot.rebind_remain_days,
schemes=schemes,
)
@router.post("/accounts/{account_id}/mall-list", response_model=WeiboMallListOut)
def list_mall_goods(
account_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""手动查询微豆商城商品列表。"""
_require_weibo_perm(current, "weibo:view")
account = _get_account(db, account_id, current)
_ensure_enabled(account)
try:
data = query_weibo_mall(
db,
account,
activity=WEIBO_DEFAULT_ACTIVITY,
)
except (WeiboError, ValueError) as exc:
raise _manual_http_error(exc) from exc
goods: list[WeiboGoodsOut] = []
for item in data.get("list") or []:
if not isinstance(item, dict):
continue
try:
goods_id = int(item.get("id") or 0)
except (TypeError, ValueError):
continue
if goods_id <= 0:
continue
goods.append(
WeiboGoodsOut(
id=goods_id,
goods_name=str(item.get("goods_name") or ""),
goods_image=str(item.get("goods_image") or ""),
price=_int_or_none(item.get("price")) or 0,
remain_count=_int_or_none(item.get("remain_count")),
is_sold_out=bool(item.get("is_sold_out")),
goods_desc=str(item.get("goods_desc") or ""),
)
)
return WeiboMallListOut(
account_id=account.id,
weidou_balance=int(data.get("weidou_balance") or 0),
goods=goods,
)
@router.post(
"/accounts/{account_id}/exchange",
response_model=WeiboExchangeOut,
)
def exchange_goods(
account_id: int,
req: WeiboExchangeRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""手动兑换商品;服务端只提交一次,不自动重试。"""
_require_weibo_perm(current, "weibo:exchange")
if not req.confirm:
raise HTTPException(status_code=400, detail="请先确认兑换商品")
account = _get_account(db, account_id, current)
_ensure_enabled(account)
try:
result = exchange_weibo_goods(
db,
account,
goods_id=req.goods_id,
expected_price=req.expected_price,
activity=WEIBO_DEFAULT_ACTIVITY,
)
except WeiboExchangeUncertainError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except (WeiboError, ValueError) as exc:
raise _manual_http_error(exc) from exc
_record_audit_safely(
db,
current,
action="weibo:mall:exchange",
target=f"weibo_account:{account.id}",
detail={
"account_id": account.id,
"uid": account.uid,
"goods_id": result.goods_id,
"goods_name": result.goods_name,
"price": result.price,
"balance_before": result.balance_before,
"balance_after": result.balance_after,
"verified": result.verified,
},
)
return WeiboExchangeOut(
account_id=account.id,
goods_id=result.goods_id,
goods_name=result.goods_name,
price=result.price,
balance_before=result.balance_before,
balance_after=result.balance_after,
remain_before=result.remain_before,
remain_after=result.remain_after,
verified=result.verified,
verify_error=result.verify_error,
)
@router.post(
"/accounts/{account_id}/exchange-records",
response_model=WeiboExchangeRecordsOut,
)
def list_exchange_records(
account_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""手动查询微豆兑换记录。"""
_require_weibo_perm(current, "weibo:view")
account = _get_account(db, account_id, current)
_ensure_enabled(account)
try:
data = query_weibo_exchange_records(
db,
account,
page=page,
page_size=page_size,
activity=WEIBO_DEFAULT_ACTIVITY,
)
except (WeiboError, ValueError) as exc:
raise _manual_http_error(exc) from exc
items: list[WeiboExchangeRecordItem] = []
for item in data.get("list") or []:
if not isinstance(item, dict):
continue
items.append(
WeiboExchangeRecordItem(
goods_name=str(item.get("goods_name") or ""),
game_account=str(item.get("game_account") or ""),
cost_weidou=_int_or_none(item.get("cost_weidou")),
exchange_time=str(item.get("exchange_time") or ""),
reward_status=_int_or_none(item.get("reward_status")),
raw=item,
)
)
return WeiboExchangeRecordsOut(
account_id=account.id,
page=int(data.get("page") or data.get("page_no") or page),
page_size=int(data.get("pageSize") or data.get("page_size") or page_size),
total=int(data.get("totalCount") or data.get("total") or 0),
items=items,
)
@router.post(
"/accounts/{account_id}/point-history",
response_model=WeiboPointHistoryOut,
)
def list_point_history(
account_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""手动查询微豆获取和充值明细。"""
_require_weibo_perm(current, "weibo:view")
account = _get_account(db, account_id, current)
_ensure_enabled(account)
try:
data = query_weibo_point_history(
db,
account,
page=page,
page_size=page_size,
activity=WEIBO_DEFAULT_ACTIVITY,
)
except (WeiboError, ValueError) as exc:
raise _manual_http_error(exc) from exc
items: list[WeiboPointRecordItem] = []
for item in data.get("list") or []:
if not isinstance(item, dict):
continue
items.append(
WeiboPointRecordItem(
id=str(item.get("id") or ""),
source_type=_int_or_none(item.get("source_type")),
record_type=_int_or_none(item.get("record_type")),
source_id=str(item.get("source_id") or ""),
source_name=str(item.get("source_name") or ""),
point_num=_int_or_none(item.get("point_num")) or 0,
create_time=str(item.get("create_time") or ""),
expire_time=str(item.get("expire_time") or ""),
)
)
return WeiboPointHistoryOut(
account_id=account.id,
page=int(data.get("page_num") or page),
page_size=int(data.get("page_size") or page_size),
total=int(data.get("total") or 0),
items=items,
)
@router.post(
"/accounts/{account_id}/recharge/orders",
response_model=WeiboRechargeOrderOut,
)
def create_recharge_order(
account_id: int,
req: WeiboRechargeRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""手动创建助威充值订单并返回支付二维码。"""
_require_weibo_perm(current, "weibo:recharge")
account = _get_account(db, account_id, current)
_ensure_enabled(account)
try:
order = create_weibo_recharge_order(
db,
account,
amount_yuan=req.amount_yuan,
activity=WEIBO_DEFAULT_ACTIVITY,
)
except (WeiboError, ValueError) as exc:
raise _manual_http_error(exc) from exc
_record_audit_safely(
db,
current,
action="weibo:recharge:create_order",
target=f"weibo_account:{account.id}",
detail={
"account_id": account.id,
"uid": account.uid,
"amount_yuan": order.amount_yuan,
"single_reward": order.single_reward,
"order_id": order.order_id,
},
)
return WeiboRechargeOrderOut(
account_id=account.id,
order_id=order.order_id,
pay_id=order.pay_id,
amount_yuan=order.amount_yuan,
single_reward=order.single_reward,
alipay_qr_code=order.alipay_qr_code,
desktop_pay_url=order.desktop_pay_url,
)
@router.post(
"/accounts/{account_id}/recharge/status",
response_model=WeiboPaymentStatusOut,
)
def get_recharge_status(
account_id: int,
req: WeiboPaymentStatusRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""手动查询支付状态;支付动作仍由用户在微信/支付宝完成。"""
_require_weibo_perm(current, "weibo:recharge")
account = _get_account(db, account_id, current)
_ensure_enabled(account)
try:
status = query_weibo_payment_status(db, account, pay_id=req.pay_id)
except (WeiboError, ValueError) as exc:
raise _manual_http_error(exc) from exc
return WeiboPaymentStatusOut(
pay_id=status.pay_id,
out_pay_id=status.out_pay_id,
status=status.status,
sub_status=status.sub_status,
)
@router.delete("/accounts/{account_id}")
def delete_account(
account_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""删除一个微博账号。"""
_require_weibo_perm(current, "weibo:delete")
account = _get_account(db, account_id, current)
uid = account.uid
db.delete(account)
db.commit()
_record_audit_safely(
db,
current,
action="weibo:account:delete",
target=f"weibo_account:{account_id}",
detail={"account_id": account_id, "uid": uid},
)
return {"message": "微博账号已删除", "success": True}