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

1236 lines
39 KiB
Python

"""微博微豆:CK 导入、账号刷新与绑定二维码。"""
from __future__ import annotations
import os
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,
WeiboImportError,
WeiboImportResult,
WeiboMallListOut,
WeiboPaymentStatusOut,
WeiboPaymentStatusRequest,
WeiboPointHistoryOut,
WeiboPointRecordItem,
WeiboRechargeOrderOut,
WeiboRechargeRequest,
WeiboWorkbenchAccountsRequest,
)
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()
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.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/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 = 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
]
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,
"errors": len(errors),
},
)
message = f"成功导入 {len(accounts)} 个微博账号"
if errors:
message += f",失败 {len(errors)} 条"
if skipped:
message += f",跳过 {skipped} 条重复"
return WeiboImportResult(
success=bool(accounts) or (skipped > 0 and not errors),
message=message,
count=len(accounts),
skipped=skipped,
errors=typed_errors,
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}