40 changed files with 3841 additions and 295 deletions
+3
View File
@@ -51,3 +51,6 @@ work/
analysis/
artifacts/
hars/
+2 -2
View File
@@ -7,7 +7,7 @@
- **斗鱼**:极验滑块自动过验、邮箱验证码、批量登录与 CK 检测/重登;精英宝典 / 电竞手册 / 和平小店三套工作台(绑定、开通、充值、兑换);鱼翅可由微信二维码或供应商直充。
- **虎牙**:Cookie / 密码 / App / 短信登录、自动注册、设备绑定、精英宝典、浏览器人工兑换;网页运行 Cookie 与 App 原始凭据(`raw_cookie`)分离,可从原始凭据重建网页 Cookie。
- **应用宝**:和平精英点券扫码登录、选区服角色、支付二维码。Worker 合在 Web 容器内,只监听 `127.0.0.1:8810`。
- **权限**:超级管理员 / 运营 / 客服;可按权限点覆盖。客服只看分配给自己的账号,虎牙侧默认只开放浏览器兑换。
- **权限**:超级管理员 / 运营 / 客服;可按权限点覆盖。客服只看分配给自己的账号,虎牙侧默认开放精英宝典(含外部 CK 导入)与浏览器兑换。
- **安全**:密码、邮箱、Cookie、代理密钥等敏感字段落盘加密;操作写审计日志。
## 项目结构
@@ -120,7 +120,7 @@ python3 -c 'import secrets; print(secrets.token_urlsafe(32))'
|---|---|
| 超级管理员 | 全部功能 + 用户管理 + 权限分配 + 审计日志 |
| 运营 | 账号管理、批量登录、代理、Cookie、双平台任务、应用宝 |
| 客服 | 仅查看分配给自己的账号;虎牙只保留浏览器兑换 |
| 客服 | 仅查看分配给自己的账号;虎牙保留精英宝典(含外部 CK 导入)与浏览器兑换 |
## 登录与任务流
+2 -1
View File
@@ -1,6 +1,6 @@
# 项目文档索引
更新时间:2026-11-06
更新时间:2026-11-08
本文只列当前仍有维护价值、能直接用于开发或排查的文档。已被代码实现取代的
一次性计划、事故经过和中间抓包记录已删除;需要追查历史时使用 Git 历史。
@@ -9,6 +9,7 @@
- [自动化与资料收集](自动化与资料收集.md):账号巡检、CK 自动重登、资料刷新、订单读取、文件存储的运行规则。
- [账号全生命周期](设计-账号全生命周期.md):斗鱼账号状态、CK、事件流和自动化边界。
- [微博账号池、分配与个人工作台](微博/微博账号池与个人工作台-2026-11-08.md):微博 CK、账号归属、客服可见范围、分配管理和个人工作台。
- [虎牙外部 CK 协作台账](设计-虎牙外部CK协作台账.md):外部订单、CK、业务状态、核账、归档和权限规则。
- [虎牙 Cookie 路线状态](虎牙/HUYA_COOKIE路线状态.md):当前 Cookie 字段来源、已完成链路和已知边界。
- [虎牙浏览器兑换](虎牙/HUYA_BROWSER_EXCHANGE.md):人工兑换页面、扩展注入和目标页说明。
@@ -0,0 +1,138 @@
---
type: 设计
description: 微博外部 CK 账号池、归属分配与客服个人工作台设计,覆盖数据范围、权限、接口和 UI。
tags: [微博, CK, 账号池, 工作台, 权限, 协作]
generated:
by: ai/codex
at: 2026-11-08T20:00:00+08:00
status: implemented
---
# 微博账号池与个人工作台
## 1. 目标
把原来的「账号与绑定」拆成两个入口:
- **账号管理**:全局微博账号池,负责 CK 导入、生命周期、标签、批量操作和资料维护。
- **分配管理**:管理员和运营把全局账号分配给客服,支持批量分配、取消分配和按客服查看。
- **微博工作台**:每个用户自己的账号子集,只展示自己导入的账号,用于绑定、充值、兑换和日常操作。
工作台采用斗鱼模式:**账号实体仍然全局唯一,工作台只保存用户与账号的映射**。同一个全局账号同一时间只能被一个用户加入工作台;当前使用人移出后,其他人才可以加入。
## 2. 数据模型
`weibo_accounts` 继续作为全局账号池,不允许因为工作台拆出第二份 CK。
账号池新增归属字段:
```text
weibo_accounts.assigned_to -> users.id
```
- `assigned_to` 为空表示未分配。
- 管理员和运营可查看全部账号并修改归属。
- 客服的账号管理、标签、工作台候选、绑定、充值、兑换、复制 CK 等操作全部只允许访问 `assigned_to = 当前用户` 的账号。
- 同一账号改分配给新客服时,旧客服工作台中的映射会自动清除,避免账号被旧工作台继续占用。
- 取消分配时清除该账号的全部工作台映射。
新增:
```text
weibo_workbench_accounts
id
user_id -> users.id
account_id -> weibo_accounts.id
created_at
UNIQUE(user_id, account_id)
UNIQUE(account_id) -- 同一账号同时只能属于一个工作台
```
删除账号或用户时,对应工作台映射级联删除,不保留孤儿记录。
## 3. 权限
- `weibo:view`:查看全局账号管理。
- `weibo:assign`:查看全部账号、进入分配管理、批量分配/取消分配客服归属。
- `weibo:workbench`:访问个人微博工作台。
- `weibo:import`:导入外部 CK。
- `weibo:manage`:编辑账号标签/备注、启停账号、批量标签。
- `weibo:account:ck_export`:复制账号 CK。
- `weibo:bind`:生成绑定二维码。
- `weibo:recharge`:创建充值订单。
- `weibo:exchange`:执行兑换。
运营默认拥有全量查看和分配权限;客服默认没有 `weibo:assign`,只能查看和使用分配给自己的账号。删除账号仍由 `weibo:delete` 控制,客服默认没有。
## 4. 接口
| 方法 | 路径 | 权限 | 说明 |
| --- | --- | --- | --- |
| GET | `/api/weibo/accounts` | `weibo:view` | 按当前用户范围返回账号分页列表 |
| GET | `/api/weibo/accounts/ids` | `weibo:view` | 返回当前筛选下全部可见账号 ID,用于跨页分配 |
| GET | `/api/weibo/workbench/accounts` | `weibo:workbench` | 当前用户工作台账号分页列表 |
| GET | `/api/weibo/workbench/pool` | `weibo:workbench` | 可加入当前工作台且尚未加入的账号 |
| POST | `/api/weibo/workbench/accounts` | `weibo:workbench` | 批量加入工作台;已被其他用户占用时返回 409 |
| POST | `/api/weibo/workbench/accounts/remove` | `weibo:workbench` | 批量移出工作台,不影响账号池和其他用户 |
| POST | `/api/weibo/accounts/batch-tag` | `weibo:manage` | 账号管理页批量设置标签 |
| POST | `/api/weibo/accounts/batch-assign` | `weibo:assign` | 批量分配给客服;`assigned_to=null` 表示取消分配 |
| GET | `/api/weibo/assignments/summary` | `weibo:assign` | 分配统计和客服名下账号数 |
工作台接口始终按 `current.id` 过滤,不接受客户端传入用户 ID。客服访问账号相关接口时,服务端还会强制叠加 `assigned_to = current.id`,不能依赖前端隐藏入口。
## 5. UI
菜单:
```text
微博
-> 账号管理
-> 分配管理(仅 weibo:assign)
-> 微博工作台
-> 外部单协作
```
账号管理页:
- 标题「微博账号管理」。
- 管理员和运营看到全量账号、归属客服列、生命周期、批量刷新、批量标签、启停和删除。
- 客服只看到分配给自己的账号,并保留绑定、充值、兑换、标签、CK 操作等日常能力。
- 用于维护账号池和客服归属,不作为客服个人工作台的选择状态。
分配管理页:
- 参考斗鱼分配管理:顶部统计、左侧客服列表、右侧未分配/已分配账号页签。
- 支持按搜索、标签、生命周期筛选。
- 支持跨页勾选后批量分配或取消分配。
- 支持把当前筛选下的全部未分配账号一次性分配给选中客服。
- 客服列表展示每个客服当前名下账号数。
工作台页:
- 标题「微博工作台」。
- 复用账号能力,但列表只显示当前用户工作台映射。
- 「导入账号」从账号池选择账号加入。
- 「导入外部 CK」会先写入全局账号池,再自动加入当前工作台。
- 从账号池只显示当前未被任何用户占用的账号。
- 支持单个/批量移出工作台,不删除账号池数据。
- 绑定二维码、充值、兑换、功能查询继续复用现有账号接口。
## 6. 数据隔离边界
- 工作台列表和增删接口都按当前登录用户过滤。
- 客服的账号池候选只包含分配给自己的账号,并排除所有已被其他用户工作台占用的账号。
- 加入/移出只操作 `weibo_workbench_accounts`,不修改账号归属或 CK。
- 账号池全量查看由 `weibo:assign` 控制;客服可以看到账号管理入口,但列表和所有详情/操作接口只返回分配给自己的账号。
- 外部单协作继续引用全局账号 ID,不受个人工作台选择影响。
## 7. CK 重复导入
同一微博 UID 在全局池中只允许存在一条账号记录。
- 默认重复导入:不覆盖现有 CK,返回 `skipped=1`。
- 勾选「重复账号时更新 CK」:覆盖现有账号 CK 和最新资料,但**不新建账号**。
- 客服不能更新已分配给其他客服的账号 CK,也不能通过重复导入改变账号归属。
- 客服导入新账号时,账号会自动分配给当前客服。
- 若账号已被其他人的工作台占用,其他用户不能更新 CK,必须由当前使用人操作。
- 无论是否更新,全局账号记录始终只有一条。
+1 -1
View File
@@ -4,7 +4,7 @@
## 入口与账号范围
独立页面:虎牙 → 浏览器兑换。导航按角色区分:客服只看到浏览器兑换一个入口;管理员/运营仍显示完整的虎牙菜单(账号管理、精英宝典等)。
独立页面:虎牙 → 浏览器兑换。导航按角色区分:客服默认看到精英宝典与浏览器兑换;管理员/运营仍显示完整的虎牙菜单(账号管理、精英宝典等)。
- 页面维护一份「兑换账号列表」,通过「导入账号」从自己可见的账号池勾选加入;客服只能看到分配给自己的账号。
- 列表按用户隔离保存(工作台 scope `browser_exchange`),与精英宝典的账号集合互不影响。
+182 -1
View File
@@ -18,6 +18,7 @@ from core.huya.shop_structs import ShopRawRsp
from web.backend.models import HuyaAccount
from web.backend.services.huya_pay_link import PayTargets, pay_qr_path
from web.backend.services.huya_runner import HuyaBatchRunner
from web.backend.services.huya_runner_bind import BindMixin
UID = 1199668595203
COOKIE = f"yyuid={UID}; udb_uid={UID}"
@@ -155,6 +156,7 @@ class _FakeBindResponse:
self.status = status
self.msg = msg
self.gameName = "和平精英"
self.gameAuthScene = ""
self.changeBindDay = 7
self.accountData = _FakeBindAccountData(nick, role_name)
@@ -787,8 +789,10 @@ def test_query_game_name_uses_new_probe_params_and_saves_role(monkeypatch):
"uid": UID,
"cookie": COOKIE,
"b_act_id": 9271,
"is_use_outer_act_id": 1,
"gid": 0,
"outer_act_id": "17096",
"is_use_outer_act_id": 1,
"scene": "",
"timeout": 15.0,
}
]
@@ -862,3 +866,180 @@ def test_shop_client_falls_back_to_http_when_wss_init_fails(monkeypatch):
assert logs and logs[-1][0] == "warning"
assert "WSS 会话不可用" in logs[-1][1]
assert "sdid" not in json.dumps(logs[-1][1]) # 日志不带 Cookie 值
class _FakeConfirmResponse:
def __init__(self, status: int = 200, msg: str = ""):
self.status = status
self.msg = msg
def to_dict(self) -> dict:
return {"status": self.status, "msg": self.msg}
class _BindConfirmClient:
def __init__(self):
self.check_calls: list[dict] = []
self.confirm_calls: list[dict] = []
def check_user_bind_game_account(self, **kwargs):
self.check_calls.append(kwargs)
return _FakeBindResponse()
def confirm_bind_act_account(self, **kwargs):
self.confirm_calls.append(kwargs)
return _FakeConfirmResponse()
class _FakeLiveLink:
def __init__(self):
self.status = 200
self.msg = ""
self.livelinkParam = {
"actId": 17096,
"code": "code",
"gameIdList": "cjm",
"livePlatId": "huya",
"sig": "sig",
"t": "t",
}
def to_log_dict(self) -> dict:
return {"status": self.status}
class _BindQrClient:
def __init__(self):
self.check_calls: list[dict] = []
self.live_link_calls: list[dict] = []
self.qrcode_calls: list[str] = []
def check_user_bind_game_account(self, **kwargs):
self.check_calls.append(kwargs)
return _FakeBindResponse()
def get_live_link_param(self, **kwargs):
self.live_link_calls.append(kwargs)
return _FakeLiveLink()
def get_user_profile_batch(self, **kwargs):
return None
def build_bind_urls(self, _param, _b_act_id, **kwargs):
return {"qr_url": "https://livelink.example/qr"}
def get_livelink_mini_qrcode(self, url):
self.qrcode_calls.append(url)
return {
"mini_qrcode_image": "base64-image",
"qrcode_token": "token",
}
def _task_runner(monkeypatch, task_type: str):
runner = HuyaBatchRunner(db=None, batch_id="test-batch", task_type=task_type)
marks: list[tuple[str, str, dict | None]] = []
monkeypatch.setattr(
runner,
"_mark_task",
lambda _db, _task, status, message, result=None: marks.append(
(status, message, result)
),
raising=False,
)
monkeypatch.setattr(
runner,
"_update_task_progress",
lambda *_args, **_kwargs: None,
raising=False,
)
return runner, marks
def test_bind_protocol_prefers_configured_gid_and_keeps_legacy_fallback():
"""精英宝典绑定可配置 gid;旧配置没填 gid 时保持 9271 协议。"""
assert BindMixin._bind_protocol({"bind_gid": "3203", "outer_act_id": "17096"}) == {
"bind_gid": "3203",
"b_act_id": 0,
"gid": 3203,
"outer_act_id": "17096",
"is_use_outer_act_id": 0,
"scene": "undefined",
}
assert BindMixin._bind_protocol({"bind_act_id": "9271"}) == {
"bind_gid": "",
"b_act_id": 9271,
"gid": 0,
"outer_act_id": "",
"is_use_outer_act_id": 1,
"scene": "",
}
def test_confirm_bind_uses_latest_har_protocol(monkeypatch):
"""确认绑定复用抓包里的 gid=3203 / bActId=0 / outerActId=17096。"""
client = _BindConfirmClient()
runner, marks = _task_runner(monkeypatch, "confirm_bind")
monkeypatch.setattr(runner, "_activity_client", lambda uid, cookie: client)
account = HuyaAccount(uid=str(UID), cookie=COOKIE)
runner._execute_confirm_bind(
None,
None,
account,
{"uid": UID, "cookie": COOKIE},
{"bind_gid": "3203", "outer_act_id": "17096"},
)
assert marks[-1][0] == "success"
assert client.confirm_calls == [
{
"uid": UID,
"cookie": COOKIE,
"b_act_id": 0,
"gid": 3203,
"outer_act_id": "17096",
}
]
assert all(
call["b_act_id"] == 0
and call["gid"] == 3203
and call["outer_act_id"] == "17096"
and call["is_use_outer_act_id"] == 0
and call["scene"] == "undefined"
for call in client.check_calls
)
def test_get_bind_qr_uses_latest_har_protocol(monkeypatch):
"""获取二维码的完整链路与 2026-11 抓包参数一致。"""
client = _BindQrClient()
runner, marks = _task_runner(monkeypatch, "get_bind_qr")
monkeypatch.setattr(runner, "_activity_client", lambda uid, cookie: client)
runner._stop.set()
account = HuyaAccount(uid=str(UID), cookie=COOKIE)
runner._execute_get_bind_qr(
None,
None,
account,
{"uid": UID, "cookie": COOKIE},
{"bind_gid": "3203", "outer_act_id": "17096"},
)
assert marks[-1][2]["gid"] == 3203
assert marks[-1][2]["bind_gid"] == "3203"
assert client.live_link_calls == [
{
"uid": UID,
"cookie": COOKIE,
"b_act_id": 0,
"gid": 3203,
"outer_act_id": 17096,
"game_auth_scene": "",
}
]
assert client.check_calls[0]["b_act_id"] == 0
assert client.check_calls[0]["gid"] == 3203
assert client.check_calls[0]["is_use_outer_act_id"] == 0
assert client.check_calls[0]["scene"] == "undefined"
+4 -1
View File
@@ -258,7 +258,10 @@ def test_query_points_uses_http_and_prechecks_ck_with_new_probe(monkeypatch):
"uid": UID,
"cookie": COOKIE,
"b_act_id_int": 9271,
"outer_act_id": "17096",
"outer_act_id": "",
"gid": 0,
"is_use_outer_act_id": 1,
"scene": "",
}
]
assert marks[-1][0] == "success"
+20
View File
@@ -1,8 +1,13 @@
"""工作台「导入外部 CK」:入库 + 自动加入工作台 + 归属导入者。"""
import pytest
from fastapi import HTTPException
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import HuyaAccount, HuyaWorkbench, HuyaWorkbenchAccount, User
from web.backend.permissions import ROLE_PERMISSIONS
from web.backend.routers.huya import import_workbench_cookies
from web.backend.routers.huya._common import _require_huya_workbench_import_ck
from web.backend.schemas import HuyaWorkbenchCookieImport
COOKIE_1 = (
@@ -79,3 +84,18 @@ class TestImportExternalCkToWorkbench:
assert result["account_ids"] == []
assert self.session.query(HuyaAccount).count() == 0
assert self.session.query(HuyaWorkbench).count() == 0
def test_support_can_import_workbench_ck_by_default(self):
"""客服默认允许工作台导入外部 CK,但旧 huya:import 仍只属于运营。"""
assert "huya:elite:import_ck" in ROLE_PERMISSIONS["support"]
assert "huya:elite:import_ck" not in ROLE_PERMISSIONS["operation"]
support = User(username="cs", password_hash="hash", role="support")
operation = User(username="op", password_hash="hash", role="operation")
guest = User(username="guest", password_hash="hash", role="guest")
assert _require_huya_workbench_import_ck(support) is None
assert _require_huya_workbench_import_ck(operation) is None
with pytest.raises(HTTPException) as excinfo:
_require_huya_workbench_import_ck(guest)
assert excinfo.value.status_code == 403
@@ -0,0 +1,99 @@
"""精英宝典工作台「转入外部单协作」:移出工作台并取回 CK。"""
import pytest
from fastapi import HTTPException
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import AuditLog, HuyaAccount, HuyaWorkbenchAccount, User
from web.backend.routers.huya.workbench import transfer_workbench_to_collab
from web.backend.schemas import HuyaWorkbenchTransferToCollab
class TestTransferWorkbenchToCollab:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.support = User(username="transfer-cs", password_hash="h", role="support")
self.account = HuyaAccount(
uid="100",
yyuid="100",
cookie="yyuid=100; udb_uid=100; udb_passport=hy_test",
assigned_to=None,
)
self.session.add_all([self.support, self.account])
self.session.commit()
self.account.assigned_to = self.support.id
self.session.commit()
def teardown_method(self):
self.session.close()
def _add_membership(self):
membership = HuyaWorkbenchAccount(
user_id=self.support.id,
handbook_scope="elite",
account_id=self.account.id,
)
self.session.add(membership)
self.session.commit()
return membership
def test_support_transfers_assigned_workbench_account(self):
self._add_membership()
result = transfer_workbench_to_collab(
HuyaWorkbenchTransferToCollab(account_id=self.account.id),
db=self.session,
current=self.support,
)
assert result["success"] is True
assert result["account_id"] == self.account.id
assert "udb_uid=100" in result["ck_text"]
assert (
self.session.query(HuyaWorkbenchAccount)
.filter(HuyaWorkbenchAccount.account_id == self.account.id)
.count()
== 0
)
audit = self.session.query(AuditLog).one()
assert audit.action == "huya:workbench:transfer_collab"
assert audit.target == f"huya_account:{self.account.id}"
def test_account_not_in_workbench_is_rejected(self):
with pytest.raises(HTTPException) as excinfo:
transfer_workbench_to_collab(
HuyaWorkbenchTransferToCollab(account_id=self.account.id),
db=self.session,
current=self.support,
)
assert excinfo.value.status_code == 404
def test_account_without_cookie_is_rejected(self):
self.account.cookie = ""
self.session.commit()
self._add_membership()
with pytest.raises(HTTPException) as excinfo:
transfer_workbench_to_collab(
HuyaWorkbenchTransferToCollab(account_id=self.account.id),
db=self.session,
current=self.support,
)
assert excinfo.value.status_code == 400
def test_support_cannot_transfer_other_users_account(self):
other = User(username="transfer-other", password_hash="h", role="support")
self.session.add(other)
self.session.commit()
self.account.assigned_to = other.id
self.session.commit()
self._add_membership()
with pytest.raises(HTTPException) as excinfo:
transfer_workbench_to_collab(
HuyaWorkbenchTransferToCollab(account_id=self.account.id),
db=self.session,
current=self.support,
)
assert excinfo.value.status_code == 403
+1 -1
View File
@@ -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 = "20261109_0075"
# 迁移新增、但不声明在模型里的复合查询索引。
EXTRA_INDEXES = (
+468 -6
View File
@@ -18,26 +18,40 @@ 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,
assignments_summary,
batch_assign_accounts,
batch_tag_accounts,
copy_account_ck,
create_bind_qrcodes,
disable_account,
enable_account,
get_account,
import_cookies,
list_account_ids,
list_account_tags,
list_accounts,
list_workbench_accounts,
list_workbench_pool,
list_workbench_tags,
refresh_account,
remove_workbench_accounts,
update_account,
)
from web.backend.schemas import (
WeiboAccountBatchTagRequest,
WeiboAccountDisableRequest,
WeiboAccountUpdate,
WeiboBatchAssignRequest,
WeiboCookieImport,
WeiboExchangeRequest,
WeiboPaymentStatusRequest,
WeiboRechargeRequest,
WeiboWorkbenchAccountsRequest,
)
from web.backend.services import weibo_service
@@ -130,23 +144,42 @@ def stub_client(monkeypatch):
monkeypatch.setattr(weibo_service, "WeiboClient", StubWeiboClient)
def test_import_creates_encrypted_account_and_is_idempotent(db, current_user):
def test_import_creates_once_and_updates_existing_only_when_requested(db, current_user):
first = import_cookies(
WeiboCookieImport(text="SCF=a; SUB=b", tag="运营"),
db=db,
current=current_user,
)
second = import_cookies(
duplicate = import_cookies(
WeiboCookieImport(text="SCF=c; SUB=d", tag="新标签"),
db=db,
current=current_user,
)
assert first.count == 1
assert second.count == 1
assert duplicate.count == 0
assert duplicate.skipped == 1
assert duplicate.accounts == []
assert db.query(WeiboAccount).count() == 1
account = db.query(WeiboAccount).one()
assert account.uid == "4014000001"
assert account.cookie == "SCF=a; SUB=b"
assert account.tag == "运营"
updated = import_cookies(
WeiboCookieImport(
text="SCF=c; SUB=d",
tag="新标签",
update_existing=True,
),
db=db,
current=current_user,
)
assert updated.count == 1
assert updated.skipped == 0
assert db.query(WeiboAccount).count() == 1
account = db.query(WeiboAccount).one()
assert account.cookie == "SCF=c; SUB=d"
assert account.tag == "新标签"
assert account.ck_check_status == "valid"
@@ -154,7 +187,7 @@ def test_import_creates_encrypted_account_and_is_idempotent(db, current_user):
assert raw_cookie.startswith("enc:v1:")
assert "SUB=d" not in raw_cookie
dumped = second.accounts[0].model_dump()
dumped = updated.accounts[0].model_dump()
assert dumped["uid"] == "4014000001"
assert dumped["is_bound"] is False
assert dumped["weidou_balance"] == 100
@@ -190,6 +223,52 @@ def test_import_reports_payment_access_failure(db, current_user):
assert db.query(WeiboAccount).count() == 0
def test_occupied_workbench_account_cannot_be_updated_by_another_user(db, current_user):
imported_out = import_cookies(
WeiboCookieImport(text="SCF=a; SUB=owner"),
db=db,
current=current_user,
).accounts[0]
imported = db.query(WeiboAccount).filter(WeiboAccount.id == imported_out.id).one()
original_cookie = imported.cookie
owner = User(
username="weibo-owner",
password_hash="hash",
role="support",
is_active=True,
)
other = User(
username="weibo-other",
password_hash="hash",
role="support",
is_active=True,
)
db.add_all([owner, other])
db.commit()
db.add(
WeiboWorkbenchAccount(
user_id=owner.id,
account_id=imported.id,
)
)
db.commit()
result = import_cookies(
WeiboCookieImport(
text="SCF=z; SUB=other",
update_existing=True,
),
db=db,
current=other,
)
assert result.count == 0
assert len(result.errors) == 1
assert "其他人的工作台占用" in result.errors[0].message
db.refresh(imported)
assert imported.cookie == original_cookie
def test_refresh_marks_payment_access_failure(db, current_user):
account_out = import_cookies(
WeiboCookieImport(text="SCF=a; SUB=b", tag=""),
@@ -255,9 +334,24 @@ def test_copy_account_ck_uses_export_permission_defaults(db, current_user):
)
db.add(support)
db.commit()
account = db.query(WeiboAccount).filter(WeiboAccount.id == account_out.id).one()
account.assigned_to = support.id
db.commit()
support_result = copy_account_ck(account_out.id, db=db, current=support)
assert support_result.ck_text == "SCF=a; SUB=b"
denied = User(
username="weibo-copy-denied",
password_hash="hash",
role="support",
custom_permissions=[],
is_active=True,
)
db.add(denied)
db.commit()
with pytest.raises(HTTPException) as exc_info:
copy_account_ck(account_out.id, db=db, current=support)
copy_account_ck(account_out.id, db=db, current=denied)
assert exc_info.value.status_code == 403
@@ -273,6 +367,147 @@ def test_copy_account_ck_uses_export_permission_defaults(db, current_user):
assert result.ck_text == "SCF=a; SUB=b"
def test_support_role_has_weibo_operation_permissions_without_delete():
support = set(ROLE_PERMISSIONS["support"])
assert {
"weibo:view",
"weibo:workbench",
"weibo:import",
"weibo:manage",
"weibo:account:ck_export",
"weibo:bind",
"weibo:recharge",
"weibo:exchange",
"weibo:collab:view",
"weibo:collab:edit",
} <= support
assert "weibo:delete" not in support
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()
first.assigned_to = user_a.id
second.assigned_to = user_b.id
db.commit()
add_workbench_accounts(
WeiboWorkbenchAccountsRequest(account_ids=[first.id]),
db=db,
current=user_a,
)
pool_before = list_workbench_pool(page=1, page_size=20, db=db, current=user_a)
assert pool_before["items"] == []
with pytest.raises(HTTPException) as exc_info:
add_workbench_accounts(
WeiboWorkbenchAccountsRequest(account_ids=[first.id]),
db=db,
current=user_b,
)
assert exc_info.value.status_code == 403
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 (
list_workbench_accounts(tag="A", page=1, page_size=20, db=db, current=user_a)[
"total"
]
== 1
)
assert list_workbench_tags(db=db, current=user_a)["tags"] == ["A"]
assert list_account_tags(db=db, current=current_user)["tags"] == ["A", "B"]
assert db.query(WeiboWorkbenchAccount).count() == 2
pool_a = list_workbench_pool(page=1, page_size=20, db=db, current=user_a)
assert pool_a["items"] == []
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
)
pool_b = list_workbench_pool(page=1, page_size=20, db=db, current=user_b)
assert pool_b["items"] == []
pool_a = list_workbench_pool(page=1, page_size=20, db=db, current=user_a)
assert [item.uid for item in pool_a["items"]] == [first.uid]
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(
@@ -302,6 +537,233 @@ def test_list_accounts_returns_parallel_page_shape(db, current_user):
}
def test_weibo_account_scope_for_support_and_operator(db):
support_a = User(
username="weibo-scope-a",
password_hash="hash",
role="support",
is_active=True,
)
support_b = User(
username="weibo-scope-b",
password_hash="hash",
role="support",
is_active=True,
)
operator = User(
username="weibo-scope-operator",
password_hash="hash",
role="operation",
is_active=True,
)
db.add_all([support_a, support_b, operator])
db.commit()
own = WeiboAccount(
uid="4014000301",
nickname="自己账号",
cookie="SCF=a; SUB=own",
tag="自己标签",
assigned_to=support_a.id,
)
other = WeiboAccount(
uid="4014000302",
nickname="他人账号",
cookie="SCF=b; SUB=other",
tag="他人标签",
assigned_to=support_b.id,
)
db.add_all([own, other])
db.commit()
own_page = list_accounts(page=1, page_size=20, db=db, current=support_a)
assert own_page["total"] == 1
own_dump = own_page["items"][0].model_dump()
assert own_dump["uid"] == own.uid
assert own_dump["assigned_to"] == support_a.id
assert own_dump["assigned_username"] == support_a.username
assert list_account_ids(db=db, current=support_a)["account_ids"] == [own.id]
assert list_account_tags(db=db, current=support_a)["tags"] == ["自己标签"]
with pytest.raises(HTTPException) as exc_info:
get_account(other.id, db=db, current=support_a)
assert exc_info.value.status_code == 404
own.tag = "原标签"
db.commit()
with pytest.raises(HTTPException) as exc_info:
batch_tag_accounts(
WeiboAccountBatchTagRequest(ids=[own.id, other.id], tag="越权"),
db=db,
current=support_a,
)
assert exc_info.value.status_code == 404
db.refresh(own)
db.refresh(other)
assert own.tag == "原标签"
assert other.tag == "他人标签"
operator_page = list_accounts(page=1, page_size=20, db=db, current=operator)
assert operator_page["total"] == 2
assert set(list_account_tags(db=db, current=operator)["tags"]) == {
"他人标签",
"原标签",
}
preview = assignments_summary(db=db, current=operator)
assert preview["total_count"] == 2
assert preview["assigned_count"] == 2
assert preview["unassigned_count"] == 0
counts = {item["id"]: item["assigned_count"] for item in preview["support_users"]}
assert counts == {support_a.id: 1, support_b.id: 1}
with pytest.raises(HTTPException) as exc_info:
batch_assign_accounts(
WeiboBatchAssignRequest(account_ids=[own.id], assigned_to=support_b.id),
db=db,
current=support_a,
)
assert exc_info.value.status_code == 403
def test_weibo_batch_assign_cleans_previous_workbench(db):
operator = User(
username="weibo-assign-operator",
password_hash="hash",
role="operation",
is_active=True,
)
old_support = User(
username="weibo-assign-old",
password_hash="hash",
role="support",
is_active=True,
)
new_support = User(
username="weibo-assign-new",
password_hash="hash",
role="support",
is_active=True,
)
db.add_all([operator, old_support, new_support])
db.commit()
account = WeiboAccount(
uid="4014000311",
nickname="待改分配",
cookie="SCF=a; SUB=b",
assigned_to=old_support.id,
)
db.add(account)
db.commit()
add_workbench_accounts(
WeiboWorkbenchAccountsRequest(account_ids=[account.id]),
db=db,
current=old_support,
)
result = batch_assign_accounts(
WeiboBatchAssignRequest(
account_ids=[account.id],
assigned_to=new_support.id,
),
db=db,
current=operator,
)
assert result["updated"] == 1
db.refresh(account)
assert account.assigned_to == new_support.id
assert (
db.query(WeiboWorkbenchAccount)
.filter(WeiboWorkbenchAccount.account_id == account.id)
.count()
== 0
)
add_workbench_accounts(
WeiboWorkbenchAccountsRequest(account_ids=[account.id]),
db=db,
current=new_support,
)
audit = (
db.query(AuditLog)
.filter(AuditLog.action == "weibo:account:assign")
.order_by(AuditLog.id.desc())
.first()
)
assert audit is not None
assert str(account.id) in audit.detail
assert "SCF=a" not in audit.detail
def test_support_cannot_update_another_users_account_by_reimport(db):
support_a = User(
username="weibo-reimport-a",
password_hash="hash",
role="support",
is_active=True,
)
support_b = User(
username="weibo-reimport-b",
password_hash="hash",
role="support",
is_active=True,
)
db.add_all([support_a, support_b])
db.commit()
account = WeiboAccount(
uid="4014000321",
nickname="他人 CK",
cookie="SCF=old; SUB=owner",
assigned_to=support_a.id,
)
db.add(account)
db.commit()
StubWeiboClient.snapshot = WeiboHomeSnapshot(
uid=account.uid,
nickname="他人 CK",
is_bound=False,
role_name="",
can_rebind=True,
rebind_remain_days=0,
weidou_balance=100,
bind_url=BIND_URL,
)
denied = import_cookies(
WeiboCookieImport(
text="SCF=new; SUB=attacker",
update_existing=True,
),
db=db,
current=support_b,
)
assert denied.count == 0
assert len(denied.errors) == 1
assert "已分配给其他客服" in denied.errors[0].message
db.refresh(account)
assert account.cookie == "SCF=old; SUB=owner"
assert account.assigned_to == support_a.id
StubWeiboClient.snapshot = WeiboHomeSnapshot(
uid="4014000322",
nickname="新账号",
is_bound=False,
role_name="",
can_rebind=True,
rebind_remain_days=0,
weidou_balance=100,
bind_url=BIND_URL,
)
imported = import_cookies(
WeiboCookieImport(text="SCF=new; SUB=own"),
db=db,
current=support_b,
)
assert imported.count == 1
new_account = db.query(WeiboAccount).filter(WeiboAccount.uid == "4014000322").one()
assert new_account.assigned_to == support_b.id
def test_account_crud_and_lifecycle(db, current_user):
account_out = import_cookies(
WeiboCookieImport(text="SCF=a; SUB=b", tag="初始"),
+3
View File
@@ -3,6 +3,8 @@
HUYA_DEFAULT_ROOM_PID = "1199650619883"
HUYA_DEFAULT_SID = "2203"
HUYA_DEFAULT_OUTER_ACT_ID = "17096"
# 2026-11-09 最新活动页抓包:绑定走 gid=3203、bActId=0。
HUYA_DEFAULT_BIND_GID = "3203"
HUYA_DEFAULT_BIND_ACT_ID = "9271"
HUYA_DEFAULT_PAY_CHANNEL = "Zfb"
@@ -10,6 +12,7 @@ HUYA_CONFIG_DEFAULTS = {
"room_pid": HUYA_DEFAULT_ROOM_PID,
"sid": HUYA_DEFAULT_SID,
"outer_act_id": HUYA_DEFAULT_OUTER_ACT_ID,
"bind_gid": HUYA_DEFAULT_BIND_GID,
"bind_act_id": HUYA_DEFAULT_BIND_ACT_ID,
"pay_channel": HUYA_DEFAULT_PAY_CHANNEL,
}
@@ -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")
@@ -0,0 +1,54 @@
"""限制微博账号同时只能属于一个个人工作台。"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261108_0073"
down_revision: str | None = "20261108_0072"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table("weibo_workbench_accounts"):
return
# 历史若已有重复占用,保留最早加入的一条,其余释放。
op.execute(
sa.text(
"DELETE newer FROM weibo_workbench_accounts AS newer "
"JOIN weibo_workbench_accounts AS older "
"ON newer.account_id = older.account_id AND newer.id > older.id"
)
)
constraints = {
item["name"]
for item in inspector.get_unique_constraints("weibo_workbench_accounts")
}
if "uq_weibo_workbench_account_owner" not in constraints:
op.create_unique_constraint(
"uq_weibo_workbench_account_owner",
"weibo_workbench_accounts",
["account_id"],
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table("weibo_workbench_accounts"):
return
constraints = {
item["name"]
for item in inspector.get_unique_constraints("weibo_workbench_accounts")
}
if "uq_weibo_workbench_account_owner" in constraints:
op.drop_constraint(
"uq_weibo_workbench_account_owner",
"weibo_workbench_accounts",
type_="unique",
)
@@ -0,0 +1,59 @@
"""增加微博账号归属客服。"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261108_0074"
down_revision: str | None = "20261108_0073"
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_accounts"):
return
columns = {column["name"] for column in inspector.get_columns("weibo_accounts")}
if "assigned_to" not in columns:
op.add_column(
"weibo_accounts",
sa.Column("assigned_to", sa.Integer(), nullable=True),
)
op.create_foreign_key(
"fk_weibo_accounts_assigned_to_users",
"weibo_accounts",
"users",
["assigned_to"],
["id"],
ondelete="SET NULL",
)
indexes = {index["name"] for index in inspector.get_indexes("weibo_accounts")}
if "ix_weibo_accounts_assigned_to" not in indexes:
op.create_index(
"ix_weibo_accounts_assigned_to",
"weibo_accounts",
["assigned_to"],
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table("weibo_accounts"):
return
foreign_keys = {fk["name"] for fk in inspector.get_foreign_keys("weibo_accounts")}
if "fk_weibo_accounts_assigned_to_users" in foreign_keys:
op.drop_constraint(
"fk_weibo_accounts_assigned_to_users",
"weibo_accounts",
type_="foreignkey",
)
indexes = {index["name"] for index in inspector.get_indexes("weibo_accounts")}
if "ix_weibo_accounts_assigned_to" in indexes:
op.drop_index("ix_weibo_accounts_assigned_to", table_name="weibo_accounts")
columns = {column["name"] for column in inspector.get_columns("weibo_accounts")}
if "assigned_to" in columns:
op.drop_column("weibo_accounts", "assigned_to")
@@ -0,0 +1,39 @@
"""虎牙绑定协议增加 gid 配置。"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261109_0075"
down_revision: str | None = "20261108_0074"
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("huya_config"):
return
columns = {column["name"] for column in inspector.get_columns("huya_config")}
if "bind_gid" not in columns:
op.add_column(
"huya_config",
sa.Column(
"bind_gid",
sa.String(length=32),
nullable=False,
server_default="3203",
),
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table("huya_config"):
return
columns = {column["name"] for column in inspector.get_columns("huya_config")}
if "bind_gid" in columns:
op.drop_column("huya_config", "bind_gid")
+41
View File
@@ -23,6 +23,7 @@ from .crypto_storage import EncryptedText
from .database import Base, UTCDateTime
from .huya_defaults import (
HUYA_DEFAULT_BIND_ACT_ID,
HUYA_DEFAULT_BIND_GID,
HUYA_DEFAULT_OUTER_ACT_ID,
HUYA_DEFAULT_PAY_CHANNEL,
HUYA_DEFAULT_ROOM_PID,
@@ -934,6 +935,11 @@ class WeiboAccount(Base):
String(256), nullable=False, default="", server_default=""
)
activity: Mapped[str] = mapped_column(String(64), default="")
assigned_to: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
is_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="1", index=True
@@ -960,6 +966,40 @@ class WeiboAccount(Base):
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
assigned_user: Mapped[User | None] = relationship(
"User",
foreign_keys=[assigned_to],
)
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",
),
UniqueConstraint(
"account_id",
name="uq_weibo_workbench_account_owner",
),
)
class WeiboCollabShop(Base):
"""微博外部单协作的店铺字典。"""
@@ -1330,6 +1370,7 @@ class HuyaConfig(Base):
outer_act_id: Mapped[str] = mapped_column(
String(32), default=HUYA_DEFAULT_OUTER_ACT_ID
)
bind_gid: Mapped[str] = mapped_column(String(32), default=HUYA_DEFAULT_BIND_GID)
bind_act_id: Mapped[str] = mapped_column(
String(32), default=HUYA_DEFAULT_BIND_ACT_ID
)
+16
View File
@@ -43,6 +43,8 @@ PERMISSIONS = {
"yyb:history": "查看应用宝充值历史",
# 微博微豆
"weibo:view": "查看微博账号",
"weibo:workbench": "使用个人微博工作台",
"weibo:assign": "查看全部微博账号并分配客服",
"weibo:import": "导入/刷新微博 CK",
"weibo:manage": "编辑、停用和启用微博账号",
"weibo:account:ck_export": "复制微博外部 CK 账号的 CK(写剪贴板)",
@@ -64,6 +66,7 @@ PERMISSIONS = {
"huya:cookie:view": "查看虎牙 Cookie",
"huya:cookie:export": "导出/清除虎牙 Cookie",
"huya:task": "虎牙任务管理",
"huya:elite:import_ck": "精英宝典工作台导入外部 CK",
"huya:bind": "虎牙绑定操作",
"huya:recharge": "虎牙充值操作",
"huya:config": "虎牙配置管理",
@@ -114,6 +117,8 @@ ROLE_PERMISSIONS = {
"yyb:manage",
"yyb:history",
"weibo:view",
"weibo:workbench",
"weibo:assign",
"weibo:import",
"weibo:manage",
"weibo:account:ck_export",
@@ -159,9 +164,20 @@ ROLE_PERMISSIONS = {
"douyu:collab:edit",
"yyb:session",
"yyb:history",
"weibo:view",
"weibo:workbench",
"weibo:import",
"weibo:manage",
"weibo:account:ck_export",
"weibo:bind",
"weibo:recharge",
"weibo:exchange",
"weibo:collab:view",
"weibo:collab:edit",
"huya:view_assigned",
"huya:tag",
"huya:task",
"huya:elite:import_ck",
# 客服能看能改(共享表,靠审计留痕),但删除只给运营。
"huya:collab:view",
"huya:collab:edit",
+2
View File
@@ -50,6 +50,7 @@ from .workbench import (
stop_batch,
task_types,
tasks_summary,
transfer_workbench_to_collab,
update_config,
update_huya_workbench_accounts,
ws_huya_logs,
@@ -105,6 +106,7 @@ __all__ = [
"task_types",
"tasks_summary",
"test_sms_url_endpoint",
"transfer_workbench_to_collab",
"update_config",
"update_huya_workbench_accounts",
"ws_huya_logs",
+10
View File
@@ -132,6 +132,15 @@ def _require_huya_perm(user: User, permission: str) -> None:
raise HTTPException(status_code=403, detail="权限不足")
def _require_huya_workbench_import_ck(user: User) -> None:
"""工作台导入外部 CK 的专用权限;运营仍走历史 huya:import。"""
if not (
_has_huya_perm(user, "huya:import")
or _has_huya_perm(user, "huya:elite:import_ck")
):
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")):
@@ -444,6 +453,7 @@ def _config_out(config: HuyaConfig) -> HuyaConfigOut:
room_pid=huya_config_value("room_pid", config.room_pid),
sid=huya_config_value("sid", config.sid),
outer_act_id=huya_config_value("outer_act_id", config.outer_act_id),
bind_gid=huya_config_value("bind_gid", config.bind_gid),
bind_act_id=huya_config_value("bind_act_id", config.bind_act_id),
pay_channel=huya_config_value("pay_channel", config.pay_channel),
updated_at=config.updated_at,
+47 -1
View File
@@ -2,6 +2,7 @@
from fastapi import APIRouter
from ...schemas import HuyaWorkbenchTransferToCollab
from ._common import *
router = APIRouter()
@@ -114,6 +115,51 @@ def update_huya_workbench_accounts(
return {"account_ids": ids, "success": True}
@router.post("/workbench-accounts/transfer-to-collab")
def transfer_workbench_to_collab(
req: HuyaWorkbenchTransferToCollab,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:task")),
):
"""把单个精英宝典账号移出工作台,并返回其网页 CK 供外部单新建表单使用。"""
_require_huya_perm(current, "huya:collab:edit")
_require_huya_task_account_access(db, current, [req.account_id])
membership = (
db.query(HuyaWorkbenchAccount)
.filter(
HuyaWorkbenchAccount.user_id == current.id,
HuyaWorkbenchAccount.handbook_scope == "elite",
HuyaWorkbenchAccount.account_id == req.account_id,
)
.first()
)
if membership is None:
raise HTTPException(status_code=404, detail="该账号不在精英宝典工作台")
account = db.query(HuyaAccount).filter(HuyaAccount.id == req.account_id).first()
ck_text = str(account.cookie or "").strip() if account else ""
if not ck_text:
raise HTTPException(
status_code=400, detail="该账号没有可用 Cookie,无法转入外部单协作"
)
db.delete(membership)
record_audit(
db,
current,
action="huya:workbench:transfer_collab",
target=f"huya_account:{account.id}",
detail={"account_id": account.id, "uid": account.uid},
)
db.commit()
return {
"account_id": account.id,
"ck_text": ck_text,
"success": True,
}
@router.get("/goods", response_model=list[HuyaGoodsOut])
def list_goods(
db: Session = Depends(get_db),
@@ -414,7 +460,7 @@ def import_workbench_cookies(
导入后的账号会出现在工作台账号列表里,可照常执行「生成开通支付码 / 绑定二维码 /
查询角色 / 刷新积分」等操作。没有归属的新账号自动归属到导入者,否则客服看不到。
"""
_require_huya_perm(current, "huya:import")
_require_huya_workbench_import_ck(current)
accounts, skipped = import_huya_cookies_detailed(
db, req.text, req.tag, assign_to=current.id
)
+534 -57
View File
@@ -6,20 +6,23 @@ import os
from fastapi import APIRouter, Depends, HTTPException, Query
from loguru import logger
from sqlalchemy import or_
from sqlalchemy.orm import Session, defer
from sqlalchemy import func, or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, defer, joinedload
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,
WeiboAccountUpdate,
WeiboBatchAssignRequest,
WeiboBindQrOut,
WeiboBindSchemeOut,
WeiboCookieImport,
@@ -37,6 +40,7 @@ from ..schemas import (
WeiboPointRecordItem,
WeiboRechargeOrderOut,
WeiboRechargeRequest,
WeiboWorkbenchAccountsRequest,
)
from ..services.audit_service import record_audit
from ..services.weibo_service import (
@@ -77,15 +81,106 @@ def _int_or_none(value: object) -> int | None:
return None
def _get_account(db: Session, account_id: int) -> WeiboAccount:
account = db.query(WeiboAccount).filter(WeiboAccount.id == account_id).first()
def _can_view_all_weibo_accounts(user: User) -> bool:
return user_has_permission(user, "weibo:assign")
def _visible_weibo_accounts_query(query, current: User):
if _can_view_all_weibo_accounts(current):
return query
return query.filter(WeiboAccount.assigned_to == current.id)
def _get_account(
db: Session,
account_id: int,
current: User | None = None,
) -> WeiboAccount:
query = db.query(WeiboAccount).filter(WeiboAccount.id == account_id)
if current is not None:
query = _visible_weibo_accounts_query(query, current)
account = query.first()
if account is None:
raise HTTPException(status_code=404, detail="微博账号不存在")
return account
def _account_out(account: WeiboAccount) -> WeiboAccountOut:
return WeiboAccountOut.model_validate(account)
return WeiboAccountOut.model_validate(account).model_copy(
update={
"assigned_username": (
account.assigned_user.username if account.assigned_user else ""
)
}
)
def _apply_account_filters(
query,
*,
search: str,
tag: str,
bind_status: str,
enabled: bool | None,
assign_status: str = "",
assigned_user_id: int | None = 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),
)
)
tag_value = _query_str(tag)
if tag_value:
query = query.filter(WeiboAccount.tag == tag_value)
assign_value = _query_str(assign_status)
if assign_value == "unassigned":
query = query.filter(WeiboAccount.assigned_to.is_(None))
elif assign_value == "assigned":
query = query.filter(WeiboAccount.assigned_to.isnot(None))
target_user_id = _int_or_none(assigned_user_id)
if target_user_id is not None:
query = query.filter(WeiboAccount.assigned_to == target_user_id)
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(
joinedload(WeiboAccount.assigned_user),
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:
@@ -139,8 +234,11 @@ def _record_audit_safely(
@router.get("/accounts")
def list_accounts(
search: str = Query(""),
tag: str = Query(""),
bind_status: str = Query(""),
enabled: bool | None = Query(None),
assign_status: str = Query(""),
assigned_user_id: int | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=200),
db: Session = Depends(get_db),
@@ -149,48 +247,414 @@ 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),
)
query = _apply_account_filters(
_visible_weibo_accounts_query(db.query(WeiboAccount), current),
search=search,
tag=tag,
bind_status=bind_status,
enabled=enabled,
assign_status=assign_status,
assigned_user_id=assigned_user_id,
)
return _paginated_accounts(query, page=page, page_size=page_size)
total = query.count()
rows = (
query.options(defer(WeiboAccount.cookie))
.order_by(WeiboAccount.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
@router.get("/workbench/accounts")
def list_workbench_accounts(
search: str = Query(""),
tag: str = Query(""),
bind_status: str = Query(""),
enabled: bool | None = Query(None),
page: int = Query(1, ge=1),
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)
)
if not _can_view_all_weibo_accounts(current):
query = query.filter(WeiboAccount.assigned_to == current.id)
query = _apply_account_filters(
query,
search=search,
tag=tag,
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(""),
tag: 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")
occupied = db.query(WeiboWorkbenchAccount.account_id)
pool_query = db.query(WeiboAccount).filter(WeiboAccount.id.not_in(occupied))
if not _can_view_all_weibo_accounts(current):
pool_query = pool_query.filter(WeiboAccount.assigned_to == current.id)
query = _apply_account_filters(
pool_query,
search=search,
tag=tag,
bind_status="",
enabled=True,
)
return _paginated_accounts(query, page=page, page_size=page_size)
@router.get("/workbench/tags")
def list_workbench_tags(
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""返回当前用户工作台中已有的标签。"""
_require_weibo_perm(current, "weibo:workbench")
tag_query = (
db.query(WeiboAccount.tag)
.join(
WeiboWorkbenchAccount,
WeiboWorkbenchAccount.account_id == WeiboAccount.id,
)
.filter(
WeiboWorkbenchAccount.user_id == current.id,
WeiboAccount.tag != "",
)
)
if not _can_view_all_weibo_accounts(current):
tag_query = tag_query.filter(WeiboAccount.assigned_to == current.id)
rows = tag_query.distinct().order_by(WeiboAccount.tag.asc()).all()
return {"tags": [tag for (tag,) in rows if tag]}
@router.post("/workbench/accounts")
def add_workbench_accounts(
req: WeiboWorkbenchAccountsRequest,
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}")
if not _can_view_all_weibo_accounts(current):
unavailable = {
account_id
for (account_id,) in db.query(WeiboAccount.id)
.filter(
WeiboAccount.id.in_(account_ids),
or_(
WeiboAccount.assigned_to.is_(None),
WeiboAccount.assigned_to != current.id,
),
)
.all()
}
if unavailable:
raise HTTPException(
status_code=403,
detail="只能使用分配给自己的微博账号",
)
taken = {
account_id
for (account_id,) in db.query(WeiboWorkbenchAccount.account_id)
.filter(
WeiboWorkbenchAccount.user_id != current.id,
WeiboWorkbenchAccount.account_id.in_(account_ids),
)
.all()
}
if taken:
raise HTTPException(
status_code=409,
detail="部分账号已在其他人的工作台中使用,请先让原使用人移出",
)
selected_ids = {
account_id
for (account_id,) in db.query(WeiboWorkbenchAccount.account_id)
.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
]
)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(
status_code=409,
detail="账号已被其他工作台占用,请刷新后重试",
) from exc
_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 = (
_visible_weibo_accounts_query(db.query(WeiboAccount), current)
.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.post("/accounts/batch-assign")
def batch_assign_accounts(
req: WeiboBatchAssignRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""批量分配微博账号给客服,或取消分配。"""
_require_weibo_perm(current, "weibo:assign")
account_ids = sorted(set(req.account_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}")
target = None
if req.assigned_to is not None:
target = (
db.query(User)
.filter(
User.id == req.assigned_to,
User.role == "support",
User.is_active.is_(True),
)
.first()
)
if target is None:
raise HTTPException(status_code=404, detail="目标客服不存在或已停用")
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)
).delete(synchronize_session=False)
else:
db.query(WeiboWorkbenchAccount).filter(
WeiboWorkbenchAccount.account_id.in_(actual_ids),
WeiboWorkbenchAccount.user_id != target.id,
).delete(synchronize_session=False)
for account in accounts:
account.assigned_to = target.id if target else None
db.commit()
target_name = target.username if target else ""
_record_audit_safely(
db,
current,
action="weibo:account:assign",
target=f"weibo_accounts:{len(account_ids)}",
detail={
"account_ids": account_ids,
"assigned_to": target.id if target else None,
"assigned_username": target_name,
"previous_assignments": previous_assignments,
},
)
return {
"items": [_account_out(account) for account in rows],
"total": total,
"page": page,
"page_size": page_size,
"updated": len(accounts),
"assigned_to": target.id if target else None,
"assigned_username": target_name,
"success": True,
}
@router.get("/assignments/summary")
def assignments_summary(
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""微博账号分配概览。"""
_require_weibo_perm(current, "weibo:assign")
rows = (
db.query(User.id, User.username, func.count(WeiboAccount.id))
.outerjoin(WeiboAccount, WeiboAccount.assigned_to == User.id)
.filter(
User.role == "support",
User.is_active.is_(True),
)
.group_by(User.id, User.username)
.order_by(User.username.asc())
.all()
)
users = [
{
"id": user_id,
"username": username,
"assigned_count": assigned_count,
}
for user_id, username, assigned_count in rows
]
total_count = db.query(WeiboAccount).count()
unassigned_count = (
db.query(WeiboAccount).filter(WeiboAccount.assigned_to.is_(None)).count()
)
return {
"total_count": total_count,
"assigned_count": total_count - unassigned_count,
"unassigned_count": unassigned_count,
"support_users": users,
}
@router.get("/accounts/tags")
def list_account_tags(
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""返回全局账号池已有的标签。"""
_require_weibo_perm(current, "weibo:view")
tag_query = _visible_weibo_accounts_query(db.query(WeiboAccount.tag), current)
rows = (
tag_query.filter(WeiboAccount.tag != "")
.distinct()
.order_by(WeiboAccount.tag.asc())
.all()
)
return {"tags": [tag for (tag,) in rows if tag]}
@router.get("/accounts/ids")
def list_account_ids(
search: str = Query(""),
tag: str = Query(""),
bind_status: str = Query(""),
enabled: bool | None = Query(None),
assign_status: str = Query(""),
assigned_user_id: int | None = Query(None),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""返回当前用户可见且符合筛选条件的全部微博账号 ID。"""
_require_weibo_perm(current, "weibo:view")
query = _apply_account_filters(
_visible_weibo_accounts_query(db.query(WeiboAccount), current),
search=search,
tag=tag,
bind_status=bind_status,
enabled=enabled,
assign_status=assign_status,
assigned_user_id=assigned_user_id,
)
account_ids = [
account_id
for (account_id,) in query.with_entities(WeiboAccount.id)
.order_by(WeiboAccount.id.desc())
.all()
]
return {"account_ids": account_ids, "total": len(account_ids)}
@router.get("/accounts/{account_id}", response_model=WeiboAccountOut)
def get_account(
account_id: int,
@@ -200,7 +664,7 @@ def get_account(
"""查看一个微博账号的本地资料。"""
_require_weibo_perm(current, "weibo:view")
return _account_out(_get_account(db, account_id))
return _account_out(_get_account(db, account_id, current))
@router.put("/accounts/{account_id}", response_model=WeiboAccountOut)
@@ -213,7 +677,7 @@ def update_account(
"""更新账号的人工标签和备注。"""
_require_weibo_perm(current, "weibo:manage")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
payload = req.model_dump(exclude_unset=True)
changes = update_weibo_account_metadata(
db,
@@ -242,7 +706,7 @@ def disable_account(
"""停用账号,保留 CK 和历史资料。"""
_require_weibo_perm(current, "weibo:manage")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
disable_weibo_account(db, account, reason=req.reason)
_record_audit_safely(
db,
@@ -267,7 +731,7 @@ def enable_account(
"""重新检测 CK 与支付风险,通过后启用账号。"""
_require_weibo_perm(current, "weibo:manage")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
_refresh_or_http(account, db)
enable_weibo_account(db, account)
_record_audit_safely(
@@ -294,10 +758,23 @@ def import_cookies(
req.text,
req.tag,
WEIBO_DEFAULT_ACTIVITY,
req.update_existing,
current.id,
can_update_all=_can_view_all_weibo_accounts(current),
)
typed_errors = [
WeiboImportError(line=item.line, message=item.message) for item in errors
]
if accounts and not _can_view_all_weibo_accounts(current):
changed = False
for account in accounts:
if account.assigned_to != current.id:
account.assigned_to = current.id
changed = True
if changed:
db.commit()
for account in accounts:
db.refresh(account)
if accounts:
_record_audit_safely(
db,
@@ -311,13 +788,13 @@ def import_cookies(
"errors": len(errors),
},
)
message = f"成功导入/更新 {len(accounts)} 个微博账号"
message = f"成功导入 {len(accounts)} 个微博账号"
if errors:
message += f",失败 {len(errors)} 条"
if skipped:
message += f",跳过 {skipped} 条重复"
return WeiboImportResult(
success=bool(accounts),
success=bool(accounts) or (skipped > 0 and not errors),
message=message,
count=len(accounts),
skipped=skipped,
@@ -335,7 +812,7 @@ def refresh_account(
"""立即刷新一个微博账号的 CK 状态和资料。"""
_require_weibo_perm(current, "weibo:view")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
_ensure_enabled(account)
_refresh_or_http(account, db)
_record_audit_safely(
@@ -357,7 +834,7 @@ def copy_account_ck(
"""复制已导入的微博 CK 明文;只走单独权限并保留审计。"""
_require_weibo_perm(current, "weibo:account:ck_export")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
ck_text = (account.cookie or "").strip()
if not ck_text:
raise HTTPException(status_code=409, detail="该账号没有保存 CK")
@@ -386,7 +863,7 @@ def create_bind_qrcodes(
_require_weibo_perm(current, "weibo:bind")
selected_channel = channel.strip() if isinstance(channel, str) else ""
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
_ensure_enabled(account)
snapshot = _refresh_or_http(account, db)
if snapshot.is_bound and not snapshot.can_rebind:
@@ -463,7 +940,7 @@ def list_mall_goods(
"""手动查询微豆商城商品列表。"""
_require_weibo_perm(current, "weibo:view")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
_ensure_enabled(account)
try:
data = query_weibo_mall(
@@ -516,7 +993,7 @@ def exchange_goods(
_require_weibo_perm(current, "weibo:exchange")
if not req.confirm:
raise HTTPException(status_code=400, detail="请先确认兑换商品")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
_ensure_enabled(account)
try:
result = exchange_weibo_goods(
@@ -574,7 +1051,7 @@ def list_exchange_records(
"""手动查询微豆兑换记录。"""
_require_weibo_perm(current, "weibo:view")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
_ensure_enabled(account)
try:
data = query_weibo_exchange_records(
@@ -623,7 +1100,7 @@ def list_point_history(
"""手动查询微豆获取和充值明细。"""
_require_weibo_perm(current, "weibo:view")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
_ensure_enabled(account)
try:
data = query_weibo_point_history(
@@ -673,7 +1150,7 @@ def create_recharge_order(
"""手动创建助威充值订单并返回支付二维码。"""
_require_weibo_perm(current, "weibo:recharge")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
_ensure_enabled(account)
try:
order = create_weibo_recharge_order(
@@ -721,7 +1198,7 @@ def get_recharge_status(
"""手动查询支付状态;支付动作仍由用户在微信/支付宝完成。"""
_require_weibo_perm(current, "weibo:recharge")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
_ensure_enabled(account)
try:
status = query_weibo_payment_status(db, account, pay_id=req.pay_id)
@@ -744,7 +1221,7 @@ def delete_account(
"""删除一个微博账号。"""
_require_weibo_perm(current, "weibo:delete")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
uid = account.uid
db.delete(account)
db.commit()
+27
View File
@@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_serializer
from .huya_defaults import (
HUYA_DEFAULT_BIND_ACT_ID,
HUYA_DEFAULT_BIND_GID,
HUYA_DEFAULT_OUTER_ACT_ID,
HUYA_DEFAULT_PAY_CHANNEL,
HUYA_DEFAULT_ROOM_PID,
@@ -468,6 +469,7 @@ class WeiboCookieImport(BaseModel):
text: str = Field(..., min_length=1)
tag: str = Field("", max_length=64)
update_existing: bool = False
class WeiboImportError(BaseModel):
@@ -482,6 +484,8 @@ class WeiboAccountOut(BaseModel):
tag: str = ""
remark: str = ""
activity: str = ""
assigned_to: int | None = None
assigned_username: str = ""
is_enabled: bool = True
disabled_at: datetime | None = None
disabled_reason: str = ""
@@ -505,6 +509,20 @@ 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 WeiboBatchAssignRequest(BaseModel):
account_ids: list[int] = Field(min_length=1)
assigned_to: int | None = None
class WeiboAccountDisableRequest(BaseModel):
reason: str = Field("", max_length=256)
@@ -1137,6 +1155,7 @@ class HuyaConfigOut(BaseModel):
room_pid: str = HUYA_DEFAULT_ROOM_PID
sid: str = HUYA_DEFAULT_SID
outer_act_id: str = HUYA_DEFAULT_OUTER_ACT_ID
bind_gid: str = HUYA_DEFAULT_BIND_GID
bind_act_id: str = HUYA_DEFAULT_BIND_ACT_ID
pay_channel: str = HUYA_DEFAULT_PAY_CHANNEL
updated_at: datetime | None = None
@@ -1147,6 +1166,7 @@ class HuyaConfigOut(BaseModel):
"room_pid": self.room_pid,
"sid": self.sid,
"outer_act_id": self.outer_act_id,
"bind_gid": self.bind_gid,
"bind_act_id": self.bind_act_id,
"pay_channel": self.pay_channel,
"updated_at": _isoformat(self.updated_at),
@@ -1157,6 +1177,7 @@ class HuyaConfigUpdate(BaseModel):
room_pid: str | None = None
sid: str | None = None
outer_act_id: str | None = None
bind_gid: str | None = None
bind_act_id: str | None = None
pay_channel: str | None = None
@@ -1182,6 +1203,12 @@ class HuyaWorkbenchCookieImport(BaseModel):
handbook_scope: str = Field("elite", pattern="^elite$|^browser_exchange$")
class HuyaWorkbenchTransferToCollab(BaseModel):
"""精英宝典工作台把单个账号转入外部单协作。"""
account_id: int = Field(..., gt=0)
class HuyaBrowserExchangeRequest(BaseModel):
"""浏览器兑换的目标页选择(常量见 services/huya_exchange_targets.py)。"""
+124 -47
View File
@@ -101,6 +101,41 @@ class BindMixin:
"bind_phase": "role_ready" if role_info["role_name"] else "waiting_role",
}
@staticmethod
def _bind_protocol(config_info: dict, payload: dict | None = None) -> dict:
"""读取绑定协议参数;有 gid 时用 2026-11 抓包协议,否则回退旧协议。"""
payload = payload or {}
bind_gid_text = str(
payload.get("bind_gid") or config_info.get("bind_gid") or ""
).strip()
bind_gid = HuyaBatchRunnerCore._to_int(bind_gid_text)
outer_act_id = str(
payload.get("outer_act_id")
or config_info.get("outer_act_id")
or HUYA_DEFAULT_OUTER_ACT_ID
).strip()
if bind_gid and outer_act_id:
return {
"bind_gid": bind_gid_text,
"b_act_id": 0,
"gid": bind_gid,
"outer_act_id": outer_act_id,
"is_use_outer_act_id": 0,
"scene": "undefined",
}
b_act_id_text = str(
payload.get("bind_act_id") or config_info.get("bind_act_id") or ""
).strip()
return {
"bind_gid": "",
"b_act_id": HuyaBatchRunnerCore._to_int(b_act_id_text),
"gid": 0,
# 旧协议最初不传 outerActId;需要旧路径的地方按原逻辑显式补默认值。
"outer_act_id": "",
"is_use_outer_act_id": 1,
"scene": "",
}
def _resolve_bind_status(
self,
client: Any,
@@ -110,16 +145,20 @@ class BindMixin:
*,
outer_act_id: str = "",
timeout: float | None = None,
gid: int = 0,
is_use_outer_act_id: int = 1,
scene: str = "",
):
"""按活动页逻辑解析绑定状态,优先返回含角色的状态。"""
probe_kwargs: dict[str, Any] = {
"uid": uid,
"cookie": cookie,
"b_act_id": b_act_id_int,
"is_use_outer_act_id": 1,
"gid": gid,
"outer_act_id": outer_act_id,
"is_use_outer_act_id": is_use_outer_act_id,
"scene": scene,
}
if outer_act_id:
probe_kwargs["outer_act_id"] = outer_act_id
if timeout is not None:
probe_kwargs["timeout"] = timeout
outer_status = client.check_user_bind_game_account(**probe_kwargs)
@@ -135,10 +174,16 @@ class BindMixin:
chosen_status = outer_status
account_data = outer_status.accountData
should_check_inner = not self._has_bind_role(outer_status) and bool(
account_data.isNeedActCheck
or not account_data.isBindAcount
or not account_data.isBindRole
# 最新抓包协议只用外层查询(gid + outerActId)。旧的内层兜底会带上
# gid=0/outerActId=空,被服务端判 501「参数错误」,仅在旧协议下保留。
should_check_inner = (
not gid
and not self._has_bind_role(outer_status)
and bool(
account_data.isNeedActCheck
or not account_data.isBindAcount
or not account_data.isBindRole
)
)
if should_check_inner:
inner_status = client.check_user_bind_game_account(
@@ -162,6 +207,9 @@ class BindMixin:
cookie: str,
b_act_id_int: int,
outer_act_id: str = "",
gid: int = 0,
is_use_outer_act_id: int = 1,
scene: str = "",
):
"""用外部单协作的新 CK 探针查角色。
@@ -177,6 +225,9 @@ class BindMixin:
b_act_id_int,
outer_act_id=outer_act_id or HUYA_DEFAULT_OUTER_ACT_ID,
timeout=HUYA_CK_PROBE_TIMEOUT_SECONDS,
gid=gid,
is_use_outer_act_id=is_use_outer_act_id,
scene=scene,
)
def _apply_role_to_account(self, account: HuyaAccount, bind_status, status: str):
@@ -209,6 +260,11 @@ class BindMixin:
cookie: str,
b_act_id_int: int,
result: dict,
*,
gid: int = 0,
outer_act_id: str = "",
is_use_outer_act_id: int = 1,
scene: str = "",
) -> tuple[str, dict]:
"""生成二维码后轮询扫码状态与角色同步,直到识别到角色、超时或停止。"""
deadline = time.monotonic() + HUYA_BIND_ROLE_POLL_SECONDS
@@ -258,6 +314,10 @@ class BindMixin:
uid=uid,
cookie=cookie,
b_act_id_int=b_act_id_int,
outer_act_id=outer_act_id,
gid=gid,
is_use_outer_act_id=is_use_outer_act_id,
scene=scene,
)
if bind_status is None:
continue
@@ -328,20 +388,12 @@ class BindMixin:
account_info: dict,
config_info: dict,
):
b_act_id = str(
self.payload.get("bind_act_id") or config_info.get("bind_act_id") or ""
).strip()
if not b_act_id:
protocol = self._bind_protocol(config_info, self.payload)
b_act_id_int = protocol["b_act_id"]
if not protocol["bind_gid"] and not b_act_id_int:
self._mark_task(worker_db, task, "failed", "请先配置虎牙绑定 bActId")
return
b_act_id_int = self._to_int(b_act_id)
if not b_act_id_int:
self._mark_task(
worker_db, task, "failed", f"虎牙绑定 bActId 无效: {b_act_id}"
)
return
uid = self._resolve_uid(account_info)
if not uid:
self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid")
@@ -358,6 +410,10 @@ class BindMixin:
uid=uid,
cookie=cookie,
b_act_id_int=b_act_id_int,
outer_act_id=protocol["outer_act_id"],
gid=protocol["gid"],
is_use_outer_act_id=protocol["is_use_outer_act_id"],
scene=protocol["scene"],
)
if bind_status is None:
self._mark_task(worker_db, task, "error", "虎牙绑定状态接口无响应")
@@ -383,6 +439,8 @@ class BindMixin:
available_at = change_state["change_available_at"]
result = {
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
**role_info,
**change_state,
"bind_status": bind_status.to_dict(),
@@ -400,6 +458,8 @@ class BindMixin:
uid=uid,
cookie=cookie,
b_act_id=b_act_id_int,
gid=protocol["gid"],
outer_act_id=int(protocol["outer_act_id"] or 0),
game_auth_scene=bind_status.gameAuthScene,
)
if live_link is None:
@@ -450,6 +510,8 @@ class BindMixin:
result = {
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
"mini_qrcode_image": mini_qrcode["mini_qrcode_image"],
"qrcode_token": mini_qrcode.get("qrcode_token") or "",
**bind_query_result,
@@ -483,6 +545,10 @@ class BindMixin:
cookie=cookie,
b_act_id_int=b_act_id_int,
result=result,
gid=protocol["gid"],
outer_act_id=protocol["outer_act_id"],
is_use_outer_act_id=protocol["is_use_outer_act_id"],
scene=protocol["scene"],
)
if role_name:
self._mark_task(
@@ -514,20 +580,12 @@ class BindMixin:
account_info: dict,
config_info: dict,
):
b_act_id = str(
self.payload.get("bind_act_id") or config_info.get("bind_act_id") or ""
).strip()
if not b_act_id:
protocol = self._bind_protocol(config_info, self.payload)
b_act_id_int = protocol["b_act_id"]
if not protocol["bind_gid"] and not b_act_id_int:
self._mark_task(worker_db, task, "failed", "请先配置虎牙绑定 bActId")
return
b_act_id_int = self._to_int(b_act_id)
if not b_act_id_int:
self._mark_task(
worker_db, task, "failed", f"虎牙绑定 bActId 无效: {b_act_id}"
)
return
uid = self._resolve_uid(account_info)
if not uid:
self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid")
@@ -538,16 +596,14 @@ class BindMixin:
self._mark_task(worker_db, task, "failed", "账号 Cookie 为空")
return
outer_act_id = str(
self.payload.get("outer_act_id")
or config_info.get("outer_act_id")
or HUYA_DEFAULT_OUTER_ACT_ID
).strip()
bind_status, bind_query_result = self._probe_bind_status(
uid=uid,
cookie=cookie,
b_act_id_int=b_act_id_int,
outer_act_id=outer_act_id,
outer_act_id=protocol["outer_act_id"],
gid=protocol["gid"],
is_use_outer_act_id=protocol["is_use_outer_act_id"],
scene=protocol["scene"],
)
if bind_status is None:
# 新探针没有回包 = 这份 CK 已经不认了,和外部单协作的判定一致。
@@ -560,6 +616,8 @@ class BindMixin:
"虎牙角色信息接口无响应(CK 已失效,请重新登录或重新导入 Cookie)",
{
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
"ck_expired": True,
"ck_expired_reason": "角色信息接口无响应",
},
@@ -572,7 +630,12 @@ class BindMixin:
task,
"failed",
bind_status.msg or f"虎牙角色信息查询失败: {bind_status.status}",
{"bind_act_id": b_act_id_int, **bind_query_result},
{
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
**bind_query_result,
},
)
return
@@ -580,6 +643,8 @@ class BindMixin:
change_state = self._bind_change_state(bind_status)
result = {
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
**role_info,
**change_state,
**bind_query_result,
@@ -604,20 +669,12 @@ class BindMixin:
account_info: dict,
config_info: dict,
):
b_act_id = str(
self.payload.get("bind_act_id") or config_info.get("bind_act_id") or ""
).strip()
if not b_act_id:
protocol = self._bind_protocol(config_info, self.payload)
b_act_id_int = protocol["b_act_id"]
if not protocol["bind_gid"] and not b_act_id_int:
self._mark_task(worker_db, task, "failed", "请先配置虎牙绑定 bActId")
return
b_act_id_int = self._to_int(b_act_id)
if not b_act_id_int:
self._mark_task(
worker_db, task, "failed", f"虎牙绑定 bActId 无效: {b_act_id}"
)
return
uid = self._resolve_uid(account_info)
if not uid:
self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid")
@@ -634,6 +691,10 @@ class BindMixin:
uid=uid,
cookie=cookie,
b_act_id_int=b_act_id_int,
outer_act_id=protocol["outer_act_id"],
gid=protocol["gid"],
is_use_outer_act_id=protocol["is_use_outer_act_id"],
scene=protocol["scene"],
)
if role_status is None:
self._mark_task(worker_db, task, "error", "虎牙绑定角色查询接口无响应")
@@ -653,6 +714,8 @@ class BindMixin:
if not role_name:
result = {
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
"bind_confirmed": False,
**role_query_result,
}
@@ -669,6 +732,8 @@ class BindMixin:
uid=uid,
cookie=cookie,
b_act_id=b_act_id_int,
gid=protocol["gid"],
outer_act_id=protocol["outer_act_id"],
)
if confirm_resp is None:
self._mark_task(worker_db, task, "error", "虎牙确认绑定接口无响应")
@@ -676,6 +741,8 @@ class BindMixin:
if confirm_resp.status != 200:
result = {
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
"bind_confirmed": False,
"confirm_result": confirm_resp.to_dict(),
"before_bind_status": role_status.to_dict(),
@@ -695,10 +762,16 @@ class BindMixin:
uid=uid,
cookie=cookie,
b_act_id_int=b_act_id_int,
outer_act_id=protocol["outer_act_id"],
gid=protocol["gid"],
is_use_outer_act_id=protocol["is_use_outer_act_id"],
scene=protocol["scene"],
)
if refreshed_status is None:
result = {
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
"bind_confirmed": True,
"confirm_result": confirm_resp.to_dict(),
"before_bind_status": role_status.to_dict(),
@@ -713,6 +786,8 @@ class BindMixin:
if refreshed_status.status != 200:
result = {
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
"bind_confirmed": True,
"confirm_result": confirm_resp.to_dict(),
"before_bind_status": role_status.to_dict(),
@@ -739,6 +814,8 @@ class BindMixin:
)
result = {
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
"bind_confirmed": True,
"refreshed_is_bound": refreshed_confirmed,
"confirm_result": confirm_resp.to_dict(),
+5 -1
View File
@@ -228,7 +228,11 @@ class HuyaBatchRunnerCore:
def _format_local_time(timestamp: int) -> str:
if not timestamp:
return ""
return datetime.fromtimestamp(timestamp, UTC).strftime("%Y-%m-%d %H:%M:%S")
return (
datetime.fromtimestamp(timestamp, UTC)
.astimezone()
.strftime("%Y-%m-%d %H:%M:%S")
)
@staticmethod
def _parse_scheduled_time(value) -> datetime | None:
+7 -10
View File
@@ -285,23 +285,20 @@ class EliteMixin:
role_name = ""
# ---- 角色绑定 / CK 有效性(先做,失效就不空跑后面两个接口) ----
b_act_id_int = self._to_int(
self.payload.get("bind_act_id") or config_info.get("bind_act_id")
)
if not b_act_id_int:
protocol = self._bind_protocol(config_info, self.payload)
b_act_id_int = protocol["b_act_id"]
if not protocol["bind_gid"] and not b_act_id_int:
failures.append("未配置绑定 bActId")
result["ck_valid"] = False
else:
outer_act_id = str(
self.payload.get("outer_act_id")
or config_info.get("outer_act_id")
or ""
).strip()
bind_status, bind_query_result = self._probe_bind_status(
uid=uid,
cookie=cookie,
b_act_id_int=b_act_id_int,
outer_act_id=outer_act_id,
outer_act_id=protocol["outer_act_id"],
gid=protocol["gid"],
is_use_outer_act_id=protocol["is_use_outer_act_id"],
scene=protocol["scene"],
)
if bind_status is None:
account.status = "ck_expired"
+7 -11
View File
@@ -10,7 +10,6 @@ from sqlalchemy.orm import Session
from core.huya.wss_client import POINTS_REDEEM_GOODS_NUM
from ..huya_defaults import HUYA_DEFAULT_OUTER_ACT_ID
from ..models import HuyaAccount, HuyaGoodsSnapshot, HuyaTask
# 积分查询超时:HTTP 通道正常几百毫秒,10 秒足够;失败时别让整批任务长时间挂住。
@@ -123,20 +122,17 @@ class GoodsMixin:
# ---- 先验 CK:没有回包就立即判失效,不再空跑积分与旧接口 ----
ck_valid = False
b_act_id_int = self._to_int(
self.payload.get("bind_act_id") or config_info.get("bind_act_id")
)
if b_act_id_int:
outer_act_id = str(
self.payload.get("outer_act_id")
or config_info.get("outer_act_id")
or HUYA_DEFAULT_OUTER_ACT_ID
).strip()
protocol = self._bind_protocol(config_info, self.payload)
b_act_id_int = protocol["b_act_id"]
if protocol["bind_gid"] or b_act_id_int:
bind_status, _bind_query_result = self._probe_bind_status(
uid=uid,
cookie=cookie,
b_act_id_int=b_act_id_int,
outer_act_id=outer_act_id,
outer_act_id=protocol["outer_act_id"],
gid=protocol["gid"],
is_use_outer_act_id=protocol["is_use_outer_act_id"],
scene=protocol["scene"],
)
if bind_status is None:
account.status = "ck_expired"
+33 -1
View File
@@ -18,7 +18,7 @@ from core.weibo import (
normalize_weibo_cookie,
)
from ..models import WeiboAccount
from ..models import WeiboAccount, WeiboWorkbenchAccount
@dataclass(frozen=True, slots=True)
@@ -61,6 +61,9 @@ def import_weibo_cookies(
text: str,
tag: str = "",
activity: str = "",
update_existing: bool = False,
current_user_id: int | None = None,
can_update_all: bool = False,
) -> tuple[list[WeiboAccount], list[WeiboImportIssue], int]:
"""逐行验证并导入微博 CK。
@@ -95,6 +98,35 @@ def import_weibo_cookies(
account = (
db.query(WeiboAccount).filter(WeiboAccount.uid == snapshot.uid).first()
)
if account is not None and update_existing and current_user_id is not None:
if (
not can_update_all
and account.assigned_to is not None
and account.assigned_to != current_user_id
):
errors.append(
WeiboImportIssue(
line=line_no,
message="该账号已分配给其他客服,不能更新 CK",
)
)
continue
owner_id = (
db.query(WeiboWorkbenchAccount.user_id)
.filter(WeiboWorkbenchAccount.account_id == account.id)
.scalar()
)
if owner_id is not None and owner_id != current_user_id:
errors.append(
WeiboImportIssue(
line=line_no,
message="该账号已被其他人的工作台占用,不能更新 CK",
)
)
continue
if account is not None and not update_existing:
skipped += 1
continue
if account is None:
account = WeiboAccount(uid=snapshot.uid, cookie=cookie)
db.add(account)
+4 -1
View File
@@ -32,6 +32,7 @@ const HuyaElitePage = lazy(() => import('./pages/HuyaElitePage'));
const HuyaCollabPage = lazy(() => import('./pages/HuyaCollabPage'));
const HuyaBrowserExchangePage = lazy(() => import('./pages/HuyaBrowserExchangePage'));
const WeiboAccountsPage = lazy(() => import('./pages/WeiboAccountsPage'));
const WeiboAssignmentsPage = lazy(() => import('./pages/WeiboAssignmentsPage'));
const WeiboCollabPage = lazy(() => import('./pages/WeiboCollabPage'));
const YybRechargePage = lazy(() => import('./pages/YybRechargePage'));
const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage'));
@@ -107,7 +108,9 @@ 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/assignments" element={lazyRoute(<WeiboAssignmentsPage />)} />
<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 />)} />
+8
View File
@@ -68,6 +68,8 @@ import type {
HuyaExchangeTargetKey,
HuyaExternalCkImportRequest,
HuyaExternalCkImportResult,
HuyaWorkbenchTransferToCollabRequest,
HuyaWorkbenchTransferToCollabResult,
HuyaWorkbenchScope,
MessageCountResponse,
MessageDeletedResponse,
@@ -179,6 +181,12 @@ export const huyaApi = {
/** 工作台导入外部 CK: 入库并把账号加入自己的工作台 (text 每行一条 CK) */
importWorkbenchCookies: (data: HuyaExternalCkImportRequest) =>
api.post<HuyaExternalCkImportResult, HuyaExternalCkImportResult>('/huya/workbench-accounts/import-cookies', data),
/** 精英宝典账号转入外部单协作:移出工作台并取回该账号网页 CK */
transferWorkbenchToCollab: (accountId: number) =>
api.post<HuyaWorkbenchTransferToCollabResult, HuyaWorkbenchTransferToCollabResult>(
'/huya/workbench-accounts/transfer-to-collab',
{ account_id: accountId } satisfies HuyaWorkbenchTransferToCollabRequest,
),
listWorkbenchAccounts: (scope: HuyaWorkbenchScope = 'elite') =>
api.get<{ account_ids: number[]; configured: boolean }, { account_ids: number[]; configured: boolean }>(
'/huya/workbench-accounts', { params: { handbook_scope: scope } },
+25
View File
@@ -754,6 +754,8 @@ export interface WeiboAccountItem {
nickname: string;
tag: string;
remark: string;
assigned_to: number | null;
assigned_username: string;
is_enabled: boolean;
disabled_at: string | null;
disabled_reason: string;
@@ -807,6 +809,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;
@@ -1171,6 +1184,7 @@ export interface HuyaConfig {
room_pid: string;
sid: string;
outer_act_id: string;
bind_gid: string;
bind_act_id: string;
pay_channel: string;
updated_at: string | null;
@@ -1261,6 +1275,17 @@ export interface HuyaExternalCkImportResult extends MessageCountResponse {
account_ids: number[];
}
/** 精英宝典工作台「转入外部单协作」:请求体 */
export interface HuyaWorkbenchTransferToCollabRequest {
account_id: number;
}
/** 精英宝典工作台「转入外部单协作」:结果里的 ck_text 用于外部单新建表单预填 */
export interface HuyaWorkbenchTransferToCollabResult extends MessageResponse {
account_id: number;
ck_text: string;
}
// ==================== Proxy ====================
export interface ProxyConfig {
+68 -1
View File
@@ -1,9 +1,11 @@
import api from './client';
import type {
AssignmentsSummary,
MessageResponse,
PaginatedResponse,
WeiboAccountItem,
WeiboAccountCkResult,
WeiboBatchTagResult,
WeiboBindQrResult,
WeiboExchangeRecordsResult,
WeiboExchangeResult,
@@ -12,13 +14,17 @@ import type {
WeiboPointHistoryResult,
WeiboRechargeOrderResult,
WeiboImportResult,
WeiboWorkbenchMutationResult,
} from './types';
export const weiboApi = {
listAccounts: (params: {
search?: string;
tag?: string;
bind_status?: string;
enabled?: boolean;
assign_status?: string;
assigned_user_id?: number;
page: number;
page_size: number;
}) =>
@@ -26,12 +32,25 @@ export const weiboApi = {
'/weibo/accounts',
{ params },
),
listAccountIds: (params: {
search?: string;
tag?: string;
bind_status?: string;
enabled?: boolean;
assign_status?: string;
assigned_user_id?: number;
}) =>
api.get<{ account_ids: number[]; total: number }, { account_ids: number[]; total: number }>(
'/weibo/accounts/ids',
{ params },
),
getAccount: (accountId: number) =>
api.get<WeiboAccountItem, WeiboAccountItem>(`/weibo/accounts/${accountId}`),
importCookies: (text: string, tag = '') =>
importCookies: (text: string, tag = '', updateExisting = false) =>
api.post<WeiboImportResult, WeiboImportResult>('/weibo/accounts/import-cookies', {
text,
tag,
update_existing: updateExisting,
}),
updateAccount: (
accountId: number,
@@ -60,6 +79,54 @@ 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 },
),
batchAssign: (accountIds: number[], assignedTo: number | null) =>
api.post<MessageResponse, MessageResponse>('/weibo/accounts/batch-assign', {
account_ids: accountIds,
assigned_to: assignedTo,
}),
assignmentsSummary: () =>
api.get<AssignmentsSummary, AssignmentsSummary>('/weibo/assignments/summary'),
accountTags: () =>
api.get<{ tags: string[] }, { tags: string[] }>('/weibo/accounts/tags'),
workbenchTags: () =>
api.get<{ tags: string[] }, { tags: string[] }>('/weibo/workbench/tags'),
listWorkbenchAccounts: (params: {
search?: string;
tag?: 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;
tag?: 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`,
@@ -4,6 +4,7 @@ import { PlusOutlined } from '@ant-design/icons';
import type { HuyaCollabShop } from '../api/modules';
const { Text } = Typography;
type ShopOption = Pick<HuyaCollabShop, 'name' | 'is_active'>;
/**
* 店铺选择器:**一个店铺一个独立按钮**(中间留白),不做下拉。
@@ -27,7 +28,7 @@ export default function HuyaCollabShopPicker({
onChange,
}: {
value?: string | null;
shops: HuyaCollabShop[];
shops: ShopOption[];
disabled?: boolean;
includeInactive?: boolean;
allowCustom?: boolean;
+9 -3
View File
@@ -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';
@@ -92,9 +91,10 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
douyuItems.push({ key: '/douyu/collab', label: '外部单协作', icon: <FileExcelOutlined /> });
}
// 虎牙:客服只保留浏览器兑换;管理员/运营显示完整入口
// 虎牙:客服用浏览器兑换和精英宝典;管理员/运营显示完整入口
if (can('huya:task')) {
if (user?.role === 'support') {
huyaItems.push({ key: '/huya/elite', label: '精英宝典', icon: <BookOutlined /> });
huyaItems.push({ key: '/huya/browser-exchange', label: '浏览器兑换', icon: <GlobalOutlined /> });
} else {
if (canAny(['huya:account', 'huya:view_all', 'huya:view_assigned'])) {
@@ -127,7 +127,13 @@ 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:assign')) {
weiboItems.push({ key: '/weibo/assignments', label: '分配管理', icon: <SwapOutlined /> });
}
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 /> });
+20
View File
@@ -7,6 +7,7 @@
* 3. **内联编辑必须带 `expected_version`**:别人先改过会 409,绝不静默覆盖(Excel 最痛的点)。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
Alert, App, Badge, Button, Card, Checkbox, Col, Descriptions, Divider, Drawer, Dropdown,
Empty, Form, Image, Input, InputNumber, Modal, Popconfirm, Popover, Progress, Radio, Row, Select, Space, Statistic,
@@ -499,6 +500,8 @@ function ImageSlotEditor({
export default function HuyaCollabPage() {
const { modal } = App.useApp();
const { can, user } = usePermissions();
const location = useLocation();
const navigate = useNavigate();
const canEdit = can('huya:collab:edit');
const canManage = can('huya:collab:manage');
// 单行复制与批量导出共用这个权限点(默认运营和客服都有)
@@ -819,6 +822,23 @@ export default function HuyaCollabPage() {
[runCkPreview],
);
useEffect(() => {
const state = location.state as { transferCk?: string } | null;
const transferCk = state?.transferCk?.trim();
if (!transferCk) return;
setCreateOpen(true);
setCkPreview(null);
setPreviewing(false);
setGiftHint('');
setDraftRestoredAt(null);
previewSeq.current += 1;
createForm.resetFields();
createForm.setFieldsValue({ ck_text: transferCk });
scheduleCkPreview(transferCk, true);
navigate(location.pathname, { replace: true, state: null });
}, [createForm, location.pathname, location.state, navigate, scheduleCkPreview]);
const isArchiveView = filters.archive_scope === 'archived';
const selectedRows = useMemo(
() => rows.filter((row) => selectedIds.includes(row.id)),
+104 -12
View File
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { MouseEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Tooltip, Typography,
Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Spin, Table, Tag, Tooltip, Typography,
} from 'antd';
import type { TableProps } from 'antd';
import {
CheckCircleOutlined, ColumnWidthOutlined, CopyOutlined, GiftOutlined, ImportOutlined, QrcodeOutlined,
ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, ZoomInOutlined,
ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, SwapOutlined, ZoomInOutlined,
} from '@ant-design/icons';
import { huyaApi, type HuyaAccountItem, type HuyaConfig, type HuyaGoodsItem, type HuyaRechargeGoodsItem, type HuyaTaskItem } from '../api/modules';
import HuyaExternalCkImportModal from '../components/HuyaExternalCkImportModal';
@@ -22,6 +23,7 @@ const SCOPE = 'elite' as const;
const ACT_ID = 25135;
const DEFAULT_SID = '2203';
const LAYOUT_KEY = 'huya_elite_layout_mode';
const PAGE_SIZE_KEY = 'huya_elite_page_size';
// 开通/充值:记住上次选的商品与数量(浏览器本地,换台机器不影响)
const RECHARGE_SPU_KEY = 'huya_elite_recharge_spu';
const RECHARGE_COUNT_KEY = 'huya_elite_recharge_count';
@@ -63,6 +65,12 @@ function savedInterval(): number {
return Number.isInteger(value) && value >= 5 && value <= 60 ? value : 15;
}
/** 账号表格每页条数:用户改过一次后就记住,默认与账号管理页一致为 20。 */
function savedPageSize(): number {
const value = Number(localStorage.getItem(PAGE_SIZE_KEY));
return Number.isInteger(value) && value > 0 && value <= 200 ? value : 20;
}
/** 上次选的开通商品(列表里已不存在时会自动回落到第一个)。 */
function savedRechargeSpu(): string {
return localStorage.getItem(RECHARGE_SPU_KEY) || '';
@@ -99,6 +107,11 @@ function resultText(task: HuyaTaskItem | undefined, key: string): string {
return value == null ? '' : String(value);
}
/** mini_qrcode_image 可能存裸 base64,也可能存 livelink 返回的 data URI;统一成 img src。 */
function miniQrcodeSrc(image: string): string {
return image.startsWith('data:image/') ? image : `data:image/png;base64,${image}`;
}
/** 开通支付码是否还需要扫码:已支付或已确认开通就不用再展示了 */
function needsScanPay(task: HuyaTaskItem): boolean {
const result = task.result || {};
@@ -162,9 +175,11 @@ function taskQrUrl(task: HuyaTaskItem | undefined): string {
}
export default function HuyaElitePage() {
const navigate = useNavigate();
const { can } = usePermissions();
const canConfig = can('huya:config');
const canImport = can('huya:import');
const canImport = can('huya:import') || can('huya:elite:import_ck');
const canTransferCollab = can('huya:collab:edit');
const [pool, setPool] = useState<HuyaAccountItem[]>([]);
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
@@ -178,7 +193,10 @@ export default function HuyaElitePage() {
// 开通/充值数量:一个账号按这个份数下单(后端 payload.count,1~10000),记住上次填的值
const [rechargeCount, setRechargeCount] = useState(savedRechargeCount);
const [search, setSearch] = useState('');
const [pageSize, setPageSize] = useState(savedPageSize);
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false);
const [transferring, setTransferring] = useState(false);
const [starting, setStarting] = useState(false);
const [concurrency, setConcurrency] = useState(3);
const [refreshInterval, setRefreshInterval] = useState(savedInterval);
@@ -190,6 +208,9 @@ export default function HuyaElitePage() {
const [importTags, setImportTags] = useState<string[]>([]);
const [importSelected, setImportSelected] = useState<number[]>([]);
const [qrTask, setQrTask] = useState<HuyaTaskItem | null>(null);
const [qrTaskLoading, setQrTaskLoading] = useState(false);
// 小程序码图片独立保存:任务轮询只会更新轻量 tasks,不允许覆盖这张大图。
const [qrImage, setQrImage] = useState('');
// 开通支付码:多选账号时可以一次生成多个码,弹窗里逐个展示(按批次收集)
const [payBatchIds, setPayBatchIds] = useState<string[]>([]);
const [payOpen, setPayOpen] = useState(false);
@@ -203,6 +224,8 @@ export default function HuyaElitePage() {
const [configSaving, setConfigSaving] = useState(false);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; accountIds: number[] } | null>(null);
const tasksLoading = useRef(false);
const qrTaskFetchId = useRef<number | null>(null);
const qrImageCacheRef = useRef<Map<number, string>>(new Map());
const logs = useWebSocketLogs();
const load = useCallback(async () => {
@@ -263,6 +286,31 @@ export default function HuyaElitePage() {
setSelectedIds((prev) => prev.filter((id) => saved.account_ids.includes(id)));
};
const transferToCollab = async () => {
if (selectedIds.length !== 1) {
message.warning('一次只能选择一个账号转入外部单协作');
return;
}
const accountId = selectedIds[0];
const account = accounts.find((item) => item.id === accountId);
if (account && account.has_cookie === false) {
message.warning('该账号没有可用 Cookie,不能转入外部单协作');
return;
}
setTransferring(true);
try {
const result = await huyaApi.transferWorkbenchToCollab(accountId);
message.success('已从精英宝典工作台移除,正在打开外部单协作');
navigate('/huya/collab', {
replace: true,
state: { transferCk: result.ck_text },
});
} catch (error) {
setTransferring(false);
message.error(getErrorMessage(error));
}
};
// 开通商品分组:精英宝典(限购主商品,单独一块)与其他商品(币/徽章/令…,带数量那一块)
const rechargeGoodsSorted = [...rechargeGoods].sort(byRechargeOrder);
const eliteRechargeGoods = rechargeGoodsSorted.filter(isEliteHandbookGood);
@@ -330,10 +378,31 @@ export default function HuyaElitePage() {
onTask: (raw) => {
const next = raw as unknown as HuyaTaskItem;
setTasks((prev) => [next, ...prev.filter((item) => item.id !== next.id)].slice(0, 300));
const result = next.result || {};
const result = (next.result || {}) as Record<string, unknown>;
// 支付弹窗只在「生成开通支付码」和点账号行时打开:多选时每个码都会回包,
// 这里再自动弹会把用户刚关掉的弹窗反复顶出来(码会在弹窗里自动补齐)。
if (next.task_type === 'get_bind_qr' && (result.mini_qrcode_image || result.bind_redirect_url)) setQrTask(next);
if (next.task_type === 'get_bind_qr') {
if (result.mini_qrcode_image) {
const directImage = String(result.mini_qrcode_image);
qrImageCacheRef.current.set(next.id, directImage);
setQrImage(directImage);
setQrTask(next);
} else if (result.has_mini_qrcode && qrTaskFetchId.current !== next.id) {
// WS 只送 has_mini_qrcode,真实小程序码体积大,必须按详情接口取。
qrTaskFetchId.current = next.id;
setQrImage('');
setQrTaskLoading(true);
setQrTask(next);
void huyaApi.getTask(next.id).then((detail) => {
const image = resultText(detail, 'mini_qrcode_image');
if (image) {
qrImageCacheRef.current.set(detail.id, image);
setQrImage(image);
}
setQrTask((current) => (current && current.id === detail.id ? detail : current));
}).catch(() => {}).finally(() => setQrTaskLoading(false));
} else if (result.bind_redirect_url) setQrTask(next);
}
},
onResult: () => { setActiveBatches((prev) => prev.filter((id) => id !== created.batch_id)); void load(); },
});
@@ -382,14 +451,37 @@ export default function HuyaElitePage() {
};
const openQrTask = async (task: HuyaTaskItem) => {
const currentImage = resultText(task, 'mini_qrcode_image');
const cachedImage = currentImage || qrImageCacheRef.current.get(task.id) || '';
// 图片是独立 state,轮询/任务对象刷新不会把它清掉。
setQrImage(cachedImage);
setQrTask(task);
try { setQrTask(await huyaApi.getTask(task.id)); } catch { /* 使用列表结果 */ }
if (currentImage) {
qrImageCacheRef.current.set(task.id, currentImage);
return;
}
if (cachedImage) return;
if (task.result?.has_mini_qrcode !== true) return;
setQrTaskLoading(true);
try {
const detail = await huyaApi.getTask(task.id);
const image = resultText(detail, 'mini_qrcode_image');
if (image) {
qrImageCacheRef.current.set(detail.id, image);
setQrImage((current) => current || image);
}
setQrTask((current) => (current && current.id === detail.id ? detail : current));
} catch { /* 使用列表结果 */ } finally { setQrTaskLoading(false); }
};
const filteredAccounts = useMemo(() => {
const value = search.trim().toLowerCase();
return accounts.filter((item) => !value || [item.uid, item.nickname, item.username, item.tag, item.game_name, item.game_phone].some((v) => String(v || '').toLowerCase().includes(value)));
}, [accounts, search]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(filteredAccounts.length / pageSize));
if (currentPage > totalPages) setCurrentPage(totalPages);
}, [currentPage, filteredAccounts.length, pageSize]);
const filteredGoods = useMemo(() => goods.map((item) => ({ ...item, label: `${item.name} / ${item.price ?? '-'}分 / ${item.remain_text || '动态库存'}` })), [goods]);
// 精英宝典:通常只有一个商品;换过 SKU 或已下线时回落到当前的第一个(并记住回落结果)
useEffect(() => {
@@ -449,7 +541,7 @@ export default function HuyaElitePage() {
{ title: '精英宝典', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => <Space direction="vertical" align="center" size={0} style={{ width: '100%' }}><Tag color={row.elite_opened === true ? 'success' : row.elite_opened === false ? 'default' : undefined}>{row.elite_opened === true ? '已开通' : row.elite_opened === false ? '未开通' : '未查询'}</Tag>{row.elite_opened === true && row.elite_open_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`开通 ${formatUnixSeconds(row.elite_open_time)}`}</Text> : null}{row.elite_expire_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`有效期至 ${formatUnixSeconds(row.elite_expire_time)}`}</Text> : null}</Space> },
{ title: '积分', dataIndex: 'points', width: '7%', align: 'center', render: (value: number | null) => value ?? <Text type="secondary">-</Text> },
{ title: '最近操作', width: '25%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const expired = ckExpired(task, row); return task ? <Space direction="vertical" align="center" size={1} style={{ width: '100%' }}><Text strong ellipsis>{TASK_LABELS[task.task_type] || task.task_type}</Text><Text type={expired ? 'danger' : 'secondary'} ellipsis title={task.message}>{task.message || '-'}</Text><Text type="secondary" style={{ fontSize: 12 }}>{formatTime(task.finished_at || task.created_at)}</Text></Space> : <Text type="secondary">暂无操作</Text>; } },
{ title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (!task) return <Text type="secondary">-</Text>; const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (!url && !image) return <Text type="secondary">-</Text>; const doneLabel = qrDoneLabel(task); if (doneLabel) return <Text type="secondary">{doneLabel}</Text>; return <div onClick={(event) => { event.stopPropagation(); if (!task) return; if (task.task_type === 'create_recharge_order') setZoomPayTask(task); else void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 3, borderRadius: 4 }} title="点击放大扫码">{image ? <img src={`data:image/png;base64,${image}`} alt="二维码" style={{ width: 52, height: 52, objectFit: 'contain' }} /> : <QRCode value={url} size={52} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>; } },
{ title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (!task) return <Text type="secondary">-</Text>; const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (task.task_type === 'get_bind_qr' && !image) { const hasMini = task.result?.has_mini_qrcode === true; if (!hasMini) return <Text type="secondary">-</Text>; return <div onClick={(event) => { event.stopPropagation(); void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 6, borderRadius: 4 }} title="点击查看小程序码"><QrcodeOutlined style={{ fontSize: 26, color: '#000' }} /></div>; } if (!url && !image) return <Text type="secondary">-</Text>; const doneLabel = qrDoneLabel(task); if (doneLabel) return <Text type="secondary">{doneLabel}</Text>; return <div onClick={(event) => { event.stopPropagation(); if (!task) return; if (task.task_type === 'create_recharge_order') setZoomPayTask(task); else void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 3, borderRadius: 4 }} title="点击放大扫码">{image ? <img src={miniQrcodeSrc(image)} alt="二维码" style={{ width: 52, height: 52, objectFit: 'contain' }} /> : <QRCode value={url} size={52} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>; } },
{ title: '状态', width: '7%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (ckExpired(task, row)) return <Tooltip title={`${resultText(task, 'ck_expired_reason') || '角色信息查不到'} —— 请重新登录或重新导入 Cookie`}><Tag color="error">CK 失效</Tag></Tooltip>; const status = task?.status || row.status || ''; return <Tag color={TASK_COLORS[status] || 'default'}>{TASK_STATUS_LABELS[status] || status || '待操作'}</Tag>; } },
];
@@ -465,7 +557,7 @@ export default function HuyaElitePage() {
</Space>
);
const accountsBody = <><Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between' }} wrap><Input size="small" allowClear prefix={<SearchOutlined />} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => setSearch(event.target.value)} style={{ width: 240 }} /><Space><Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>{canImport && <Button size="small" icon={<ImportOutlined />} onClick={() => setCkImportOpen(true)}>导入外部 CK</Button>}{selectedIds.length > 0 && <Button size="small" danger onClick={() => void saveAccounts(accounts.map((item) => item.id).filter((id) => !selectedIds.includes(id)))}>移出选中</Button>}</Space></Space><div className="huya-account-table"><Table rowKey="id" size="small" loading={loading} tableLayout="fixed" rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(Number)) }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }} scroll={{ y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (!task?.result) return; if (task.task_type === 'get_bind_qr') void openQrTask(task); else if (task.task_type === 'create_recharge_order') openPayTasks(task.batch_id); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} /></div></>;
const accountsBody = <><Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between' }} wrap><Input size="small" allowClear prefix={<SearchOutlined />} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => { setSearch(event.target.value); setCurrentPage(1); }} style={{ width: 240 }} /><Space><Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>{canImport && <Button size="small" icon={<ImportOutlined />} onClick={() => setCkImportOpen(true)}>导入外部 CK</Button>}{selectedIds.length > 0 && <Button size="small" danger onClick={() => void saveAccounts(accounts.map((item) => item.id).filter((id) => !selectedIds.includes(id)))}>移出选中</Button>}{canTransferCollab && selectedIds.length === 1 && <Button size="small" icon={<SwapOutlined />} loading={transferring} onClick={() => void transferToCollab()}>转入外部单协作</Button>}</Space></Space><div className="huya-account-table"><Table rowKey="id" size="small" loading={loading} tableLayout="fixed" rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(Number)), preserveSelectedRowKeys: true }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ current: currentPage, pageSize, total: filteredAccounts.length, showSizeChanger: true, showTotal: (total) => `共 ${total} 条`, onChange: (page, size) => { setCurrentPage(page); if (size !== pageSize) { setPageSize(size); localStorage.setItem(PAGE_SIZE_KEY, String(size)); setCurrentPage(1); } } }} scroll={{ y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (!task?.result) return; if (task.task_type === 'get_bind_qr') void openQrTask(task); else if (task.task_type === 'create_recharge_order') openPayTasks(task.batch_id); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} /></div></>;
const configField = (key: Exclude<keyof HuyaConfig, 'updated_at'>, label: string) => configDraft && <Space key={key} style={{ width: '100%', justifyContent: 'space-between' }}><Text>{label}</Text><Input value={configDraft[key] || ''} onChange={(event) => setConfigDraft({ ...configDraft, [key]: event.target.value })} style={{ width: 300 }} /></Space>;
const contextMenuStyle = contextMenu ? {
@@ -475,14 +567,14 @@ export default function HuyaElitePage() {
top: Math.max(8, Math.min(contextMenu.y, window.innerHeight - 330)),
} : undefined;
const tableStyle = `.huya-operation-section{border:1px solid rgba(128,128,128,.22);border-radius:6px;padding:8px}.huya-operation-title{display:flex;gap:6px;align-items:center;font-weight:600;margin-bottom:8px}.huya-action-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}.huya-account-table{flex:1;min-height:0;overflow:hidden}.huya-account-table .ant-table-wrapper,.huya-account-table .ant-spin-nested-loading,.huya-account-table .ant-spin-container{height:100%}.huya-account-table .ant-table{height:100%;display:flex;flex-direction:column}.huya-account-table .ant-table-container{flex:1;min-height:0;display:flex;flex-direction:column}.huya-account-table .ant-table-header{flex-shrink:0}.huya-account-table .ant-table-body{flex:1;min-height:0;overflow:auto !important}.huya-account-table .ant-table-thead > tr > th,.huya-account-table .ant-table-tbody > tr > td{padding:4px 8px;line-height:20px}.huya-account-table .ant-typography{font-size:12px}.huya-account-table .ant-tag{font-size:11px;line-height:18px;margin-inline-end:0;padding-inline:5px}.huya-account-table .ant-pagination{flex-shrink:0;margin:0;padding:6px 4px 2px;border-top:1px solid rgba(128,128,128,.22)}`;
const tableStyle = `.huya-operation-section{border:1px solid rgba(128,128,128,.22);border-radius:6px;padding:8px}.huya-operation-title{display:flex;gap:6px;align-items:center;font-weight:600;margin-bottom:8px}.huya-action-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}.huya-account-table{flex:1;min-height:0;display:flex;overflow:hidden}.huya-account-table .ant-table-wrapper,.huya-account-table .ant-spin,.huya-account-table .ant-spin-nested-loading,.huya-account-table .ant-spin-container{flex:1 1 auto;min-width:0;min-height:0;display:flex;flex-direction:column}.huya-account-table .ant-table{flex:1 1 auto;min-height:0;display:flex;flex-direction:column}.huya-account-table .ant-table-container{flex:1 1 auto;min-height:0;display:flex;flex-direction:column}.huya-account-table .ant-table-header{flex:0 0 auto}.huya-account-table .ant-table-body{flex:1 1 auto;min-height:0;max-height:none !important;overflow:auto !important}.huya-account-table .ant-table-thead > tr > th,.huya-account-table .ant-table-tbody > tr > td{padding:4px 8px;line-height:20px}.huya-account-table .ant-typography{font-size:12px}.huya-account-table .ant-tag{font-size:11px;line-height:18px;margin-inline-end:0;padding-inline:5px}.huya-account-table .ant-pagination{flex:0 0 auto;margin:0;padding:6px 4px 2px;border-top:1px solid rgba(128,128,128,.22)}`;
return <div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}><style>{tableStyle}</style>
<Space style={{ justifyContent: 'space-between', width: '100%', marginBottom: 8, flexShrink: 0 }}><div><h2 style={{ margin: 0 }}>虎牙精英宝典工作台</h2><Text type="secondary">绑定、开通、积分与兑换任务</Text></div><Space><Tooltip title={layoutMode === 'split' ? '切换为上下布局' : '切换为左右布局'}><Button icon={<ColumnWidthOutlined />} onClick={() => { const next = layoutMode === 'split' ? 'stack' : 'split'; setLayoutMode(next); localStorage.setItem(LAYOUT_KEY, next); }} /></Tooltip><Button icon={<ReloadOutlined />} onClick={() => void load()} loading={loading}>刷新</Button>{canConfig && <Button icon={<SettingOutlined />} onClick={() => void openSettings()}>设置</Button>}{!!activeBatches.length && <Button danger icon={<StopOutlined />} onClick={() => void stop()}>停止</Button>}</Space></Space>
{layoutMode === 'split' ? <div style={{ flex: 1, minHeight: 0, display: 'flex', gap: 12, overflow: 'hidden' }}><Card size="small" title="账号" extra={<Tag color="blue">已选 {selectedIds.length}/{accounts.length}</Tag>} style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}>{accountsBody}</Card><Card size="small" title="精英宝典操作" extra={<Space size={4}><Text type="secondary">并发</Text><InputNumber size="small" min={1} max={10} value={concurrency} onChange={(value) => setConcurrency(value || 1)} style={{ width: 58 }} /></Space>} style={{ width: 360, flexShrink: 0, minHeight: 0, overflow: 'hidden' }} styles={{ body: { padding: 8, overflowY: 'auto', height: 'calc(100% - 38px)' } }}>{operationBody}</Card></div> : <><Card size="small" title="精英宝典操作" extra={<Space size={4}><Text type="secondary">并发</Text><InputNumber size="small" min={1} max={10} value={concurrency} onChange={(value) => setConcurrency(value || 1)} style={{ width: 58 }} /></Space>} style={{ flexShrink: 0, marginBottom: 12 }} styles={{ body: { padding: 8 } }}>{operationBody}</Card><Card size="small" title="账号" extra={<Tag color="blue">已选 {selectedIds.length}/{accounts.length}</Tag>} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}>{accountsBody}</Card></>}
<Text type="secondary" style={{ fontSize: 12, marginTop: 6 }}>兑换任务 {exchangeCount} 条;勾选账号后可批量执行,账号行支持右键操作。</Text>
{contextMenu && <div style={contextMenuStyle} onClick={(event) => event.stopPropagation()}><Card size="small" title={contextMenu.accountIds.length > 1 ? '批量动作' : '账号动作'} styles={{ body: { padding: 4 } }} style={{ width: 220, boxShadow: '0 4px 12px rgba(0,0,0,0.14)' }}><Space direction="vertical" style={{ width: '100%' }}>{HUYA_QUICK_ACTIONS.map((item) => <Button key={item.key} block size="small" type="text" icon={item.icon} onClick={() => runContextAction(item.key)} disabled={starting} style={{ justifyContent: 'flex-start' }}>{TASK_LABELS[item.key] || item.key}</Button>)}</Space></Card></div>}
<Modal open={qrTask !== null} title="绑定二维码" footer={null} onCancel={() => setQrTask(null)} centered>{qrTask && <div style={{ textAlign: 'center' }}>{resultText(qrTask, 'mini_qrcode_image') ? <img src={`data:image/png;base64,${resultText(qrTask, 'mini_qrcode_image')}`} alt="绑定二维码" style={{ width: 240, height: 240 }} /> : <QRCode value={resultText(qrTask, 'bind_redirect_url') || 'https://zt.huya.com/b02faae1/pc/index.html'} size={240} color="#000" bgColor="#fff" />}<p><Tag color={TASK_COLORS[qrTask.status] || 'default'}>{qrTask.message || qrTask.status}</Tag></p></div>}</Modal>
<Modal open={qrTask !== null} title="绑定二维码" footer={null} onCancel={() => { setQrTask(null); setQrImage(''); }} centered>{qrTask && <div style={{ textAlign: 'center' }}>{qrImage ? <img src={miniQrcodeSrc(qrImage)} alt="绑定二维码" style={{ width: 240, height: 240, background: '#fff', padding: 12, boxSizing: 'border-box', borderRadius: 8 }} /> : qrTask.result?.has_mini_qrcode === true ? <div style={{ width: 240, height: 240, display: 'grid', placeItems: 'center', background: '#fff', borderRadius: 8 }}>{qrTaskLoading ? <Spin size="large" /> : <Text type="secondary">正在获取小程序码…</Text>}</div> : <QRCode value={resultText(qrTask, 'bind_redirect_url') || 'https://zt.huya.com/b02faae1/pc/index.html'} size={240} color="#000" bgColor="#fff" />}<p><Tag color={TASK_COLORS[qrTask.status] || 'default'}>{qrTask.message || qrTask.status}</Tag></p></div>}</Modal>
<Modal
open={payOpen}
title={payTasks.length ? `开通支付码(${payPendingTasks.length} 个待扫码 · 已完成 ${payDoneTasks.length}/${payTasks.length})` : '开通支付码'}
@@ -498,7 +590,7 @@ export default function HuyaElitePage() {
const qrValue = payQrValue(task);
return (
<div key={task.id} style={{ border: '1px solid rgba(128,128,128,.22)', borderRadius: 6, padding: 10, textAlign: 'center' }}>
<div style={{ background: '#fff', padding: 6, borderRadius: 6, lineHeight: 0, display: 'inline-block' }}>{resultText(task, 'mini_qrcode_image') ? <img src={`data:image/png;base64,${resultText(task, 'mini_qrcode_image')}`} alt="支付码" style={{ width: 150, height: 150, objectFit: 'contain' }} /> : <QRCode value={qrValue} size={150} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>
<div style={{ background: '#fff', padding: 6, borderRadius: 6, lineHeight: 0, display: 'inline-block' }}>{resultText(task, 'mini_qrcode_image') ? <img src={miniQrcodeSrc(resultText(task, 'mini_qrcode_image'))} alt="支付码" style={{ width: 150, height: 150, objectFit: 'contain' }} /> : <QRCode value={qrValue} size={150} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>
<div><Text strong ellipsis>{payTaskLabel(task)}</Text></div>
<Text type="secondary" style={{ fontSize: 12 }}>UID {task.account_uid || '-'}</Text>
<div><Text type="secondary" style={{ fontSize: 12 }}>{[resultText(task, 'product_name') || '精英宝典', Number(resultText(task, 'count')) > 1 ? `×${resultText(task, 'count')}` : '', resultText(task, 'amount_text')].filter(Boolean).join(' ')}</Text></div>
@@ -535,7 +627,7 @@ export default function HuyaElitePage() {
</div>}
</Modal>
<Modal open={importOpen} title="导入精英宝典账号" onCancel={() => setImportOpen(false)} onOk={() => { void saveAccounts([...new Set([...accounts.map((item) => item.id), ...importSelected])]).then(() => { setImportSelected([]); setImportOpen(false); }).catch((error) => message.error(getErrorMessage(error))); }} okText="导入" cancelText="取消"><Input.Search allowClear placeholder="搜索账号" value={importSearch} onChange={(event) => setImportSearch(event.target.value)} style={{ marginBottom: 8 }} /><Select allowClear placeholder="标签" value={importTag || undefined} onChange={(value) => setImportTag(value || '')} options={importTags.map((tag) => ({ value: tag, label: tag }))} style={{ width: '100%', marginBottom: 8 }} /><Table rowKey="id" size="small" dataSource={pool.filter((item) => !accounts.some((current) => current.id === item.id) && (!importSearch || [item.uid, item.nickname, item.username].some((value) => String(value || '').includes(importSearch))) && (!importTag || item.tag === importTag))} columns={[{ title: '账号', render: (_value, row) => row.nickname || row.username || row.uid }, { title: '标签', dataIndex: 'tag' }]} pagination={{ pageSize: 8 }} rowSelection={{ selectedRowKeys: importSelected, onChange: (keys) => setImportSelected(keys.map(Number)) }} /></Modal>
<Modal open={configOpen} title="精英宝典设置" onCancel={() => setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={500}>{configLoading ? <div style={{ padding: 24, textAlign: 'center' }}>加载中...</div> : configDraft && <Space direction="vertical" size={10} style={{ width: '100%' }}>{configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}<Space style={{ width: '100%', justifyContent: 'space-between' }}><Text>空闲轮询间隔(秒)</Text><InputNumber min={5} max={60} value={refreshInterval} onChange={(value) => { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} /></Space></Space>}</Modal>
<Modal open={configOpen} title="精英宝典设置" onCancel={() => setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={500}>{configLoading ? <div style={{ padding: 24, textAlign: 'center' }}>加载中...</div> : configDraft && <Space direction="vertical" size={10} style={{ width: '100%' }}>{configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('bind_gid', '绑定 gid')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}<Space style={{ width: '100%', justifyContent: 'space-between' }}><Text>空闲轮询间隔(秒)</Text><InputNumber min={5} max={60} value={refreshInterval} onChange={(value) => { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} /></Space></Space>}</Modal>
<HuyaExternalCkImportModal open={ckImportOpen} onClose={() => setCkImportOpen(false)} tags={importTags} scope={SCOPE} onImported={(result) => { localStorage.setItem('huya_elite_workbench_account_ids', JSON.stringify(result.account_ids)); void load(); }} />
</div>;
}
+5
View File
@@ -1836,6 +1836,11 @@ export default function HuyaTasksPage() {
<Input placeholder="9504" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="绑定 gid" name="bind_gid">
<Input placeholder="3203" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="绑定 bActId" name="bind_act_id">
<Input placeholder="9271" />
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,625 @@
import { useCallback, useEffect, useState } from 'react';
import {
Badge,
Button,
Card,
Col,
Input,
Popconfirm,
Row,
Select,
Space,
Statistic,
Table,
Tabs,
Tag,
Tooltip,
Typography,
theme,
} from 'antd';
import type { TableProps } from 'antd';
import {
CheckCircleOutlined,
ClearOutlined,
ReloadOutlined,
SwapOutlined,
TeamOutlined,
UserOutlined,
UsergroupAddOutlined,
} from '@ant-design/icons';
import {
weiboApi,
type AssignmentsSummary,
type PaginatedResponse,
type SupportUserItem,
type WeiboAccountItem,
} from '../api/modules';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
import { formatTime } from '../utils/time';
const { Text } = Typography;
const STORAGE_KEY_SELECTED_USER = 'weibo_assignments_selected_user_id';
const STORAGE_KEY_PAGE_SIZE = 'weibo_assignment_page_size';
const CK_STATUS: Record<string, { label: string; color: string }> = {
valid: { label: '有效', color: 'success' },
invalid: { label: '失效', color: 'error' },
error: { label: '查询失败', color: 'warning' },
};
function renderBindStatus(account: WeiboAccountItem) {
if (account.is_bound === true && account.can_rebind === true) {
return <Tag color="processing">可换绑</Tag>;
}
if (account.is_bound === true) {
const days = account.rebind_remain_days ?? 0;
return <Tag color="success">{days > 0 ? `${days} 天后可换绑` : '已绑定'}</Tag>;
}
if (account.is_bound === false) return <Tag color="warning">未绑定</Tag>;
return <Tag>未查询</Tag>;
}
export default function WeiboAssignmentsPage() {
const { token } = theme.useToken();
const [summary, setSummary] = useState<AssignmentsSummary | null>(null);
const [supportUsers, setSupportUsers] = useState<SupportUserItem[]>([]);
const [selectedUser, setSelectedUser] = useState<SupportUserItem | null>(null);
const [accounts, setAccounts] = useState<WeiboAccountItem[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [tableLoading, setTableLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [assigning, setAssigning] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [knownTags, setKnownTags] = useState<string[]>([]);
const [searchText, setSearchText] = useState('');
const [filterTag, setFilterTag] = useState<string | undefined>();
const [enabledFilter, setEnabledFilter] = useState<string>('');
const [activeTab, setActiveTab] = useState<'unassigned' | 'assigned'>('unassigned');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEY_PAGE_SIZE);
return saved ? Number(saved) || 15 : 15;
});
const loadSummary = useCallback(async (silent = false) => {
try {
const data = await weiboApi.assignmentsSummary();
setSummary(data);
setSupportUsers(data.support_users);
return data.support_users;
} catch (error) {
if (!silent) message.error(getErrorMessage(error));
return [];
}
}, []);
const loadTags = useCallback(async () => {
try {
const result = await weiboApi.accountTags();
setKnownTags(result.tags);
} catch {
// 标签字典失败不影响分配列表。
}
}, []);
const loadAccounts = useCallback(
async (silent = false) => {
if (activeTab === 'assigned' && !selectedUser) return;
if (!silent) setTableLoading(true);
try {
const data: PaginatedResponse<WeiboAccountItem> = await weiboApi.listAccounts({
page: currentPage,
page_size: pageSize,
search: searchText.trim() || undefined,
tag: filterTag,
enabled:
enabledFilter === 'enabled'
? true
: enabledFilter === 'disabled'
? false
: undefined,
assign_status: activeTab === 'unassigned' ? 'unassigned' : undefined,
assigned_user_id:
activeTab === 'assigned' ? selectedUser?.id : undefined,
});
setAccounts(data.items);
setTotalCount(data.total);
} catch (error) {
if (!silent) message.error(getErrorMessage(error));
} finally {
if (!silent) setTableLoading(false);
}
},
[
activeTab,
currentPage,
enabledFilter,
filterTag,
pageSize,
searchText,
selectedUser,
],
);
useEffect(() => {
const initialize = async () => {
void loadTags();
const users = await loadSummary();
if (users.length > 0) {
const savedId = localStorage.getItem(STORAGE_KEY_SELECTED_USER);
const savedUser = savedId
? users.find((user) => user.id === Number(savedId))
: undefined;
setSelectedUser(savedUser || users[0]);
}
};
void initialize();
}, [loadSummary, loadTags]);
useEffect(() => {
void loadAccounts();
}, [loadAccounts]);
const refreshData = async (silent = false) => {
await Promise.all([loadSummary(silent), loadAccounts(silent)]);
};
const handleRefresh = async () => {
setRefreshing(true);
try {
await refreshData();
} finally {
setRefreshing(false);
}
};
const handleSelectUser = (user: SupportUserItem) => {
setSelectedUser(user);
setActiveTab('unassigned');
setSelectedRowKeys([]);
setCurrentPage(1);
localStorage.setItem(STORAGE_KEY_SELECTED_USER, String(user.id));
};
const handlePageChange = (page: number, size: number) => {
setCurrentPage(page);
if (size !== pageSize) {
setPageSize(size);
localStorage.setItem(STORAGE_KEY_PAGE_SIZE, String(size));
setCurrentPage(1);
}
};
const handleBatchAssign = async () => {
if (!selectedUser || selectedRowKeys.length === 0) {
message.warning('请先选择客服和微博账号');
return;
}
setAssigning(true);
try {
const result = await weiboApi.batchAssign(selectedRowKeys, selectedUser.id);
message.success(
result.message || `已将 ${selectedRowKeys.length} 个账号分配给 ${selectedUser.username}`,
);
setSelectedRowKeys([]);
await refreshData();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setAssigning(false);
}
};
const handleAssignAllFiltered = async () => {
if (!selectedUser) {
message.warning('请先选择客服');
return;
}
setAssigning(true);
try {
const result = await weiboApi.listAccountIds({
search: searchText.trim() || undefined,
tag: filterTag,
enabled:
enabledFilter === 'enabled'
? true
: enabledFilter === 'disabled'
? false
: undefined,
assign_status: 'unassigned',
});
if (result.account_ids.length === 0) {
message.info('当前筛选下没有未分配账号');
return;
}
await weiboApi.batchAssign(result.account_ids, selectedUser.id);
message.success(
`已将 ${result.account_ids.length} 个账号分配给 ${selectedUser.username}`,
);
setSelectedRowKeys([]);
await refreshData();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setAssigning(false);
}
};
const handleBatchUnassign = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择微博账号');
return;
}
setAssigning(true);
try {
await weiboApi.batchAssign(selectedRowKeys, null);
message.success(`已取消 ${selectedRowKeys.length} 个账号的分配`);
setSelectedRowKeys([]);
await refreshData();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setAssigning(false);
}
};
const selectedUserCount =
supportUsers.find((user) => user.id === selectedUser?.id)?.assigned_count ??
selectedUser?.assigned_count ??
0;
const unassignedCount = summary?.unassigned_count ?? 0;
const assignedCount = summary?.assigned_count ?? 0;
const accountCount = summary?.total_count ?? assignedCount + unassignedCount;
const columns: TableProps<WeiboAccountItem>['columns'] = [
{
title: '序号',
key: 'index',
width: 58,
align: 'center',
render: (_, __, index) => (currentPage - 1) * pageSize + index + 1,
},
{
title: '微博账号',
key: 'account',
width: 190,
align: 'center',
render: (_, account) => (
<Space orientation="vertical" size={0}>
<Text strong>{account.nickname || account.uid}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
UID {account.uid}
</Text>
</Space>
),
},
{
title: '标签',
dataIndex: 'tag',
width: 100,
align: 'center',
render: (tag: string) =>
tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>,
},
{
title: 'CK 状态',
dataIndex: 'ck_check_status',
width: 100,
align: 'center',
render: (status: string) => {
const state = CK_STATUS[status] || { label: '未检测', color: 'default' };
return <Tag color={state.color}>{state.label}</Tag>;
},
},
{
title: '角色绑定',
key: 'bind',
width: 120,
align: 'center',
render: (_, account) => renderBindStatus(account),
},
{
title: '微豆',
dataIndex: 'weidou_balance',
width: 90,
align: 'center',
render: (value: number | null) =>
value == null ? <Text type="secondary">-</Text> : value.toLocaleString(),
},
{
title: '生命周期',
dataIndex: 'is_enabled',
width: 90,
align: 'center',
render: (enabled: boolean) => (
<Tag color={enabled ? 'success' : 'default'}>
{enabled ? '已启用' : '已停用'}
</Tag>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 155,
align: 'center',
render: (value: string | null) =>
value ? formatTime(value) : <Text type="secondary">-</Text>,
},
];
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ margin: 0 }}>微博分配管理</h2>
<Text type="secondary">管理员和运营查看全部账号;客服只能看到分配给自己的账号。</Text>
</div>
<Button icon={<ReloadOutlined />} loading={refreshing} onClick={handleRefresh}>
刷新
</Button>
</div>
<Row gutter={12} style={{ flexShrink: 0 }}>
<Col xs={12} lg={6}>
<Card size="small">
<Statistic title="微博账号" value={accountCount} prefix={<TeamOutlined />} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small">
<Statistic
title="已分配"
value={assignedCount}
styles={{ content: { color: token.colorSuccess } }}
prefix={<CheckCircleOutlined />}
/>
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small">
<Statistic
title="未分配"
value={unassignedCount}
styles={{ content: { color: token.colorError } }}
prefix={<SwapOutlined />}
/>
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small">
<Statistic
title="客服人数"
value={supportUsers.length}
prefix={<UsergroupAddOutlined />}
/>
</Card>
</Col>
</Row>
<Row gutter={12} style={{ flex: 1, minHeight: 0 }}>
<Col xs={24} lg={8} style={{ height: '100%', display: 'flex', minHeight: 0 }}>
<Card
title="客服列表"
size="small"
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
styles={{ body: { flex: 1, overflow: 'auto', padding: 8 } }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{supportUsers.map((user) => {
const isSelected = selectedUser?.id === user.id;
return (
<div
key={user.id}
onClick={() => handleSelectUser(user)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 14px',
borderRadius: 6,
cursor: 'pointer',
border: `1px solid ${isSelected ? token.colorPrimary : token.colorBorderSecondary}`,
background: isSelected ? token.colorPrimaryBg : token.colorBgContainer,
}}
>
<Space>
<UserOutlined style={{ color: token.colorPrimary }} />
<Text strong>{user.username}</Text>
</Space>
<Tooltip title={`已分配 ${user.assigned_count} 个账号`}>
<Badge
count={user.assigned_count}
overflowCount={999999}
showZero
style={{ backgroundColor: token.colorPrimary }}
/>
</Tooltip>
</div>
);
})}
{supportUsers.length === 0 && (
<Text
type="secondary"
style={{ textAlign: 'center', padding: 20, display: 'block' }}
>
暂无客服用户
</Text>
)}
</div>
</Card>
</Col>
<Col xs={24} lg={16} style={{ height: '100%', display: 'flex', minHeight: 0 }}>
<Card
size="small"
title={
selectedUser ? (
<Space wrap>
<span>已选中客服:</span>
<Tag color="blue" style={{ fontSize: 14, padding: '2px 10px' }}>
{selectedUser.username}(已分配 {selectedUserCount} 个)
</Tag>
</Space>
) : (
<Text type="secondary">请选择左侧客服进行操作</Text>
)
}
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
styles={{
body: {
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
padding: 0,
},
}}
>
<div
style={{
padding: '8px 16px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
flexWrap: 'wrap',
gap: 8,
alignItems: 'center',
flexShrink: 0,
}}
>
<Input.Search
allowClear
placeholder="搜索 UID、昵称、角色或备注"
value={searchText}
onChange={(event) => {
setSearchText(event.target.value);
setCurrentPage(1);
}}
style={{ width: 230 }}
size="small"
/>
<Select
allowClear
placeholder="按标签筛选"
value={filterTag}
onChange={(value) => {
setFilterTag(value);
setCurrentPage(1);
}}
style={{ width: 140 }}
size="small"
options={knownTags.map((tag) => ({ value: tag, label: tag }))}
/>
<Select
value={enabledFilter}
onChange={(value) => {
setEnabledFilter(value);
setCurrentPage(1);
}}
style={{ width: 120 }}
size="small"
options={[
{ value: '', label: '全部状态' },
{ value: 'enabled', label: '已启用' },
{ value: 'disabled', label: '已停用' },
]}
/>
</div>
<Tabs
size="small"
activeKey={activeTab}
onChange={(key) => {
setActiveTab(key as 'unassigned' | 'assigned');
setSelectedRowKeys([]);
setCurrentPage(1);
}}
style={{ padding: '0 16px', flexShrink: 0, marginBottom: 0 }}
items={[
{ key: 'unassigned', label: `未分配 (${unassignedCount})` },
{
key: 'assigned',
label: selectedUser
? `已分配给 ${selectedUser.username} (${selectedUserCount})`
: '已分配账号',
disabled: !selectedUser,
},
]}
/>
<div
style={{
padding: '0 16px 8px',
display: 'flex',
gap: 8,
flexWrap: 'wrap',
flexShrink: 0,
}}
>
{activeTab === 'unassigned' && selectedUser && (
<>
<Button
type="primary"
size="small"
icon={<SwapOutlined />}
loading={assigning}
disabled={selectedRowKeys.length === 0}
onClick={handleBatchAssign}
>
分配选中 ({selectedRowKeys.length}) 给 {selectedUser.username}
</Button>
<Popconfirm
title={`把当前筛选下 ${totalCount} 个未分配账号全部分给 ${selectedUser.username}?`}
okText="确认分配"
cancelText="取消"
disabled={totalCount === 0}
onConfirm={handleAssignAllFiltered}
>
<Button size="small" loading={assigning} disabled={totalCount === 0}>
分配当前筛选全部
</Button>
</Popconfirm>
</>
)}
{activeTab === 'assigned' && selectedUser && (
<Button
danger
size="small"
icon={<ClearOutlined />}
loading={assigning}
disabled={selectedRowKeys.length === 0}
onClick={handleBatchUnassign}
>
取消分配选中 ({selectedRowKeys.length})
</Button>
)}
</div>
<Table<WeiboAccountItem>
columns={columns}
dataSource={accounts}
rowKey="id"
size="small"
loading={tableLoading}
pagination={{
current: currentPage,
pageSize,
total: totalCount,
size: 'small',
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
onChange: handlePageChange,
}}
rowSelection={{
selectedRowKeys,
preserveSelectedRowKeys: true,
onChange: (keys) => setSelectedRowKeys(keys.map(Number)),
}}
scroll={{ x: 940, y: 'calc(100vh - 500px)' }}
/>
</Card>
</Col>
</Row>
</div>
);
}
+165 -83
View File
@@ -4,6 +4,7 @@ import {
AutoComplete,
Button,
Checkbox,
Col,
Descriptions,
Drawer,
Form,
@@ -11,6 +12,7 @@ import {
InputNumber,
Modal,
Popconfirm,
Row,
Select,
Space,
Statistic,
@@ -35,6 +37,7 @@ import {
UndoOutlined,
} from '@ant-design/icons';
import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader';
import HuyaCollabShopPicker from '../components/HuyaCollabShopPicker';
import {
weiboCollabApi,
type WeiboCollabAccountLookup,
@@ -71,6 +74,12 @@ const STATUS_META: Record<WeiboCollabStatus, { label: string; color: string }> =
void: { label: '已作废', color: 'default' },
};
const ACCOUNT_CK_LABELS: Record<string, string> = {
valid: '有效',
invalid: '失效',
error: '查询失败',
};
const STATUS_ORDER: WeiboCollabStatus[] = [
'incomplete',
'to_recharge',
@@ -247,6 +256,7 @@ export default function WeiboCollabPage() {
const accountOptions = accounts.map((item) => ({
value: item.id,
label: `${item.nickname || item.uid} · UID ${item.uid}${item.game_role_name ? ` · ${item.game_role_name}` : ''}`,
title: item.nickname || `UID ${item.uid}`,
}));
const liveGoodsOptions = (lookup?.goods || []).map((item) => ({
@@ -263,7 +273,7 @@ export default function WeiboCollabPage() {
.filter((item) => item.is_active)
.map((item) => ({
value: item.name,
label: `${item.name} · ${item.price} 微豆(字典)`,
label: `${item.name} · ${item.price} 微豆(自定义)`,
goods: {
name: item.name,
price: item.price,
@@ -280,8 +290,8 @@ export default function WeiboCollabPage() {
goods_id: number | null;
goods_image: string;
};
}> = [...liveGoodsOptions];
for (const option of catalogGoodsOptions) {
}> = [...catalogGoodsOptions];
for (const option of liveGoodsOptions) {
if (!goodsOptions.some((item) => item.value === option.value)) goodsOptions.push(option);
}
@@ -569,6 +579,14 @@ export default function WeiboCollabPage() {
);
const columns: TableProps<WeiboCollabOrder>['columns'] = [
{
title: '序号',
key: 'index',
width: 72,
align: 'center',
fixed: 'left',
render: (_, __, index) => (page - 1) * pageSize + index + 1,
},
{
title: '订单编号',
dataIndex: 'order_no',
@@ -748,11 +766,6 @@ export default function WeiboCollabPage() {
<Button icon={<ReloadOutlined />} onClick={refreshAll}>
刷新
</Button>
{canEdit && (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新建协作单
</Button>
)}
</Space>
</div>
@@ -877,6 +890,11 @@ export default function WeiboCollabPage() {
<Button icon={<FileExcelOutlined />} onClick={() => setGoodsOpen(true)}>
商品管理
</Button>
{canEdit && (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新建协作单
</Button>
)}
</Space>
</div>
@@ -944,66 +962,82 @@ export default function WeiboCollabPage() {
<Modal
title={editing ? `编辑协作单 · ${editing.order_no}` : '新建微博外部单'}
open={editOpen}
width={760}
width={920}
confirmLoading={saving}
okText={editing ? '保存' : form.getFieldValue('draft') ? '保存草稿' : '创建并校验'}
cancelText="取消"
onOk={() => void saveOrder()}
onCancel={() => setEditOpen(false)}
styles={{ body: { maxHeight: '78vh', overflowY: 'auto', paddingRight: 12 } }}
destroyOnHidden
>
<Form form={form} layout="vertical">
<Space size={12} style={{ width: '100%' }} align="start">
<Form.Item
name="order_no"
label="订单编号"
rules={[{ required: true, message: '请填写订单编号' }]}
style={{ flex: 1 }}
>
<Input maxLength={64} />
</Form.Item>
<Form.Item name="shop" label="店铺" style={{ flex: 1 }}>
<AutoComplete
options={shops.map((item) => ({ value: item.name }))}
placeholder="可输入新店铺"
filterOption
/>
</Form.Item>
<Form.Item name="owner_id" label="负责人" style={{ width: 150 }}>
<Select
allowClear
options={owners.map((item) => ({ value: item.id, label: item.username }))}
/>
</Form.Item>
</Space>
<Space size={12} style={{ width: '100%' }} align="start">
<Form.Item name="account_id" label="微博账号" style={{ flex: 1 }}>
<Select
allowClear
showSearch
optionFilterProp="label"
options={accountOptions}
loading={lookupLoading}
onChange={(value) => void lookupAccount(value)}
/>
</Form.Item>
<Form.Item name="goods_name" label="商品" style={{ flex: 1 }}>
<AutoComplete
options={goodsOptions}
onChange={handleGoodsChange}
placeholder={lookup ? '选择当前账号商城商品' : '先选账号获取实时商品'}
filterOption
/>
</Form.Item>
<Form.Item
name="required_weidou"
label="所需微豆"
rules={[{ required: true, message: '请填写所需微豆' }]}
style={{ width: 150 }}
>
<InputNumber min={0} precision={0} style={{ width: '100%' }} />
</Form.Item>
</Space>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item
name="order_no"
label="订单编号"
rules={[{ required: true, message: '请填写订单编号' }]}
>
<Input maxLength={64} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="owner_id" label="负责人">
<Select
allowClear
options={owners.map((item) => ({ value: item.id, label: item.username }))}
/>
</Form.Item>
</Col>
</Row>
<Form.Item name="shop" label="店铺">
<HuyaCollabShopPicker
shops={shops}
allowClear={false}
allowCustom
size="large"
/>
</Form.Item>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="account_id" label="微博账号">
<Select
allowClear
showSearch
optionFilterProp="label"
optionLabelProp="title"
options={accountOptions}
loading={lookupLoading}
placeholder="搜索并选择微博账号"
onChange={(value) => void lookupAccount(value)}
/>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="goods_name" label="商品">
<AutoComplete
options={goodsOptions}
onChange={handleGoodsChange}
placeholder={
lookup
? '选择当前账号商城商品'
: '选择自定义商品,或先选账号加载实时商品'
}
filterOption
/>
</Form.Item>
</Col>
<Col xs={24} md={4}>
<Form.Item
name="required_weidou"
label="所需微豆"
rules={[{ required: true, message: '请填写所需微豆' }]}
>
<InputNumber min={0} precision={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="goods_id" hidden>
<InputNumber />
</Form.Item>
@@ -1011,30 +1045,78 @@ export default function WeiboCollabPage() {
<Input />
</Form.Item>
{lookup && (
<Alert
type={lookup.ok ? 'success' : 'warning'}
showIcon
style={{ marginBottom: 16 }}
message={
lookup.ok
? `${lookup.nickname || lookup.uid} · 当前 ${lookup.weidou_balance?.toLocaleString() ?? '-'} 微豆`
: lookup.message || '账号不可用'
}
description={
lookup.orders_using_account.length > 0
? `同账号已有 ${lookup.orders_using_account.length} 单:${lookup.orders_using_account
.map((item) => item.order_no)
.join('、')}`
: undefined
}
/>
<>
<Alert
type={lookup.ok ? 'success' : 'warning'}
showIcon
style={{ marginBottom: 12 }}
message={
lookup.ok
? `${lookup.nickname || lookup.uid} · 当前 ${lookup.weidou_balance?.toLocaleString() ?? '-'} 微豆`
: lookup.message || '账号不可用'
}
/>
{lookup.ok && (
<Descriptions
size="small"
column={3}
style={{ marginBottom: 16 }}
items={[
{ key: 'uid', label: 'UID', children: lookup.uid || '-' },
{
key: 'role',
label: '绑定角色',
children: lookup.game_role_name || '未读取到角色',
},
{
key: 'bind',
label: '绑定状态',
children:
lookup.is_bound === true
? '已绑定'
: lookup.is_bound === false
? '未绑定'
: '未知',
},
{
key: 'ck',
label: 'CK 状态',
children:
ACCOUNT_CK_LABELS[lookup.ck_check_status] ||
lookup.ck_check_status ||
'未检测',
},
{
key: 'orders',
label: '同账号订单',
span: 2,
children:
lookup.orders_using_account.length > 0
? lookup.orders_using_account
.map(
(item) =>
`${item.order_no}(${item.status_label || item.status})`,
)
.join('、')
: '暂无',
},
]}
/>
)}
</>
)}
<Form.Item name="home_images" label="主页截图">
<HuyaCollabHomeImageUploader optional />
</Form.Item>
<Form.Item name="remark" label="备注">
<TextArea rows={3} maxLength={512} showCount />
</Form.Item>
<Row gutter={16}>
<Col xs={24} md={14}>
<Form.Item name="home_images" label="主页截图">
<HuyaCollabHomeImageUploader optional />
</Form.Item>
</Col>
<Col xs={24} md={10}>
<Form.Item name="remark" label="备注">
<TextArea rows={6} maxLength={512} showCount />
</Form.Item>
</Col>
</Row>
{!editing && (
<Form.Item name="draft" valuePropName="checked">
<Checkbox>先保存草稿,不校验账号</Checkbox>