feat(weibo): 支持管理员强制转入工作台且不影响原使用人
This commit is contained in:
@@ -18,7 +18,8 @@ status: implemented
|
||||
- **分配管理**:管理员和运营把全局账号分配给客服,支持批量分配、取消分配和按客服查看。
|
||||
- **微博工作台**:每个用户自己的账号子集,只展示自己导入的账号,用于绑定、充值、兑换和日常操作。
|
||||
|
||||
工作台采用斗鱼模式:**账号实体仍然全局唯一,工作台只保存用户与账号的映射**。同一个全局账号同一时间只能被一个用户加入工作台;当前使用人移出后,其他人才可以加入。
|
||||
工作台采用斗鱼模式:**账号实体仍然全局唯一,工作台只保存用户与账号的映射**。
|
||||
普通用户之间保持独占;超级管理员可以额外增加并行映射,不改变、也不阻塞原使用人。
|
||||
|
||||
## 2. 数据模型
|
||||
|
||||
@@ -33,8 +34,8 @@ weibo_accounts.assigned_to -> users.id
|
||||
- `assigned_to` 为空表示未分配。
|
||||
- 管理员和运营可查看全部账号并修改归属。
|
||||
- 客服的账号管理、标签、工作台候选、绑定、充值、兑换、复制 CK 等操作全部只允许访问 `assigned_to = 当前用户` 的账号。
|
||||
- 同一账号改分配给新客服时,旧客服工作台中的映射会自动清除,避免账号被旧工作台继续占用。
|
||||
- 取消分配时清除该账号的全部工作台映射。
|
||||
- 同一账号改分配给新客服时,旧客服的普通工作台映射会自动清除,避免账号被旧工作台继续占用。
|
||||
- 取消分配时清除普通工作台映射;超级管理员的并行映射保留。
|
||||
|
||||
新增:
|
||||
|
||||
@@ -43,10 +44,12 @@ weibo_workbench_accounts
|
||||
id
|
||||
user_id -> users.id
|
||||
account_id -> weibo_accounts.id
|
||||
exclusive_slot -- 普通用户固定为 1,超级管理员为 NULL
|
||||
created_at
|
||||
|
||||
UNIQUE(user_id, account_id)
|
||||
UNIQUE(account_id) -- 同一账号同时只能属于一个工作台
|
||||
UNIQUE(account_id, exclusive_slot)
|
||||
-- MySQL 允许多个 NULL,因此一个账号可保留普通使用人,同时被多个超级管理员并行使用
|
||||
```
|
||||
|
||||
删除账号或用户时,对应工作台映射级联删除,不保留孤儿记录。
|
||||
@@ -148,6 +151,9 @@ UNIQUE(account_id) -- 同一账号同时只能属于一个工作台
|
||||
中文逗号、分号分隔;后端先按 UID 精确匹配,再按用户名、角色名兜底解析,
|
||||
重名时必须改用 UID。导入结果逐项区分:已导入、已存在、未找到、重名、
|
||||
被其他工作台占用、账号停用和名单内重复。原来的「从账号池选择」页签完整保留。
|
||||
超级管理员的批量粘贴默认允许强制转入被其他用户占用的账号:服务端新增一条
|
||||
`exclusive_slot = NULL` 的并行映射,不修改原使用人的工作台、账号归属或 CK。
|
||||
普通客服仍不能强制转入。结果会把强制转入单独计数,并记录原使用人和时间。
|
||||
|
||||
### 5.3 工作台转入外部单协作
|
||||
|
||||
@@ -159,7 +165,8 @@ UNIQUE(account_id) -- 同一账号同时只能属于一个工作台
|
||||
## 6. 数据隔离边界
|
||||
|
||||
- 工作台列表和增删接口都按当前登录用户过滤。
|
||||
- 客服的账号池候选只包含分配给自己的账号,并排除所有已被其他用户工作台占用的账号。
|
||||
- 客服的账号池候选只包含分配给自己的账号,并排除所有已被其他普通用户独占的账号。
|
||||
- 超级管理员的账号池候选只排除自己已经加入的账号,其他人的占用不阻塞。
|
||||
- 加入/移出只操作 `weibo_workbench_accounts`,不修改账号归属或 CK。
|
||||
- 账号池全量查看由 `weibo:assign` 控制;客服可以看到账号管理入口,但列表和所有详情/操作接口只返回分配给自己的账号。
|
||||
- 外部单协作继续引用全局账号 ID,不受个人工作台选择影响。
|
||||
|
||||
+115
-1
@@ -17,16 +17,18 @@ from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import web.backend.database as database_module
|
||||
from tests.support.db import get_mysql_credentials, get_test_engine
|
||||
from web.backend.database import Base
|
||||
from web.backend.models import User, WeiboAccount
|
||||
|
||||
TESTS_DIR = Path(__file__).resolve().parent
|
||||
PROJECT_ROOT = TESTS_DIR.parent
|
||||
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
|
||||
|
||||
HEAD_REVISION = "20261111_0076"
|
||||
HEAD_REVISION = "20261112_0077"
|
||||
|
||||
# 迁移新增、但不声明在模型里的复合查询索引。
|
||||
EXTRA_INDEXES = (
|
||||
@@ -154,6 +156,118 @@ class TestMigrationSmoke:
|
||||
with admin_engine.begin() as conn:
|
||||
conn.execute(text(f"DROP DATABASE IF EXISTS `{mig_db}`;"))
|
||||
|
||||
def test_0077_converts_super_admin_membership_to_overlay(self):
|
||||
creds = get_mysql_credentials()
|
||||
mig_db = f"douyu_login_mig_{secrets.token_hex(4)}"
|
||||
mig_url = (
|
||||
f"mysql+pymysql://{quote_plus(creds['user'])}:"
|
||||
f"{quote_plus(creds['password'])}@{creds['host']}:{creds['port']}/"
|
||||
f"{mig_db}?charset=utf8mb4"
|
||||
)
|
||||
|
||||
admin_engine = get_test_engine()
|
||||
with admin_engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
f"CREATE DATABASE `{mig_db}` "
|
||||
"CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;"
|
||||
)
|
||||
)
|
||||
|
||||
original_url = database_module.DATABASE_URL
|
||||
mig_engine = create_engine(mig_url)
|
||||
try:
|
||||
database_module.DATABASE_URL = mig_url
|
||||
cfg = _migration_config()
|
||||
cfg.set_main_option("sqlalchemy.url", mig_url)
|
||||
command.upgrade(cfg, "20261111_0076")
|
||||
|
||||
with Session(mig_engine) as session:
|
||||
admin = User(
|
||||
username="migration-admin",
|
||||
password_hash="hash",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
support = User(
|
||||
username="migration-support",
|
||||
password_hash="hash",
|
||||
role="support",
|
||||
is_active=True,
|
||||
)
|
||||
admin_account = WeiboAccount(
|
||||
uid="4014000771",
|
||||
nickname="管理员旧映射",
|
||||
cookie="SCF=a; SUB=admin",
|
||||
)
|
||||
support_account = WeiboAccount(
|
||||
uid="4014000772",
|
||||
nickname="客服旧映射",
|
||||
cookie="SCF=b; SUB=support",
|
||||
)
|
||||
session.add_all([admin, support, admin_account, support_account])
|
||||
session.commit()
|
||||
admin_id = admin.id
|
||||
support_id = support.id
|
||||
admin_account_id = admin_account.id
|
||||
support_account_id = support_account.id
|
||||
|
||||
with mig_engine.begin() as conn:
|
||||
for user_id, account_id in (
|
||||
(admin_id, admin_account_id),
|
||||
(support_id, support_account_id),
|
||||
):
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO weibo_workbench_accounts "
|
||||
"(user_id, account_id, created_at) "
|
||||
"VALUES (:user_id, :account_id, UTC_TIMESTAMP())"
|
||||
),
|
||||
{"user_id": user_id, "account_id": account_id},
|
||||
)
|
||||
|
||||
command.upgrade(cfg, "20261112_0077")
|
||||
|
||||
with mig_engine.begin() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"SELECT user_id, account_id, exclusive_slot "
|
||||
"FROM weibo_workbench_accounts ORDER BY user_id"
|
||||
)
|
||||
).all()
|
||||
assert {
|
||||
(user_id, account_id, exclusive_slot)
|
||||
for user_id, account_id, exclusive_slot in rows
|
||||
} == {
|
||||
(admin_id, admin_account_id, None),
|
||||
(support_id, support_account_id, 1),
|
||||
}
|
||||
|
||||
# 管理员可以给同一个账号再挂一条并行映射,普通唯一槽位保持不变。
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO weibo_workbench_accounts "
|
||||
"(user_id, account_id, exclusive_slot, created_at) "
|
||||
"VALUES (:user_id, :account_id, NULL, UTC_TIMESTAMP())"
|
||||
),
|
||||
{"user_id": admin_id, "account_id": support_account_id},
|
||||
)
|
||||
assert (
|
||||
conn.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM weibo_workbench_accounts "
|
||||
"WHERE account_id = :account_id"
|
||||
),
|
||||
{"account_id": support_account_id},
|
||||
).scalar_one()
|
||||
== 2
|
||||
)
|
||||
finally:
|
||||
database_module.DATABASE_URL = original_url
|
||||
mig_engine.dispose()
|
||||
with admin_engine.begin() as conn:
|
||||
conn.execute(text(f"DROP DATABASE IF EXISTS `{mig_db}`;"))
|
||||
|
||||
|
||||
class TestRollbackBackfillScript:
|
||||
"""回滚前置脚本:把账号侧 CK 搬回 `login_tasks`。
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import ClassVar
|
||||
|
||||
import pytest
|
||||
@@ -277,6 +278,7 @@ def test_occupied_workbench_account_cannot_be_updated_by_another_user(db, curren
|
||||
WeiboWorkbenchAccount(
|
||||
user_id=owner.id,
|
||||
account_id=imported.id,
|
||||
exclusive_slot=1,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
@@ -549,8 +551,16 @@ def test_batch_import_workbench_accounts_reports_each_identifier(db, current_use
|
||||
db.commit()
|
||||
db.add_all(
|
||||
[
|
||||
WeiboWorkbenchAccount(user_id=current_user.id, account_id=existing.id),
|
||||
WeiboWorkbenchAccount(user_id=other_user.id, account_id=occupied.id),
|
||||
WeiboWorkbenchAccount(
|
||||
user_id=current_user.id,
|
||||
account_id=existing.id,
|
||||
exclusive_slot=1,
|
||||
),
|
||||
WeiboWorkbenchAccount(
|
||||
user_id=other_user.id,
|
||||
account_id=occupied.id,
|
||||
exclusive_slot=1,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
@@ -581,6 +591,11 @@ def test_batch_import_workbench_accounts_reports_each_identifier(db, current_use
|
||||
"批量导入重名": "ambiguous",
|
||||
"批量导入停用": "disabled",
|
||||
}
|
||||
occupied_item = next(
|
||||
item for item in result["items"] if item["identifier"] == occupied.uid
|
||||
)
|
||||
assert occupied_item["occupied_by_user_id"] == other_user.id
|
||||
assert occupied_item["occupied_by_username"] == other_user.username
|
||||
workbench_ids = {
|
||||
account_id
|
||||
for (account_id,) in db.query(WeiboWorkbenchAccount.account_id)
|
||||
@@ -590,6 +605,151 @@ def test_batch_import_workbench_accounts_reports_each_identifier(db, current_use
|
||||
assert workbench_ids == {existing.id, added.id}
|
||||
|
||||
|
||||
def test_super_admin_force_adds_account_without_removing_original_owner(
|
||||
db, current_user
|
||||
):
|
||||
other_user = User(
|
||||
username="weibo-batch-import-owner",
|
||||
password_hash="hash",
|
||||
role="support",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(other_user)
|
||||
db.commit()
|
||||
account = WeiboAccount(
|
||||
uid="4014000911",
|
||||
nickname="用户8477791859",
|
||||
cookie="SCF=a; SUB=force-add",
|
||||
assigned_to=other_user.id,
|
||||
)
|
||||
db.add(account)
|
||||
db.commit()
|
||||
db.add(
|
||||
WeiboWorkbenchAccount(
|
||||
user_id=other_user.id,
|
||||
account_id=account.id,
|
||||
exclusive_slot=1,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
admin_pool = list_workbench_pool(page=1, page_size=20, db=db, current=current_user)
|
||||
assert [item.id for item in admin_pool["items"]] == [account.id]
|
||||
|
||||
result = import_workbench_accounts_by_text(
|
||||
WeiboWorkbenchBatchImportRequest(
|
||||
text=account.nickname,
|
||||
force_add_occupied=True,
|
||||
),
|
||||
db=db,
|
||||
current=current_user,
|
||||
).model_dump()
|
||||
|
||||
assert result["requested"] == 1
|
||||
assert result["added"] == 0
|
||||
assert result["forced_added"] == 1
|
||||
assert result["already_added"] == 0
|
||||
assert result["failed"] == 0
|
||||
assert result["account_ids"] == [account.id]
|
||||
assert result["items"] == [
|
||||
{
|
||||
"identifier": account.nickname,
|
||||
"status": "forced_added",
|
||||
"message": (f"已强制转入,{other_user.username}的工作台保持不变"),
|
||||
"account_id": account.id,
|
||||
"uid": account.uid,
|
||||
"nickname": account.nickname,
|
||||
"occupied_by_user_id": other_user.id,
|
||||
"occupied_by_username": other_user.username,
|
||||
}
|
||||
]
|
||||
|
||||
db.expire_all()
|
||||
memberships = (
|
||||
db.query(WeiboWorkbenchAccount)
|
||||
.filter(WeiboWorkbenchAccount.account_id == account.id)
|
||||
.order_by(WeiboWorkbenchAccount.user_id)
|
||||
.all()
|
||||
)
|
||||
assert {
|
||||
(membership.user_id, membership.exclusive_slot) for membership in memberships
|
||||
} == {
|
||||
(other_user.id, 1),
|
||||
(current_user.id, None),
|
||||
}
|
||||
assert (
|
||||
list_workbench_accounts(page=1, page_size=20, db=db, current=other_user)[
|
||||
"total"
|
||||
]
|
||||
== 1
|
||||
)
|
||||
assert (
|
||||
list_workbench_accounts(page=1, page_size=20, db=db, current=current_user)[
|
||||
"total"
|
||||
]
|
||||
== 1
|
||||
)
|
||||
|
||||
StubWeiboClient.snapshot = replace(StubWeiboClient.snapshot, uid=account.uid)
|
||||
updated = import_cookies(
|
||||
WeiboCookieImport(
|
||||
text="SCF=updated; SUB=owner-still-works",
|
||||
update_existing=True,
|
||||
),
|
||||
db=db,
|
||||
current=other_user,
|
||||
)
|
||||
assert updated.count == 1
|
||||
assert updated.errors == []
|
||||
db.refresh(account)
|
||||
assert "SUB=owner-still-works" in account.cookie
|
||||
|
||||
remove_workbench_accounts(
|
||||
WeiboWorkbenchAccountsRequest(account_ids=[account.id]),
|
||||
db=db,
|
||||
current=current_user,
|
||||
)
|
||||
assert (
|
||||
list_workbench_accounts(page=1, page_size=20, db=db, current=other_user)[
|
||||
"total"
|
||||
]
|
||||
== 1
|
||||
)
|
||||
audit = (
|
||||
db.query(AuditLog)
|
||||
.filter(AuditLog.action == "weibo:workbench:batch_import")
|
||||
.one()
|
||||
)
|
||||
assert f'"forced_added_account_ids":[{account.id}]' in audit.detail
|
||||
assert f'"existing_owner_user_id":{other_user.id}' in audit.detail
|
||||
assert f'"existing_owner_username":"{other_user.username}"' in audit.detail
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["operation", "support"])
|
||||
def test_non_super_admin_cannot_force_add_occupied_workbench_account(db, role):
|
||||
user = User(
|
||||
username=f"weibo-force-add-{role}",
|
||||
password_hash="hash",
|
||||
role=role,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
import_workbench_accounts_by_text(
|
||||
WeiboWorkbenchBatchImportRequest(
|
||||
text="用户8477791859",
|
||||
force_add_occupied=True,
|
||||
),
|
||||
db=db,
|
||||
current=user,
|
||||
)
|
||||
|
||||
assert excinfo.value.status_code == 403
|
||||
assert "仅超级管理员" in str(excinfo.value.detail)
|
||||
|
||||
|
||||
def test_operation_can_transfer_workbench_account_to_collab(db):
|
||||
operator = User(
|
||||
username="weibo-transfer-operator",
|
||||
@@ -612,6 +772,7 @@ def test_operation_can_transfer_workbench_account_to_collab(db):
|
||||
WeiboWorkbenchAccount(
|
||||
user_id=operator.id,
|
||||
account_id=account.id,
|
||||
exclusive_slot=1,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
@@ -830,7 +991,13 @@ def test_weibo_batch_assign_cleans_previous_workbench(db):
|
||||
role="support",
|
||||
is_active=True,
|
||||
)
|
||||
db.add_all([operator, old_support, new_support])
|
||||
admin = User(
|
||||
username="weibo-assign-admin",
|
||||
password_hash="hash",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
db.add_all([operator, old_support, new_support, admin])
|
||||
db.commit()
|
||||
account = WeiboAccount(
|
||||
uid="4014000311",
|
||||
@@ -845,6 +1012,14 @@ def test_weibo_batch_assign_cleans_previous_workbench(db):
|
||||
db=db,
|
||||
current=old_support,
|
||||
)
|
||||
db.add(
|
||||
WeiboWorkbenchAccount(
|
||||
user_id=admin.id,
|
||||
account_id=account.id,
|
||||
exclusive_slot=None,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
result = batch_assign_accounts(
|
||||
WeiboBatchAssignRequest(
|
||||
@@ -858,17 +1033,25 @@ def test_weibo_batch_assign_cleans_previous_workbench(db):
|
||||
assert result["updated"] == 1
|
||||
db.refresh(account)
|
||||
assert account.assigned_to == new_support.id
|
||||
assert (
|
||||
memberships = (
|
||||
db.query(WeiboWorkbenchAccount)
|
||||
.filter(WeiboWorkbenchAccount.account_id == account.id)
|
||||
.count()
|
||||
== 0
|
||||
.all()
|
||||
)
|
||||
assert {
|
||||
(membership.user_id, membership.exclusive_slot) for membership in memberships
|
||||
} == {(admin.id, None)}
|
||||
add_workbench_accounts(
|
||||
WeiboWorkbenchAccountsRequest(account_ids=[account.id]),
|
||||
db=db,
|
||||
current=new_support,
|
||||
)
|
||||
assert (
|
||||
db.query(WeiboWorkbenchAccount)
|
||||
.filter(WeiboWorkbenchAccount.account_id == account.id)
|
||||
.count()
|
||||
== 2
|
||||
)
|
||||
audit = (
|
||||
db.query(AuditLog)
|
||||
.filter(AuditLog.action == "weibo:account:assign")
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""允许超级管理员在不影响原使用人的情况下附加微博工作台。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20261112_0077"
|
||||
down_revision: str | None = "20261111_0076"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
TABLE = "weibo_workbench_accounts"
|
||||
OLD_UNIQUE = "uq_weibo_workbench_account_owner"
|
||||
NEW_UNIQUE = "uq_weibo_workbench_account_exclusive"
|
||||
CHECK_CONSTRAINT = "ck_weibo_workbench_exclusive_slot"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table(TABLE):
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns(TABLE)}
|
||||
if "exclusive_slot" not in columns:
|
||||
op.add_column(
|
||||
TABLE,
|
||||
sa.Column(
|
||||
"exclusive_slot",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
server_default="1",
|
||||
),
|
||||
)
|
||||
|
||||
constraints = {item["name"] for item in inspector.get_unique_constraints(TABLE)}
|
||||
if OLD_UNIQUE in constraints:
|
||||
op.drop_constraint(OLD_UNIQUE, TABLE, type_="unique")
|
||||
|
||||
# 迁移前超级管理员已有映射时,改为并行挂载,不能继续占用唯一槽位。
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE weibo_workbench_accounts AS w "
|
||||
"JOIN users AS u ON u.id = w.user_id "
|
||||
"SET w.exclusive_slot = NULL "
|
||||
"WHERE u.role = 'super_admin'"
|
||||
)
|
||||
)
|
||||
|
||||
constraints = {
|
||||
item["name"] for item in sa.inspect(op.get_bind()).get_unique_constraints(TABLE)
|
||||
}
|
||||
if NEW_UNIQUE not in constraints:
|
||||
op.create_unique_constraint(
|
||||
NEW_UNIQUE,
|
||||
TABLE,
|
||||
["account_id", "exclusive_slot"],
|
||||
)
|
||||
checks = {
|
||||
item["name"] for item in sa.inspect(op.get_bind()).get_check_constraints(TABLE)
|
||||
}
|
||||
if CHECK_CONSTRAINT not in checks:
|
||||
op.create_check_constraint(
|
||||
CHECK_CONSTRAINT,
|
||||
TABLE,
|
||||
"exclusive_slot IS NULL OR exclusive_slot = 1",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table(TABLE):
|
||||
return
|
||||
|
||||
constraints = {item["name"] for item in inspector.get_unique_constraints(TABLE)}
|
||||
if NEW_UNIQUE in constraints:
|
||||
op.drop_constraint(NEW_UNIQUE, TABLE, type_="unique")
|
||||
checks = {item["name"] for item in inspector.get_check_constraints(TABLE)}
|
||||
if CHECK_CONSTRAINT in checks:
|
||||
op.drop_constraint(CHECK_CONSTRAINT, TABLE, type_="check")
|
||||
|
||||
# 旧结构无法表达多个并行映射,降级时只保留普通用户唯一占用。
|
||||
op.execute(sa.text(f"DELETE FROM {TABLE} WHERE exclusive_slot IS NULL"))
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns(TABLE)}
|
||||
if "exclusive_slot" in columns:
|
||||
op.drop_column(TABLE, "exclusive_slot")
|
||||
|
||||
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 sa.inspect(op.get_bind()).get_unique_constraints(TABLE)
|
||||
}
|
||||
if OLD_UNIQUE not in constraints:
|
||||
op.create_unique_constraint(
|
||||
OLD_UNIQUE,
|
||||
TABLE,
|
||||
["account_id"],
|
||||
)
|
||||
+10
-1
@@ -8,6 +8,7 @@ from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Computed,
|
||||
ForeignKey,
|
||||
Index,
|
||||
@@ -989,6 +990,9 @@ class WeiboWorkbenchAccount(Base):
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# 普通用户固定为 1;超级管理员的并行工作台映射为 NULL。
|
||||
# MySQL 唯一索引允许多个 NULL,因此管理员附加映射不会挤掉原使用人。
|
||||
exclusive_slot: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
@@ -999,7 +1003,12 @@ class WeiboWorkbenchAccount(Base):
|
||||
),
|
||||
UniqueConstraint(
|
||||
"account_id",
|
||||
name="uq_weibo_workbench_account_owner",
|
||||
"exclusive_slot",
|
||||
name="uq_weibo_workbench_account_exclusive",
|
||||
),
|
||||
CheckConstraint(
|
||||
"exclusive_slot IS NULL OR exclusive_slot = 1",
|
||||
name="ck_weibo_workbench_exclusive_slot",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
+130
-33
@@ -313,8 +313,18 @@ def list_workbench_pool(
|
||||
"""查看可加入当前用户工作台、且尚未加入的账号池账号。"""
|
||||
|
||||
_require_weibo_perm(current, "weibo:workbench")
|
||||
occupied = db.query(WeiboWorkbenchAccount.account_id)
|
||||
pool_query = db.query(WeiboAccount).filter(WeiboAccount.id.not_in(occupied))
|
||||
if current.role == "super_admin":
|
||||
selected = db.query(WeiboWorkbenchAccount.account_id).filter(
|
||||
WeiboWorkbenchAccount.user_id == current.id
|
||||
)
|
||||
pool_query = db.query(WeiboAccount).filter(WeiboAccount.id.not_in(selected))
|
||||
else:
|
||||
exclusively_occupied = db.query(WeiboWorkbenchAccount.account_id).filter(
|
||||
WeiboWorkbenchAccount.exclusive_slot.is_not(None)
|
||||
)
|
||||
pool_query = db.query(WeiboAccount).filter(
|
||||
WeiboAccount.id.not_in(exclusively_occupied)
|
||||
)
|
||||
if not _can_view_all_weibo_accounts(current):
|
||||
pool_query = pool_query.filter(WeiboAccount.assigned_to == current.id)
|
||||
query = _apply_account_filters(
|
||||
@@ -339,6 +349,11 @@ def import_workbench_accounts_by_text(
|
||||
"""按 UID/用户名/角色批量解析账号并加入当前用户工作台。"""
|
||||
|
||||
_require_weibo_perm(current, "weibo:workbench")
|
||||
if req.force_add_occupied and current.role != "super_admin":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="仅超级管理员可以强制转入其他人的工作台账号",
|
||||
)
|
||||
identifiers: list[str] = []
|
||||
for token in re.split(r"[\s,,;;]+", req.text.strip()):
|
||||
value = token.strip()
|
||||
@@ -373,18 +388,42 @@ def import_workbench_accounts_by_text(
|
||||
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(
|
||||
memberships: dict[int, list[tuple[int, str, int | None]]] = {}
|
||||
for account_id, user_id, username, exclusive_slot in (
|
||||
db.query(
|
||||
WeiboWorkbenchAccount.account_id,
|
||||
WeiboWorkbenchAccount.user_id,
|
||||
User.username,
|
||||
WeiboWorkbenchAccount.exclusive_slot,
|
||||
)
|
||||
.outerjoin(User, User.id == WeiboWorkbenchAccount.user_id)
|
||||
.filter(WeiboWorkbenchAccount.account_id.in_(account_ids))
|
||||
.all()
|
||||
):
|
||||
memberships.setdefault(account_id, []).append(
|
||||
(user_id, username or "", exclusive_slot)
|
||||
)
|
||||
current_member_ids = {
|
||||
account_id
|
||||
for account_id, rows in memberships.items()
|
||||
if any(user_id == current.id for user_id, _username, _slot in rows)
|
||||
}
|
||||
exclusive_owners = {
|
||||
account_id: next(
|
||||
(
|
||||
(user_id, username)
|
||||
for user_id, username, exclusive_slot in rows
|
||||
if exclusive_slot is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
for account_id, rows in memberships.items()
|
||||
}
|
||||
|
||||
items: list[WeiboWorkbenchBatchImportItem] = []
|
||||
to_add: list[WeiboAccount] = []
|
||||
to_add: list[tuple[WeiboAccount, int | None]] = []
|
||||
forced_accounts: list[WeiboAccount] = []
|
||||
planned_accounts: list[WeiboAccount] = []
|
||||
planned_ids: set[int] = set()
|
||||
for identifier in identifiers:
|
||||
candidates = by_uid.get(identifier) or by_nickname.get(identifier) or []
|
||||
@@ -410,13 +449,19 @@ def import_workbench_accounts_by_text(
|
||||
continue
|
||||
|
||||
account = candidates[0]
|
||||
exclusive_owner = exclusive_owners.get(account.id)
|
||||
base = {
|
||||
"identifier": identifier,
|
||||
"account_id": account.id,
|
||||
"uid": account.uid,
|
||||
"nickname": account.nickname,
|
||||
"occupied_by_user_id": (
|
||||
exclusive_owner[0] if exclusive_owner is not None else None
|
||||
),
|
||||
"occupied_by_username": (
|
||||
exclusive_owner[1] if exclusive_owner is not None else ""
|
||||
),
|
||||
}
|
||||
occupant = occupancy.get(account.id)
|
||||
if not account.is_enabled:
|
||||
items.append(
|
||||
WeiboWorkbenchBatchImportItem(
|
||||
@@ -425,22 +470,6 @@ def import_workbench_accounts_by_text(
|
||||
**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(
|
||||
@@ -449,9 +478,51 @@ def import_workbench_accounts_by_text(
|
||||
**base,
|
||||
)
|
||||
)
|
||||
elif account.id in current_member_ids:
|
||||
items.append(
|
||||
WeiboWorkbenchBatchImportItem(
|
||||
status="already_added",
|
||||
message="账号已在本工作台",
|
||||
**base,
|
||||
)
|
||||
)
|
||||
elif exclusive_owner is not None and exclusive_owner[0] != current.id:
|
||||
if req.force_add_occupied:
|
||||
planned_ids.add(account.id)
|
||||
to_add.append((account, None))
|
||||
forced_accounts.append(account)
|
||||
planned_accounts.append(account)
|
||||
items.append(
|
||||
WeiboWorkbenchBatchImportItem(
|
||||
status="forced_added",
|
||||
message=(
|
||||
f"已强制转入,{exclusive_owner[1] or '原使用人'}"
|
||||
"的工作台保持不变"
|
||||
),
|
||||
**base,
|
||||
)
|
||||
)
|
||||
continue
|
||||
items.append(
|
||||
WeiboWorkbenchBatchImportItem(
|
||||
status="unavailable",
|
||||
message=(
|
||||
f"账号已在 {exclusive_owner[1]} 的工作台中使用"
|
||||
if exclusive_owner[1]
|
||||
else "账号已在其他人的工作台中使用"
|
||||
),
|
||||
**base,
|
||||
)
|
||||
)
|
||||
else:
|
||||
planned_ids.add(account.id)
|
||||
to_add.append(account)
|
||||
to_add.append(
|
||||
(
|
||||
account,
|
||||
None if current.role == "super_admin" else 1,
|
||||
)
|
||||
)
|
||||
planned_accounts.append(account)
|
||||
items.append(
|
||||
WeiboWorkbenchBatchImportItem(
|
||||
status="added",
|
||||
@@ -460,11 +531,23 @@ def import_workbench_accounts_by_text(
|
||||
)
|
||||
)
|
||||
|
||||
forced_details = [
|
||||
{
|
||||
"account_id": account.id,
|
||||
"existing_owner_user_id": exclusive_owners[account.id][0],
|
||||
"existing_owner_username": exclusive_owners[account.id][1],
|
||||
}
|
||||
for account in forced_accounts
|
||||
]
|
||||
try:
|
||||
db.add_all(
|
||||
[
|
||||
WeiboWorkbenchAccount(user_id=current.id, account_id=account.id)
|
||||
for account in to_add
|
||||
WeiboWorkbenchAccount(
|
||||
user_id=current.id,
|
||||
account_id=account.id,
|
||||
exclusive_slot=exclusive_slot,
|
||||
)
|
||||
for account, exclusive_slot in to_add
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
@@ -474,24 +557,31 @@ def import_workbench_accounts_by_text(
|
||||
status_code=409,
|
||||
detail="部分账号刚被其他工作台占用,请刷新后重试",
|
||||
) from exc
|
||||
if to_add:
|
||||
if planned_accounts:
|
||||
_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]},
|
||||
detail={
|
||||
"account_ids": [account.id for account in planned_accounts],
|
||||
"added_account_ids": [account.id for account, _slot in to_add],
|
||||
"forced_added_account_ids": [account.id for account in forced_accounts],
|
||||
"existing_owners": forced_details,
|
||||
},
|
||||
)
|
||||
|
||||
added = sum(item.status == "added" for item in items)
|
||||
forced_added = sum(item.status == "forced_added" for item in items)
|
||||
already_added = sum(item.status == "already_added" for item in items)
|
||||
failed = len(items) - added - already_added
|
||||
failed = len(items) - added - forced_added - already_added
|
||||
return WeiboWorkbenchBatchImportOut(
|
||||
requested=len(identifiers),
|
||||
added=added,
|
||||
forced_added=forced_added,
|
||||
already_added=already_added,
|
||||
failed=failed,
|
||||
account_ids=[account.id for account in to_add],
|
||||
account_ids=[account.id for account in planned_accounts],
|
||||
items=items,
|
||||
)
|
||||
|
||||
@@ -567,11 +657,12 @@ def add_workbench_accounts(
|
||||
for (account_id,) in db.query(WeiboWorkbenchAccount.account_id)
|
||||
.filter(
|
||||
WeiboWorkbenchAccount.user_id != current.id,
|
||||
WeiboWorkbenchAccount.exclusive_slot.is_not(None),
|
||||
WeiboWorkbenchAccount.account_id.in_(account_ids),
|
||||
)
|
||||
.all()
|
||||
}
|
||||
if taken:
|
||||
if taken and current.role != "super_admin":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="部分账号已在其他人的工作台中使用,请先让原使用人移出",
|
||||
@@ -587,7 +678,11 @@ def add_workbench_accounts(
|
||||
}
|
||||
db.add_all(
|
||||
[
|
||||
WeiboWorkbenchAccount(user_id=current.id, account_id=account_id)
|
||||
WeiboWorkbenchAccount(
|
||||
user_id=current.id,
|
||||
account_id=account_id,
|
||||
exclusive_slot=(None if current.role == "super_admin" else 1),
|
||||
)
|
||||
for account_id in account_ids
|
||||
if account_id not in selected_ids
|
||||
]
|
||||
@@ -763,12 +858,14 @@ def batch_assign_accounts(
|
||||
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)
|
||||
WeiboWorkbenchAccount.account_id.in_(actual_ids),
|
||||
WeiboWorkbenchAccount.exclusive_slot.is_not(None),
|
||||
).delete(synchronize_session=False)
|
||||
else:
|
||||
db.query(WeiboWorkbenchAccount).filter(
|
||||
WeiboWorkbenchAccount.account_id.in_(actual_ids),
|
||||
WeiboWorkbenchAccount.user_id != target.id,
|
||||
WeiboWorkbenchAccount.exclusive_slot.is_not(None),
|
||||
).delete(synchronize_session=False)
|
||||
for account in accounts:
|
||||
account.assigned_to = target.id if target else None
|
||||
|
||||
@@ -542,6 +542,7 @@ class WeiboWorkbenchTransferToCollabOut(BaseModel):
|
||||
|
||||
class WeiboWorkbenchBatchImportRequest(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=100_000)
|
||||
force_add_occupied: bool = False
|
||||
|
||||
|
||||
class WeiboWorkbenchBatchImportItem(BaseModel):
|
||||
@@ -551,11 +552,14 @@ class WeiboWorkbenchBatchImportItem(BaseModel):
|
||||
account_id: int | None = None
|
||||
uid: str = ""
|
||||
nickname: str = ""
|
||||
occupied_by_user_id: int | None = None
|
||||
occupied_by_username: str = ""
|
||||
|
||||
|
||||
class WeiboWorkbenchBatchImportOut(BaseModel):
|
||||
requested: int
|
||||
added: int
|
||||
forced_added: int = 0
|
||||
already_added: int
|
||||
failed: int
|
||||
account_ids: list[int] = Field(default_factory=list)
|
||||
|
||||
@@ -135,10 +135,13 @@ def import_weibo_cookies(
|
||||
continue
|
||||
owner_id = (
|
||||
db.query(WeiboWorkbenchAccount.user_id)
|
||||
.filter(WeiboWorkbenchAccount.account_id == account.id)
|
||||
.scalar()
|
||||
.filter(
|
||||
WeiboWorkbenchAccount.account_id == account.id,
|
||||
WeiboWorkbenchAccount.exclusive_slot.is_not(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if owner_id is not None and owner_id != current_user_id:
|
||||
if owner_id is not None and owner_id[0] != current_user_id:
|
||||
errors.append(
|
||||
WeiboImportIssue(
|
||||
line=line_no,
|
||||
|
||||
@@ -826,6 +826,7 @@ export interface WeiboWorkbenchBatchImportItem {
|
||||
identifier: string;
|
||||
status:
|
||||
| 'added'
|
||||
| 'forced_added'
|
||||
| 'already_added'
|
||||
| 'not_found'
|
||||
| 'ambiguous'
|
||||
@@ -836,11 +837,14 @@ export interface WeiboWorkbenchBatchImportItem {
|
||||
account_id: number | null;
|
||||
uid: string;
|
||||
nickname: string;
|
||||
occupied_by_user_id: number | null;
|
||||
occupied_by_username: string;
|
||||
}
|
||||
|
||||
export interface WeiboWorkbenchBatchImportResult {
|
||||
requested: number;
|
||||
added: number;
|
||||
forced_added: number;
|
||||
already_added: number;
|
||||
failed: number;
|
||||
account_ids: number[];
|
||||
|
||||
@@ -124,10 +124,10 @@ export const weiboApi = {
|
||||
'/weibo/workbench/accounts',
|
||||
{ account_ids: accountIds },
|
||||
),
|
||||
batchImportWorkbenchAccounts: (text: string) =>
|
||||
batchImportWorkbenchAccounts: (text: string, forceAddOccupied = false) =>
|
||||
api.post<WeiboWorkbenchBatchImportResult, WeiboWorkbenchBatchImportResult>(
|
||||
'/weibo/workbench/accounts/batch-import',
|
||||
{ text },
|
||||
{ text, force_add_occupied: forceAddOccupied },
|
||||
),
|
||||
transferWorkbenchToCollab: (accountId: number) =>
|
||||
api.post<
|
||||
|
||||
@@ -83,6 +83,7 @@ const WORKBENCH_IMPORT_STATUS: Record<
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
added: { label: '已导入', color: 'success' },
|
||||
forced_added: { label: '强制转入', color: 'orange' },
|
||||
already_added: { label: '已存在', color: 'blue' },
|
||||
not_found: { label: '未找到', color: 'error' },
|
||||
ambiguous: { label: '重名', color: 'warning' },
|
||||
@@ -161,8 +162,9 @@ export default function WeiboAccountsPage({
|
||||
mode?: WeiboAccountsPageMode;
|
||||
}) {
|
||||
const { token } = theme.useToken();
|
||||
const { can } = usePermissions();
|
||||
const { can, user } = usePermissions();
|
||||
const navigate = useNavigate();
|
||||
const isSuperAdmin = user?.role === 'super_admin';
|
||||
const isWorkbench = mode === 'workbench';
|
||||
const canImport = can('weibo:import');
|
||||
const canManage = can('weibo:manage');
|
||||
@@ -206,6 +208,7 @@ export default function WeiboAccountsPage({
|
||||
'paste',
|
||||
);
|
||||
const [batchImportText, setBatchImportText] = useState('');
|
||||
const [forceAddOccupied, setForceAddOccupied] = useState(false);
|
||||
const [batchImportLoading, setBatchImportLoading] = useState(false);
|
||||
const [batchImportResult, setBatchImportResult] =
|
||||
useState<WeiboWorkbenchBatchImportResult | null>(null);
|
||||
@@ -400,6 +403,7 @@ export default function WeiboAccountsPage({
|
||||
setPoolSelectedIds([]);
|
||||
setWorkbenchImportTab('paste');
|
||||
setBatchImportText('');
|
||||
setForceAddOccupied(isSuperAdmin);
|
||||
setBatchImportResult(null);
|
||||
setPoolOpen(true);
|
||||
void loadWorkbenchPool(1, poolPageSize, '');
|
||||
@@ -414,17 +418,24 @@ export default function WeiboAccountsPage({
|
||||
setBatchImportLoading(true);
|
||||
setBatchImportResult(null);
|
||||
try {
|
||||
const result = await weiboApi.batchImportWorkbenchAccounts(text);
|
||||
const result = await weiboApi.batchImportWorkbenchAccounts(
|
||||
text,
|
||||
isSuperAdmin && forceAddOccupied,
|
||||
);
|
||||
setBatchImportResult(result);
|
||||
await loadAccounts();
|
||||
await loadKnownTags();
|
||||
const forceAddSummary =
|
||||
result.forced_added > 0
|
||||
? `,强制转入 ${result.forced_added} 个`
|
||||
: '';
|
||||
if (result.failed > 0) {
|
||||
message.warning(
|
||||
`批量导入完成:新增 ${result.added} 个,已存在 ${result.already_added} 个,失败 ${result.failed} 个`,
|
||||
`批量导入完成:新增 ${result.added} 个${forceAddSummary},已存在 ${result.already_added} 个,失败 ${result.failed} 个`,
|
||||
);
|
||||
} else {
|
||||
message.success(
|
||||
`批量导入完成:新增 ${result.added} 个,已存在 ${result.already_added} 个`,
|
||||
`批量导入完成:新增 ${result.added} 个${forceAddSummary},已存在 ${result.already_added} 个`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -2589,6 +2600,16 @@ export default function WeiboAccountsPage({
|
||||
title="支持多行、空格、逗号分隔"
|
||||
description="优先按 UID 精确匹配;也支持微博用户名和角色名。重名时请改用 UID。"
|
||||
/>
|
||||
{isSuperAdmin && (
|
||||
<Checkbox
|
||||
checked={forceAddOccupied}
|
||||
onChange={(event) =>
|
||||
setForceAddOccupied(event.target.checked)
|
||||
}
|
||||
>
|
||||
其他人的工作台占用时,强制转入当前工作台(不影响原使用人)
|
||||
</Checkbox>
|
||||
)}
|
||||
<TextArea
|
||||
rows={8}
|
||||
value={batchImportText}
|
||||
@@ -2606,7 +2627,7 @@ export default function WeiboAccountsPage({
|
||||
<Alert
|
||||
type={batchImportResult.failed > 0 ? 'warning' : 'success'}
|
||||
showIcon
|
||||
title={`解析 ${batchImportResult.requested} 项:新增 ${batchImportResult.added},已存在 ${batchImportResult.already_added},失败 ${batchImportResult.failed}`}
|
||||
title={`解析 ${batchImportResult.requested} 项:新增 ${batchImportResult.added},强制转入 ${batchImportResult.forced_added},已存在 ${batchImportResult.already_added},失败 ${batchImportResult.failed}`}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
|
||||
Reference in New Issue
Block a user