feat(weibo): 增加工作台独占与快捷操作

This commit is contained in:
yml2213
2026-11-08 16:39:32 +08:00
parent d566518478
commit c8b62e5267
10 changed files with 341 additions and 29 deletions
@@ -0,0 +1,54 @@
"""限制微博账号同时只能属于一个个人工作台。"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261108_0073"
down_revision: str | None = "20261108_0072"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table("weibo_workbench_accounts"):
return
# 历史若已有重复占用,保留最早加入的一条,其余释放。
op.execute(
sa.text(
"DELETE newer FROM weibo_workbench_accounts AS newer "
"JOIN weibo_workbench_accounts AS older "
"ON newer.account_id = older.account_id AND newer.id > older.id"
)
)
constraints = {
item["name"]
for item in inspector.get_unique_constraints("weibo_workbench_accounts")
}
if "uq_weibo_workbench_account_owner" not in constraints:
op.create_unique_constraint(
"uq_weibo_workbench_account_owner",
"weibo_workbench_accounts",
["account_id"],
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table("weibo_workbench_accounts"):
return
constraints = {
item["name"]
for item in inspector.get_unique_constraints("weibo_workbench_accounts")
}
if "uq_weibo_workbench_account_owner" in constraints:
op.drop_constraint(
"uq_weibo_workbench_account_owner",
"weibo_workbench_accounts",
type_="unique",
)
+4
View File
@@ -983,6 +983,10 @@ class WeiboWorkbenchAccount(Base):
"account_id",
name="uq_weibo_workbench_account",
),
UniqueConstraint(
"account_id",
name="uq_weibo_workbench_account_owner",
),
)
+82 -7
View File
@@ -7,6 +7,7 @@ import os
from fastapi import APIRouter, Depends, HTTPException, Query
from loguru import logger
from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, defer
from core.weibo import WeiboAuthError, WeiboError, WeiboExchangeUncertainError
@@ -94,6 +95,7 @@ def _apply_account_filters(
query,
*,
search: str,
tag: str,
bind_status: str,
enabled: bool | None,
):
@@ -109,6 +111,9 @@ def _apply_account_filters(
WeiboAccount.remark.ilike(pattern),
)
)
tag_value = _query_str(tag)
if tag_value:
query = query.filter(WeiboAccount.tag == tag_value)
if isinstance(enabled, bool):
query = query.filter(WeiboAccount.is_enabled.is_(enabled))
bind_value = _query_str(bind_status)
@@ -192,6 +197,7 @@ def _record_audit_safely(
@router.get("/accounts")
def list_accounts(
search: str = Query(""),
tag: str = Query(""),
bind_status: str = Query(""),
enabled: bool | None = Query(None),
page: int = Query(1, ge=1),
@@ -205,6 +211,7 @@ def list_accounts(
query = _apply_account_filters(
db.query(WeiboAccount),
search=search,
tag=tag,
bind_status=bind_status,
enabled=enabled,
)
@@ -214,6 +221,7 @@ def list_accounts(
@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),
@@ -235,6 +243,7 @@ def list_workbench_accounts(
query = _apply_account_filters(
query,
search=search,
tag=tag,
bind_status=bind_status,
enabled=enabled,
)
@@ -244,6 +253,7 @@ def list_workbench_accounts(
@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),
@@ -252,18 +262,42 @@ def list_workbench_pool(
"""查看可加入当前用户工作台、且尚未加入的账号池账号。"""
_require_weibo_perm(current, "weibo:workbench")
selected = db.query(WeiboWorkbenchAccount.account_id).filter(
WeiboWorkbenchAccount.user_id == current.id
)
occupied = db.query(WeiboWorkbenchAccount.account_id)
query = _apply_account_filters(
db.query(WeiboAccount).filter(WeiboAccount.id.not_in(selected)),
db.query(WeiboAccount).filter(WeiboAccount.id.not_in(occupied)),
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")
rows = (
db.query(WeiboAccount.tag)
.join(
WeiboWorkbenchAccount,
WeiboWorkbenchAccount.account_id == WeiboAccount.id,
)
.filter(
WeiboWorkbenchAccount.user_id == current.id,
WeiboAccount.tag != "",
)
.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,
@@ -287,6 +321,20 @@ def add_workbench_accounts(
missing = sorted(set(account_ids) - existing_ids)
if missing:
raise HTTPException(status_code=404, detail=f"账号不存在: {missing}")
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)
@@ -303,7 +351,14 @@ def add_workbench_accounts(
if account_id not in selected_ids
]
)
db.commit()
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(
status_code=409,
detail="账号已被其他工作台占用,请刷新后重试",
) from exc
_record_audit_safely(
db,
current,
@@ -379,6 +434,24 @@ def batch_tag_accounts(
return {"updated": len(accounts), "tag": tag, "success": True}
@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")
rows = (
db.query(WeiboAccount.tag)
.filter(WeiboAccount.tag != "")
.distinct()
.order_by(WeiboAccount.tag.asc())
.all()
)
return {"tags": [tag for (tag,) in rows if tag]}
@router.get("/accounts/{account_id}", response_model=WeiboAccountOut)
def get_account(
account_id: int,
@@ -482,6 +555,8 @@ def import_cookies(
req.text,
req.tag,
WEIBO_DEFAULT_ACTIVITY,
req.update_existing,
current.id,
)
typed_errors = [
WeiboImportError(line=item.line, message=item.message) for item in errors
@@ -499,13 +574,13 @@ def import_cookies(
"errors": len(errors),
},
)
message = f"成功导入/更新 {len(accounts)} 个微博账号"
message = f"成功导入 {len(accounts)} 个微博账号"
if errors:
message += f",失败 {len(errors)} 条"
if skipped:
message += f",跳过 {skipped} 条重复"
return WeiboImportResult(
success=bool(accounts),
success=bool(accounts) or (skipped > 0 and not errors),
message=message,
count=len(accounts),
skipped=skipped,
+1
View File
@@ -468,6 +468,7 @@ class WeiboCookieImport(BaseModel):
text: str = Field(..., min_length=1)
tag: str = Field("", max_length=64)
update_existing: bool = False
class WeiboImportError(BaseModel):
+20 -1
View File
@@ -18,7 +18,7 @@ from core.weibo import (
normalize_weibo_cookie,
)
from ..models import WeiboAccount
from ..models import WeiboAccount, WeiboWorkbenchAccount
@dataclass(frozen=True, slots=True)
@@ -61,6 +61,8 @@ def import_weibo_cookies(
text: str,
tag: str = "",
activity: str = "",
update_existing: bool = False,
current_user_id: int | None = None,
) -> tuple[list[WeiboAccount], list[WeiboImportIssue], int]:
"""逐行验证并导入微博 CK。
@@ -95,6 +97,23 @@ def import_weibo_cookies(
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:
owner_id = (
db.query(WeiboWorkbenchAccount.user_id)
.filter(WeiboWorkbenchAccount.account_id == account.id)
.scalar()
)
if owner_id is not None and owner_id != current_user_id:
errors.append(
WeiboImportIssue(
line=line_no,
message="该账号已被其他人的工作台占用,不能更新 CK",
)
)
continue
if account is not None and not update_existing:
skipped += 1
continue
if account is None:
account = WeiboAccount(uid=snapshot.uid, cookie=cookie)
db.add(account)