优化客服账号标签管理

This commit is contained in:
yml2213
2026-08-14 12:44:48 +08:00
parent 7fe6228901
commit baed0e61ff
6 changed files with 111 additions and 81 deletions
+27 -1
View File
@@ -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()
+6
View File
@@ -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",
], ],
} }
+20 -6
View File
@@ -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="请选择账号")
+12 -6
View File
@@ -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,
) )
+21 -32
View File
@@ -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 />}
+21 -32
View File
@@ -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);