feat(weibo): 拆分账号管理与个人工作台
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
---
|
||||
type: 设计
|
||||
description: 微博外部 CK 账号池与客服个人工作台的拆分设计,覆盖数据归属、权限、接口和 UI。
|
||||
tags: [微博, CK, 账号池, 工作台, 权限, 协作]
|
||||
generated:
|
||||
by: ai/codex
|
||||
at: 2026-11-08T16:20:00+08:00
|
||||
status: implemented
|
||||
---
|
||||
|
||||
# 微博账号池与个人工作台
|
||||
|
||||
## 1. 目标
|
||||
|
||||
把原来的「账号与绑定」拆成两个入口:
|
||||
|
||||
- **账号管理**:全局微博账号池,负责 CK 导入、生命周期、标签、批量操作和资料维护。
|
||||
- **微博工作台**:每个用户自己的账号子集,只展示自己导入的账号,用于绑定、充值、兑换和日常操作。
|
||||
|
||||
工作台采用斗鱼模式:**账号实体仍然全局唯一,工作台只保存用户与账号的映射**。同一个账号可以被多人分别加入自己的工作台,工作台增删互不影响;底层 CK 刷新后,所有引用该账号的工作台看到同一份最新资料。
|
||||
|
||||
## 2. 数据模型
|
||||
|
||||
`weibo_accounts` 继续作为全局账号池,不允许因为工作台拆出第二份 CK。
|
||||
|
||||
新增:
|
||||
|
||||
```text
|
||||
weibo_workbench_accounts
|
||||
id
|
||||
user_id -> users.id
|
||||
account_id -> weibo_accounts.id
|
||||
created_at
|
||||
|
||||
UNIQUE(user_id, account_id)
|
||||
```
|
||||
|
||||
删除账号或用户时,对应工作台映射级联删除,不保留孤儿记录。
|
||||
|
||||
## 3. 权限
|
||||
|
||||
- `weibo:view`:查看全局账号管理。
|
||||
- `weibo:workbench`:访问个人微博工作台。
|
||||
- `weibo:import`:导入外部 CK。
|
||||
- `weibo:manage`:编辑账号标签/备注、启停账号、批量标签。
|
||||
- `weibo:account:ck_export`:复制账号 CK。
|
||||
- `weibo:bind`:生成绑定二维码。
|
||||
- `weibo:recharge`:创建充值订单。
|
||||
- `weibo:exchange`:执行兑换。
|
||||
|
||||
客服和运营默认拥有个人工作台及日常操作权限;删除账号仍由 `weibo:delete` 控制,客服默认没有。
|
||||
|
||||
## 4. 接口
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/weibo/accounts` | `weibo:view` | 全局账号池分页列表 |
|
||||
| GET | `/api/weibo/workbench/accounts` | `weibo:workbench` | 当前用户工作台账号分页列表 |
|
||||
| GET | `/api/weibo/workbench/pool` | `weibo:workbench` | 可加入当前工作台且尚未加入的账号 |
|
||||
| POST | `/api/weibo/workbench/accounts` | `weibo:workbench` | 批量加入工作台,幂等 |
|
||||
| POST | `/api/weibo/workbench/accounts/remove` | `weibo:workbench` | 批量移出工作台,不影响账号池和其他用户 |
|
||||
| POST | `/api/weibo/accounts/batch-tag` | `weibo:manage` | 账号管理页批量设置标签 |
|
||||
|
||||
工作台接口始终按 `current.id` 过滤,不接受客户端传入用户 ID。
|
||||
|
||||
## 5. UI
|
||||
|
||||
菜单:
|
||||
|
||||
```text
|
||||
微博
|
||||
-> 账号管理
|
||||
-> 微博工作台
|
||||
-> 外部单协作
|
||||
```
|
||||
|
||||
账号管理页:
|
||||
|
||||
- 标题「微博账号管理」。
|
||||
- 保留全量账号列表、生命周期、批量刷新、批量标签、启停和删除。
|
||||
- 用于维护账号池,不作为客服的日常入口。
|
||||
|
||||
工作台页:
|
||||
|
||||
- 标题「微博工作台」。
|
||||
- 复用账号能力,但列表只显示当前用户工作台映射。
|
||||
- 「导入账号」从账号池选择账号加入。
|
||||
- 「导入外部 CK」会先写入全局账号池,再自动加入当前工作台。
|
||||
- 支持单个/批量移出工作台,不删除账号池数据。
|
||||
- 绑定二维码、充值、兑换、功能查询继续复用现有账号接口。
|
||||
|
||||
## 6. 数据隔离边界
|
||||
|
||||
- 工作台列表、账号池候选和增删接口都按当前登录用户过滤。
|
||||
- 加入/移出只操作 `weibo_workbench_accounts`,不修改账号归属或 CK。
|
||||
- 账号池仍由全局权限控制;客服可以看到账号管理入口,但不能删除账号。
|
||||
- 外部单协作继续引用全局账号 ID,不受个人工作台选择影响。
|
||||
@@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent
|
||||
PROJECT_ROOT = TESTS_DIR.parent
|
||||
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
|
||||
|
||||
HEAD_REVISION = "20261108_0071"
|
||||
HEAD_REVISION = "20261108_0072"
|
||||
|
||||
# 迁移新增、但不声明在模型里的复合查询索引。
|
||||
EXTRA_INDEXES = (
|
||||
|
||||
@@ -18,10 +18,12 @@ from core.weibo import (
|
||||
WeiboRechargeError,
|
||||
WeiboRechargeOrder,
|
||||
)
|
||||
from web.backend.models import AuditLog, User, WeiboAccount
|
||||
from web.backend.models import AuditLog, User, WeiboAccount, WeiboWorkbenchAccount
|
||||
from web.backend.permissions import ROLE_PERMISSIONS
|
||||
from web.backend.routers import weibo as weibo_router
|
||||
from web.backend.routers.weibo import (
|
||||
add_workbench_accounts,
|
||||
batch_tag_accounts,
|
||||
copy_account_ck,
|
||||
create_bind_qrcodes,
|
||||
disable_account,
|
||||
@@ -29,16 +31,21 @@ from web.backend.routers.weibo import (
|
||||
get_account,
|
||||
import_cookies,
|
||||
list_accounts,
|
||||
list_workbench_accounts,
|
||||
list_workbench_pool,
|
||||
refresh_account,
|
||||
remove_workbench_accounts,
|
||||
update_account,
|
||||
)
|
||||
from web.backend.schemas import (
|
||||
WeiboAccountBatchTagRequest,
|
||||
WeiboAccountDisableRequest,
|
||||
WeiboAccountUpdate,
|
||||
WeiboCookieImport,
|
||||
WeiboExchangeRequest,
|
||||
WeiboPaymentStatusRequest,
|
||||
WeiboRechargeRequest,
|
||||
WeiboWorkbenchAccountsRequest,
|
||||
)
|
||||
from web.backend.services import weibo_service
|
||||
|
||||
@@ -291,6 +298,7 @@ def test_support_role_has_weibo_operation_permissions_without_delete():
|
||||
|
||||
assert {
|
||||
"weibo:view",
|
||||
"weibo:workbench",
|
||||
"weibo:import",
|
||||
"weibo:manage",
|
||||
"weibo:account:ck_export",
|
||||
@@ -304,6 +312,103 @@ def test_support_role_has_weibo_operation_permissions_without_delete():
|
||||
assert "weibo:collab:manage" not in support
|
||||
|
||||
|
||||
def test_weibo_workbench_is_isolated_per_user(db, current_user):
|
||||
first = WeiboAccount(
|
||||
uid="4014000101",
|
||||
nickname="工作台账号A",
|
||||
cookie="SCF=a; SUB=first",
|
||||
tag="A",
|
||||
)
|
||||
second = WeiboAccount(
|
||||
uid="4014000102",
|
||||
nickname="工作台账号B",
|
||||
cookie="SCF=b; SUB=second",
|
||||
tag="B",
|
||||
)
|
||||
db.add_all([first, second])
|
||||
db.commit()
|
||||
user_a = User(
|
||||
username="weibo-workbench-a",
|
||||
password_hash="hash",
|
||||
role="support",
|
||||
is_active=True,
|
||||
)
|
||||
user_b = User(
|
||||
username="weibo-workbench-b",
|
||||
password_hash="hash",
|
||||
role="support",
|
||||
is_active=True,
|
||||
)
|
||||
db.add_all([user_a, user_b])
|
||||
db.commit()
|
||||
|
||||
add_workbench_accounts(
|
||||
WeiboWorkbenchAccountsRequest(account_ids=[first.id]),
|
||||
db=db,
|
||||
current=user_a,
|
||||
)
|
||||
add_workbench_accounts(
|
||||
WeiboWorkbenchAccountsRequest(account_ids=[second.id]),
|
||||
db=db,
|
||||
current=user_b,
|
||||
)
|
||||
|
||||
page_a = list_workbench_accounts(page=1, page_size=20, db=db, current=user_a)
|
||||
page_b = list_workbench_accounts(page=1, page_size=20, db=db, current=user_b)
|
||||
assert [item.uid for item in page_a["items"]] == [first.uid]
|
||||
assert [item.uid for item in page_b["items"]] == [second.uid]
|
||||
assert db.query(WeiboWorkbenchAccount).count() == 2
|
||||
|
||||
pool_a = list_workbench_pool(page=1, page_size=20, db=db, current=user_a)
|
||||
assert [item.uid for item in pool_a["items"]] == [second.uid]
|
||||
|
||||
remove_workbench_accounts(
|
||||
WeiboWorkbenchAccountsRequest(account_ids=[first.id]),
|
||||
db=db,
|
||||
current=user_a,
|
||||
)
|
||||
assert (
|
||||
list_workbench_accounts(page=1, page_size=20, db=db, current=user_a)["total"]
|
||||
== 0
|
||||
)
|
||||
assert (
|
||||
list_workbench_accounts(page=1, page_size=20, db=db, current=user_b)["total"]
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def test_batch_tag_updates_selected_accounts(db, current_user):
|
||||
first = WeiboAccount(
|
||||
uid="4014000201",
|
||||
nickname="批量标签A",
|
||||
cookie="SCF=a; SUB=first",
|
||||
tag="旧A",
|
||||
)
|
||||
second = WeiboAccount(
|
||||
uid="4014000202",
|
||||
nickname="批量标签B",
|
||||
cookie="SCF=b; SUB=second",
|
||||
tag="旧B",
|
||||
)
|
||||
db.add_all([first, second])
|
||||
db.commit()
|
||||
|
||||
result = batch_tag_accounts(
|
||||
WeiboAccountBatchTagRequest(ids=[first.id, second.id], tag="客服组"),
|
||||
db=db,
|
||||
current=current_user,
|
||||
)
|
||||
|
||||
assert result["updated"] == 2
|
||||
tags = {
|
||||
account.id: account.tag
|
||||
for account in db.query(WeiboAccount)
|
||||
.filter(WeiboAccount.id.in_([first.id, second.id]))
|
||||
.all()
|
||||
}
|
||||
assert tags == {first.id: "客服组", second.id: "客服组"}
|
||||
|
||||
|
||||
def test_list_accounts_returns_parallel_page_shape(db, current_user):
|
||||
assert (
|
||||
import_cookies(
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""增加微博个人工作台账号映射。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20261108_0072"
|
||||
down_revision: str | None = "20261108_0071"
|
||||
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"):
|
||||
op.create_table(
|
||||
"weibo_workbench_accounts",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("account_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["account_id"],
|
||||
["weibo_accounts.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"user_id",
|
||||
"account_id",
|
||||
name="uq_weibo_workbench_account",
|
||||
),
|
||||
)
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
|
||||
existing_indexes = {
|
||||
index["name"] for index in inspector.get_indexes("weibo_workbench_accounts")
|
||||
}
|
||||
if "ix_weibo_workbench_accounts_user_id" not in existing_indexes:
|
||||
op.create_index(
|
||||
"ix_weibo_workbench_accounts_user_id",
|
||||
"weibo_workbench_accounts",
|
||||
["user_id"],
|
||||
)
|
||||
if "ix_weibo_workbench_accounts_account_id" not in existing_indexes:
|
||||
op.create_index(
|
||||
"ix_weibo_workbench_accounts_account_id",
|
||||
"weibo_workbench_accounts",
|
||||
["account_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("weibo_workbench_accounts"):
|
||||
return
|
||||
op.drop_table("weibo_workbench_accounts")
|
||||
@@ -961,6 +961,31 @@ class WeiboAccount(Base):
|
||||
)
|
||||
|
||||
|
||||
class WeiboWorkbenchAccount(Base):
|
||||
"""用户个人微博工作台中的账号选择。"""
|
||||
|
||||
__tablename__ = "weibo_workbench_accounts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
account_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("weibo_accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"user_id",
|
||||
"account_id",
|
||||
name="uq_weibo_workbench_account",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class WeiboCollabShop(Base):
|
||||
"""微博外部单协作的店铺字典。"""
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ PERMISSIONS = {
|
||||
"yyb:history": "查看应用宝充值历史",
|
||||
# 微博微豆
|
||||
"weibo:view": "查看微博账号",
|
||||
"weibo:workbench": "使用个人微博工作台",
|
||||
"weibo:import": "导入/刷新微博 CK",
|
||||
"weibo:manage": "编辑、停用和启用微博账号",
|
||||
"weibo:account:ck_export": "复制微博外部 CK 账号的 CK(写剪贴板)",
|
||||
@@ -114,6 +115,7 @@ ROLE_PERMISSIONS = {
|
||||
"yyb:manage",
|
||||
"yyb:history",
|
||||
"weibo:view",
|
||||
"weibo:workbench",
|
||||
"weibo:import",
|
||||
"weibo:manage",
|
||||
"weibo:account:ck_export",
|
||||
@@ -160,6 +162,7 @@ ROLE_PERMISSIONS = {
|
||||
"yyb:session",
|
||||
"yyb:history",
|
||||
"weibo:view",
|
||||
"weibo:workbench",
|
||||
"weibo:import",
|
||||
"weibo:manage",
|
||||
"weibo:account:ck_export",
|
||||
|
||||
+227
-39
@@ -13,9 +13,10 @@ from core.weibo import WeiboAuthError, WeiboError, WeiboExchangeUncertainError
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user
|
||||
from ..models import User, WeiboAccount
|
||||
from ..models import User, WeiboAccount, WeiboWorkbenchAccount
|
||||
from ..permissions import user_has_permission
|
||||
from ..schemas import (
|
||||
WeiboAccountBatchTagRequest,
|
||||
WeiboAccountCkOut,
|
||||
WeiboAccountDisableRequest,
|
||||
WeiboAccountOut,
|
||||
@@ -37,6 +38,7 @@ from ..schemas import (
|
||||
WeiboPointRecordItem,
|
||||
WeiboRechargeOrderOut,
|
||||
WeiboRechargeRequest,
|
||||
WeiboWorkbenchAccountsRequest,
|
||||
)
|
||||
from ..services.audit_service import record_audit
|
||||
from ..services.weibo_service import (
|
||||
@@ -88,6 +90,57 @@ def _account_out(account: WeiboAccount) -> WeiboAccountOut:
|
||||
return WeiboAccountOut.model_validate(account)
|
||||
|
||||
|
||||
def _apply_account_filters(
|
||||
query,
|
||||
*,
|
||||
search: str,
|
||||
bind_status: str,
|
||||
enabled: bool | 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),
|
||||
)
|
||||
)
|
||||
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(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="微博账号已停用,请先启用")
|
||||
@@ -149,46 +202,181 @@ def list_accounts(
|
||||
"""查看已导入的微博账号。"""
|
||||
|
||||
_require_weibo_perm(current, "weibo:view")
|
||||
query = db.query(WeiboAccount)
|
||||
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),
|
||||
)
|
||||
)
|
||||
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),
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
rows = (
|
||||
query.options(defer(WeiboAccount.cookie))
|
||||
.order_by(WeiboAccount.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
query = _apply_account_filters(
|
||||
db.query(WeiboAccount),
|
||||
search=search,
|
||||
bind_status=bind_status,
|
||||
enabled=enabled,
|
||||
)
|
||||
return {
|
||||
"items": [_account_out(account) for account in rows],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
return _paginated_accounts(query, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/workbench/accounts")
|
||||
def list_workbench_accounts(
|
||||
search: 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)
|
||||
)
|
||||
query = _apply_account_filters(
|
||||
query,
|
||||
search=search,
|
||||
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(""),
|
||||
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")
|
||||
selected = db.query(WeiboWorkbenchAccount.account_id).filter(
|
||||
WeiboWorkbenchAccount.user_id == current.id
|
||||
)
|
||||
query = _apply_account_filters(
|
||||
db.query(WeiboAccount).filter(WeiboAccount.id.not_in(selected)),
|
||||
search=search,
|
||||
bind_status="",
|
||||
enabled=True,
|
||||
)
|
||||
return _paginated_accounts(query, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@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}")
|
||||
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
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
_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 = 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}")
|
||||
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.get("/accounts/{account_id}", response_model=WeiboAccountOut)
|
||||
|
||||
@@ -505,6 +505,15 @@ class WeiboAccountUpdate(BaseModel):
|
||||
remark: str | None = Field(None, max_length=256)
|
||||
|
||||
|
||||
class WeiboAccountBatchTagRequest(BaseModel):
|
||||
ids: list[int] = Field(min_length=1)
|
||||
tag: str = Field("", max_length=64)
|
||||
|
||||
|
||||
class WeiboWorkbenchAccountsRequest(BaseModel):
|
||||
account_ids: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WeiboAccountDisableRequest(BaseModel):
|
||||
reason: str = Field("", max_length=256)
|
||||
|
||||
|
||||
@@ -107,7 +107,8 @@ function AppContent() {
|
||||
<Route path="huya/elite" element={lazyRoute(<HuyaElitePage />)} />
|
||||
<Route path="huya/collab" element={lazyRoute(<HuyaCollabPage />)} />
|
||||
<Route path="huya/browser-exchange" element={lazyRoute(<HuyaBrowserExchangePage />)} />
|
||||
<Route path="weibo/accounts" element={lazyRoute(<WeiboAccountsPage />)} />
|
||||
<Route path="weibo/accounts" element={lazyRoute(<WeiboAccountsPage mode="management" />)} />
|
||||
<Route path="weibo/workbench" element={lazyRoute(<WeiboAccountsPage mode="workbench" />)} />
|
||||
<Route path="weibo/collab" element={lazyRoute(<WeiboCollabPage />)} />
|
||||
<Route path="yyb/recharge" element={lazyRoute(<YybRechargePage />)} />
|
||||
<Route path="proxy" element={lazyRoute(<ProxyPage />)} />
|
||||
|
||||
@@ -807,6 +807,17 @@ export interface WeiboAccountCkResult {
|
||||
ck_text: string;
|
||||
}
|
||||
|
||||
export interface WeiboWorkbenchMutationResult {
|
||||
account_ids: number[];
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface WeiboBatchTagResult {
|
||||
updated: number;
|
||||
tag: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface WeiboGoodsItem {
|
||||
id: number;
|
||||
goods_name: string;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
PaginatedResponse,
|
||||
WeiboAccountItem,
|
||||
WeiboAccountCkResult,
|
||||
WeiboBatchTagResult,
|
||||
WeiboBindQrResult,
|
||||
WeiboExchangeRecordsResult,
|
||||
WeiboExchangeResult,
|
||||
@@ -12,6 +13,7 @@ import type {
|
||||
WeiboPointHistoryResult,
|
||||
WeiboRechargeOrderResult,
|
||||
WeiboImportResult,
|
||||
WeiboWorkbenchMutationResult,
|
||||
} from './types';
|
||||
|
||||
export const weiboApi = {
|
||||
@@ -60,6 +62,41 @@ export const weiboApi = {
|
||||
api.post<WeiboAccountCkResult, WeiboAccountCkResult>(
|
||||
`/weibo/accounts/${accountId}/ck/copy`,
|
||||
),
|
||||
batchTagAccounts: (ids: number[], tag: string) =>
|
||||
api.post<WeiboBatchTagResult, WeiboBatchTagResult>(
|
||||
'/weibo/accounts/batch-tag',
|
||||
{ ids, tag },
|
||||
),
|
||||
listWorkbenchAccounts: (params: {
|
||||
search?: string;
|
||||
bind_status?: string;
|
||||
enabled?: boolean;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}) =>
|
||||
api.get<PaginatedResponse<WeiboAccountItem>, PaginatedResponse<WeiboAccountItem>>(
|
||||
'/weibo/workbench/accounts',
|
||||
{ params },
|
||||
),
|
||||
listWorkbenchPool: (params: {
|
||||
search?: string;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}) =>
|
||||
api.get<PaginatedResponse<WeiboAccountItem>, PaginatedResponse<WeiboAccountItem>>(
|
||||
'/weibo/workbench/pool',
|
||||
{ params },
|
||||
),
|
||||
addWorkbenchAccounts: (accountIds: number[]) =>
|
||||
api.post<WeiboWorkbenchMutationResult, WeiboWorkbenchMutationResult>(
|
||||
'/weibo/workbench/accounts',
|
||||
{ account_ids: accountIds },
|
||||
),
|
||||
removeWorkbenchAccounts: (accountIds: number[]) =>
|
||||
api.post<WeiboWorkbenchMutationResult, WeiboWorkbenchMutationResult>(
|
||||
'/weibo/workbench/accounts/remove',
|
||||
{ account_ids: accountIds },
|
||||
),
|
||||
bindQrcodes: (accountId: number, channel: 'wx' | 'qq') =>
|
||||
api.post<WeiboBindQrResult, WeiboBindQrResult>(
|
||||
`/weibo/accounts/${accountId}/bind-qrcodes`,
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ApartmentOutlined, MobileOutlined, ShoppingCartOutlined,
|
||||
BookOutlined, TrophyOutlined, ShopOutlined, GlobalOutlined,
|
||||
SafetyCertificateOutlined, FileSearchOutlined, RobotOutlined, FileExcelOutlined,
|
||||
MessageOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||
import { getUser, clearAuth, type AuthUser } from '../store/auth';
|
||||
@@ -127,7 +126,10 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
|
||||
}
|
||||
|
||||
if (can('weibo:view')) {
|
||||
weiboItems.push({ key: '/weibo/accounts', label: '账号与绑定', icon: <MessageOutlined /> });
|
||||
weiboItems.push({ key: '/weibo/accounts', label: '账号管理', icon: <UserOutlined /> });
|
||||
}
|
||||
if (can('weibo:workbench')) {
|
||||
weiboItems.push({ key: '/weibo/workbench', label: '微博工作台', icon: <DesktopOutlined /> });
|
||||
}
|
||||
if (can('weibo:collab:view')) {
|
||||
weiboItems.push({ key: '/weibo/collab', label: '外部单协作', icon: <FileExcelOutlined /> });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user