优化客服账号标签管理
This commit is contained in:
@@ -6,10 +6,12 @@ os.environ.setdefault("APP_ENCRYPTION_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODl
|
|||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from web.backend.database import Base
|
from web.backend.database import Base
|
||||||
from web.backend.models import Account, User
|
from web.backend.models import Account, User
|
||||||
from web.backend.routers.accounts import list_accounts
|
from web.backend.routers.accounts import list_accounts, set_account_tag
|
||||||
|
from web.backend.schemas import AccountTag
|
||||||
|
|
||||||
|
|
||||||
class AccountSensitiveFieldsTests(unittest.TestCase):
|
class AccountSensitiveFieldsTests(unittest.TestCase):
|
||||||
@@ -68,6 +70,30 @@ class AccountSensitiveFieldsTests(unittest.TestCase):
|
|||||||
self.assertEqual(item.email, "account@example.com")
|
self.assertEqual(item.email, "account@example.com")
|
||||||
self.assertEqual(item.email_password, "email-password")
|
self.assertEqual(item.email_password, "email-password")
|
||||||
|
|
||||||
|
def test_support_can_only_change_tags_on_assigned_accounts(self):
|
||||||
|
support = User(username="support", password_hash="hash", role="support")
|
||||||
|
other_support = User(username="other-support", password_hash="hash", role="support")
|
||||||
|
self.session.add_all([support, other_support])
|
||||||
|
self.session.commit()
|
||||||
|
assigned = Account(
|
||||||
|
username="assigned", password="password", email="assigned@example.com",
|
||||||
|
email_password="mail-password", assigned_to=support.id,
|
||||||
|
)
|
||||||
|
other = Account(
|
||||||
|
username="other", password="password", email="other@example.com",
|
||||||
|
email_password="mail-password", assigned_to=other_support.id,
|
||||||
|
)
|
||||||
|
self.session.add_all([assigned, other])
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
set_account_tag(assigned.id, AccountTag(tag="客服组"), db=self.session, current=support)
|
||||||
|
self.session.refresh(assigned)
|
||||||
|
self.assertEqual(assigned.tag, "客服组")
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as context:
|
||||||
|
set_account_tag(other.id, AccountTag(tag="越权"), db=self.session, current=support)
|
||||||
|
self.assertEqual(context.exception.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ PERMISSIONS = {
|
|||||||
"account:view_full": "查看账号完整字段(密码/邮箱,仅管理员)",
|
"account:view_full": "查看账号完整字段(密码/邮箱,仅管理员)",
|
||||||
"account:view_assigned": "查看分配给自己的账号",
|
"account:view_assigned": "查看分配给自己的账号",
|
||||||
"account:import": "导入账号",
|
"account:import": "导入账号",
|
||||||
|
"account:tag": "修改账号标签",
|
||||||
"account:check": "账号检测",
|
"account:check": "账号检测",
|
||||||
"account:assign": "分配账号给客服",
|
"account:assign": "分配账号给客服",
|
||||||
"account:delete": "删除账号",
|
"account:delete": "删除账号",
|
||||||
@@ -37,6 +38,7 @@ PERMISSIONS = {
|
|||||||
"huya:view_all": "查看所有虎牙账号",
|
"huya:view_all": "查看所有虎牙账号",
|
||||||
"huya:view_assigned": "查看分配给自己的虎牙账号",
|
"huya:view_assigned": "查看分配给自己的虎牙账号",
|
||||||
"huya:import": "导入/登录虎牙账号",
|
"huya:import": "导入/登录虎牙账号",
|
||||||
|
"huya:tag": "修改虎牙账号标签",
|
||||||
"huya:assign": "分配虎牙账号",
|
"huya:assign": "分配虎牙账号",
|
||||||
"huya:delete": "删除虎牙账号",
|
"huya:delete": "删除虎牙账号",
|
||||||
"huya:cookie:view": "查看虎牙 Cookie",
|
"huya:cookie:view": "查看虎牙 Cookie",
|
||||||
@@ -60,6 +62,7 @@ ROLE_PERMISSIONS = {
|
|||||||
"operation": [
|
"operation": [
|
||||||
"account:view_all",
|
"account:view_all",
|
||||||
"account:import",
|
"account:import",
|
||||||
|
"account:tag",
|
||||||
"account:check",
|
"account:check",
|
||||||
"account:assign",
|
"account:assign",
|
||||||
"login:batch",
|
"login:batch",
|
||||||
@@ -75,6 +78,7 @@ ROLE_PERMISSIONS = {
|
|||||||
"huya:account",
|
"huya:account",
|
||||||
"huya:view_all",
|
"huya:view_all",
|
||||||
"huya:import",
|
"huya:import",
|
||||||
|
"huya:tag",
|
||||||
"huya:assign",
|
"huya:assign",
|
||||||
"huya:delete",
|
"huya:delete",
|
||||||
"huya:cookie:view",
|
"huya:cookie:view",
|
||||||
@@ -89,11 +93,13 @@ ROLE_PERMISSIONS = {
|
|||||||
],
|
],
|
||||||
"support": [
|
"support": [
|
||||||
"account:view_assigned",
|
"account:view_assigned",
|
||||||
|
"account:tag",
|
||||||
"cookie:operate",
|
"cookie:operate",
|
||||||
"douyu:task",
|
"douyu:task",
|
||||||
"yyb:session",
|
"yyb:session",
|
||||||
"yyb:history",
|
"yyb:history",
|
||||||
"huya:view_assigned",
|
"huya:view_assigned",
|
||||||
|
"huya:tag",
|
||||||
"huya:task",
|
"huya:task",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,15 @@ def _selected_account_ids(db: Session, current: User, req: AccountBulkSelection)
|
|||||||
return [account_id for account_id in ids if account_id in allowed]
|
return [account_id for account_id in ids if account_id in allowed]
|
||||||
|
|
||||||
|
|
||||||
|
def _require_account_tag_permission(current: User) -> None:
|
||||||
|
"""标签权限兼容历史上使用账号导入权限的运营人员。"""
|
||||||
|
if not (
|
||||||
|
user_has_permission(current, "account:tag")
|
||||||
|
or user_has_permission(current, "account:import")
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="无权修改账号标签")
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_accounts(
|
def list_accounts(
|
||||||
assigned_only: bool = Query(False),
|
assigned_only: bool = Query(False),
|
||||||
@@ -323,13 +332,16 @@ def set_account_tag(
|
|||||||
account_id: int,
|
account_id: int,
|
||||||
req: AccountTag,
|
req: AccountTag,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current: User = Depends(require_permission("account:import")),
|
current: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""设置单个账号标签。"""
|
"""设置单个可见账号的标签。"""
|
||||||
acc = db.query(Account).filter(Account.id == account_id).first()
|
_require_account_tag_permission(current)
|
||||||
|
acc = _visible_accounts_query(db, current).filter(Account.id == account_id).first()
|
||||||
if not acc:
|
if not acc:
|
||||||
raise HTTPException(status_code=404, detail="账号不存在")
|
raise HTTPException(status_code=404, detail="账号不存在")
|
||||||
acc.tag = (req.tag or "").strip()
|
acc.tag = (req.tag or "").strip()
|
||||||
|
db.add(AuditLog(user_id=current.id, username=current.username,
|
||||||
|
action="account:tag", target=acc.username, detail=acc.tag))
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "标签已更新", "success": True}
|
return {"message": "标签已更新", "success": True}
|
||||||
|
|
||||||
@@ -338,13 +350,14 @@ def set_account_tag(
|
|||||||
def batch_tag(
|
def batch_tag(
|
||||||
req: AccountTag,
|
req: AccountTag,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current: User = Depends(require_permission("account:import")),
|
current: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""批量设置账号标签。"""
|
"""批量设置账号标签。"""
|
||||||
|
_require_account_tag_permission(current)
|
||||||
if not req.account_ids:
|
if not req.account_ids:
|
||||||
raise HTTPException(status_code=400, detail="请选择账号")
|
raise HTTPException(status_code=400, detail="请选择账号")
|
||||||
tag = (req.tag or "").strip()
|
tag = (req.tag or "").strip()
|
||||||
count = db.query(Account).filter(Account.id.in_(req.account_ids)).update(
|
count = _visible_accounts_query(db, current).filter(Account.id.in_(req.account_ids)).update(
|
||||||
{Account.tag: tag}, synchronize_session=False
|
{Account.tag: tag}, synchronize_session=False
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -355,9 +368,10 @@ def batch_tag(
|
|||||||
def batch_tag_selection(
|
def batch_tag_selection(
|
||||||
req: AccountBulkTag,
|
req: AccountBulkTag,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current: User = Depends(require_permission("account:import")),
|
current: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""按显式选择或当前筛选结果批量设置账号标签。"""
|
"""按显式选择或当前筛选结果批量设置账号标签。"""
|
||||||
|
_require_account_tag_permission(current)
|
||||||
ids = _selected_account_ids(db, current, req)
|
ids = _selected_account_ids(db, current, req)
|
||||||
if not ids:
|
if not ids:
|
||||||
raise HTTPException(status_code=400, detail="请选择账号")
|
raise HTTPException(status_code=400, detail="请选择账号")
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ def _require_huya_perm(user: User, permission: str) -> None:
|
|||||||
raise HTTPException(status_code=403, detail="权限不足")
|
raise HTTPException(status_code=403, detail="权限不足")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_huya_tag_permission(user: User) -> None:
|
||||||
|
"""标签权限兼容历史上使用导入权限的运营人员。"""
|
||||||
|
if not (_has_huya_perm(user, "huya:tag") or _has_huya_perm(user, "huya:import")):
|
||||||
|
raise HTTPException(status_code=403, detail="无权修改虎牙账号标签")
|
||||||
|
|
||||||
|
|
||||||
def _can_view_huya_all(user: User) -> bool:
|
def _can_view_huya_all(user: User) -> bool:
|
||||||
return _has_huya_perm(user, "huya:view_all")
|
return _has_huya_perm(user, "huya:view_all")
|
||||||
|
|
||||||
@@ -1064,8 +1070,8 @@ def set_account_tag(
|
|||||||
current: User = Depends(get_current_user),
|
current: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""设置单个虎牙账号标签。"""
|
"""设置单个虎牙账号标签。"""
|
||||||
_require_huya_perm(current, "huya:import")
|
_require_huya_tag_permission(current)
|
||||||
account = db.query(HuyaAccount).filter(HuyaAccount.id == account_id).first()
|
account = _visible_huya_accounts_query(db, current).filter(HuyaAccount.id == account_id).first()
|
||||||
if not account:
|
if not account:
|
||||||
raise HTTPException(status_code=404, detail="账号不存在")
|
raise HTTPException(status_code=404, detail="账号不存在")
|
||||||
account.tag = (req.tag or "").strip()
|
account.tag = (req.tag or "").strip()
|
||||||
@@ -1080,11 +1086,11 @@ def batch_tag(
|
|||||||
current: User = Depends(get_current_user),
|
current: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""批量设置虎牙账号标签。"""
|
"""批量设置虎牙账号标签。"""
|
||||||
_require_huya_perm(current, "huya:import")
|
_require_huya_tag_permission(current)
|
||||||
if not req.account_ids:
|
if not req.account_ids:
|
||||||
raise HTTPException(status_code=400, detail="请选择账号")
|
raise HTTPException(status_code=400, detail="请选择账号")
|
||||||
tag = (req.tag or "").strip()
|
tag = (req.tag or "").strip()
|
||||||
count = db.query(HuyaAccount).filter(HuyaAccount.id.in_(req.account_ids)).update(
|
count = _visible_huya_accounts_query(db, current).filter(HuyaAccount.id.in_(req.account_ids)).update(
|
||||||
{HuyaAccount.tag: tag},
|
{HuyaAccount.tag: tag},
|
||||||
synchronize_session=False,
|
synchronize_session=False,
|
||||||
)
|
)
|
||||||
@@ -1099,12 +1105,12 @@ def batch_tag_selection(
|
|||||||
current: User = Depends(get_current_user),
|
current: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""按显式选择或当前筛选结果批量设置虎牙账号标签。"""
|
"""按显式选择或当前筛选结果批量设置虎牙账号标签。"""
|
||||||
_require_huya_perm(current, "huya:import")
|
_require_huya_tag_permission(current)
|
||||||
ids = _selected_huya_account_ids(db, current, req)
|
ids = _selected_huya_account_ids(db, current, req)
|
||||||
if not ids:
|
if not ids:
|
||||||
raise HTTPException(status_code=400, detail="请选择虎牙账号")
|
raise HTTPException(status_code=400, detail="请选择虎牙账号")
|
||||||
tag = (req.tag_value or "").strip()
|
tag = (req.tag_value or "").strip()
|
||||||
count = db.query(HuyaAccount).filter(HuyaAccount.id.in_(ids)).update(
|
count = _visible_huya_accounts_query(db, current).filter(HuyaAccount.id.in_(ids)).update(
|
||||||
{HuyaAccount.tag: tag},
|
{HuyaAccount.tag: tag},
|
||||||
synchronize_session=False,
|
synchronize_session=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,9 +45,10 @@ export default function AccountsPage() {
|
|||||||
|
|
||||||
const canViewFull = can('account:view_full');
|
const canViewFull = can('account:view_full');
|
||||||
const canImport = can('account:import');
|
const canImport = can('account:import');
|
||||||
|
const canTag = can('account:tag') || canImport;
|
||||||
const canAssign = can('account:assign');
|
const canAssign = can('account:assign');
|
||||||
const canDelete = can('account:delete');
|
const canDelete = can('account:delete');
|
||||||
const canSelectRows = canImport || canDelete || canAssign;
|
const canSelectRows = canTag || canDelete || canAssign;
|
||||||
|
|
||||||
const loadAccounts = useCallback(async () => {
|
const loadAccounts = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -252,43 +253,29 @@ export default function AccountsPage() {
|
|||||||
{
|
{
|
||||||
title: '标签',
|
title: '标签',
|
||||||
dataIndex: 'tag',
|
dataIndex: 'tag',
|
||||||
width: 120,
|
width: 170,
|
||||||
render: (tag: string, record: AccountItem) => {
|
render: (tag: string, record: AccountItem) => {
|
||||||
if (!tag) {
|
if (canTag) {
|
||||||
if (canImport) {
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
maxCount={1}
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
size="small"
|
size="small"
|
||||||
placeholder="输入标签"
|
placeholder="选择或输入标签"
|
||||||
style={{ width: 90 }}
|
style={{ width: 150 }}
|
||||||
onPressEnter={(e) => {
|
value={tag ? [tag] : []}
|
||||||
const val = (e.target as HTMLInputElement).value.trim();
|
onChange={(values) => {
|
||||||
if (val) handleSetTag(record.id, val);
|
const value = values.at(-1)?.trim() || '';
|
||||||
}}
|
if (value !== tag) handleSetTag(record.id, value);
|
||||||
onBlur={(e) => {
|
|
||||||
const val = e.target.value.trim();
|
|
||||||
if (val) handleSetTag(record.id, val);
|
|
||||||
}}
|
}}
|
||||||
|
options={tags.map((value) => ({ value, label: value }))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <Text type="secondary">-</Text>;
|
return tag ? <Tag color={tagColorMap[tag]}>{tag}</Tag> : <Text type="secondary">-</Text>;
|
||||||
}
|
|
||||||
if (canImport) {
|
|
||||||
return (
|
|
||||||
<Tag
|
|
||||||
color={tagColorMap[tag]}
|
|
||||||
closable
|
|
||||||
onClose={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSetTag(record.id, '');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</Tag>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <Tag color={tagColorMap[tag]}>{tag}</Tag>;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -346,6 +333,8 @@ export default function AccountsPage() {
|
|||||||
<Space>
|
<Space>
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
placeholder="按标签筛选"
|
placeholder="按标签筛选"
|
||||||
style={{ width: 150 }}
|
style={{ width: 150 }}
|
||||||
value={tagFilter || undefined}
|
value={tagFilter || undefined}
|
||||||
@@ -375,7 +364,7 @@ export default function AccountsPage() {
|
|||||||
{sensitiveVisible ? '隐藏凭据' : '显示凭据'}
|
{sensitiveVisible ? '隐藏凭据' : '显示凭据'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canImport && (
|
{canTag && (
|
||||||
<Button
|
<Button
|
||||||
disabled={selectedCount === 0}
|
disabled={selectedCount === 0}
|
||||||
icon={<TagOutlined />}
|
icon={<TagOutlined />}
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export default function HuyaAccountsPage() {
|
|||||||
|
|
||||||
const canManage = can('huya:account');
|
const canManage = can('huya:account');
|
||||||
const canImport = can('huya:import') || canManage;
|
const canImport = can('huya:import') || canManage;
|
||||||
|
const canTag = can('huya:tag') || canImport;
|
||||||
const canAssign = can('huya:assign') || canManage;
|
const canAssign = can('huya:assign') || canManage;
|
||||||
const canDelete = can('huya:delete') || canManage;
|
const canDelete = can('huya:delete') || canManage;
|
||||||
const canViewCookie = can('huya:cookie:view') || can('huya:cookie:export') || canManage;
|
const canViewCookie = can('huya:cookie:view') || can('huya:cookie:export') || canManage;
|
||||||
@@ -426,43 +427,29 @@ export default function HuyaAccountsPage() {
|
|||||||
{
|
{
|
||||||
title: '标签',
|
title: '标签',
|
||||||
dataIndex: 'tag',
|
dataIndex: 'tag',
|
||||||
width: 110,
|
width: 170,
|
||||||
render: (tag: string, record) => {
|
render: (tag: string, record) => {
|
||||||
if (!tag) {
|
if (canTag) {
|
||||||
if (canImport) {
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
maxCount={1}
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
size="small"
|
size="small"
|
||||||
placeholder="输入标签"
|
placeholder="选择或输入标签"
|
||||||
style={{ width: 90 }}
|
style={{ width: 150 }}
|
||||||
onPressEnter={(e) => {
|
value={tag ? [tag] : []}
|
||||||
const value = (e.target as HTMLInputElement).value.trim();
|
onChange={(values) => {
|
||||||
if (value) handleSetTag(record.id, value);
|
const value = values.at(-1)?.trim() || '';
|
||||||
}}
|
if (value !== tag) handleSetTag(record.id, value);
|
||||||
onBlur={(e) => {
|
|
||||||
const value = e.target.value.trim();
|
|
||||||
if (value) handleSetTag(record.id, value);
|
|
||||||
}}
|
}}
|
||||||
|
options={tags.map((value) => ({ value, label: value }))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <Text type="secondary">-</Text>;
|
return tag ? <Tag color={tagColorMap[tag]}>{tag}</Tag> : <Text type="secondary">-</Text>;
|
||||||
}
|
|
||||||
if (canImport) {
|
|
||||||
return (
|
|
||||||
<Tag
|
|
||||||
color={tagColorMap[tag]}
|
|
||||||
closable
|
|
||||||
onClose={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSetTag(record.id, '');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</Tag>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <Tag color={tagColorMap[tag]}>{tag}</Tag>;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -560,6 +547,8 @@ export default function HuyaAccountsPage() {
|
|||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
placeholder="按标签筛选"
|
placeholder="按标签筛选"
|
||||||
style={{ width: 150 }}
|
style={{ width: 150 }}
|
||||||
value={tagFilter || undefined}
|
value={tagFilter || undefined}
|
||||||
@@ -573,7 +562,7 @@ export default function HuyaAccountsPage() {
|
|||||||
<Button icon={<ReloadOutlined />} onClick={loadAccounts} loading={loading}>
|
<Button icon={<ReloadOutlined />} onClick={loadAccounts} loading={loading}>
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
{canImport && (
|
{canTag && (
|
||||||
<Button
|
<Button
|
||||||
disabled={selectedCount === 0}
|
disabled={selectedCount === 0}
|
||||||
icon={<TagOutlined />}
|
icon={<TagOutlined />}
|
||||||
@@ -699,7 +688,7 @@ export default function HuyaAccountsPage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
rowSelection={(canImport || canDelete || canAssign) ? {
|
rowSelection={(canTag || canDelete || canAssign) ? {
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
onChange: (keys) => {
|
onChange: (keys) => {
|
||||||
setSelectedRowKeys(keys);
|
setSelectedRowKeys(keys);
|
||||||
|
|||||||
Reference in New Issue
Block a user