Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a48d9c624 | ||
|
|
b06b622eb3 | ||
|
|
5f1d78e1e4 | ||
|
|
7282787398 | ||
|
|
efd851f3ed | ||
|
|
2292864754 | ||
|
|
84883015b4 | ||
|
|
070a7954e7 | ||
|
|
6b2285913f | ||
|
|
c8b62e5267 | ||
|
|
d566518478 | ||
|
|
5d6325d74c | ||
|
|
a927be36f2 | ||
|
|
0285fc80bb |
@@ -51,3 +51,6 @@ work/
|
||||
|
||||
analysis/
|
||||
artifacts/
|
||||
|
||||
|
||||
hars/
|
||||
@@ -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 导入)与浏览器兑换 |
|
||||
|
||||
## 登录与任务流
|
||||
|
||||
|
||||
@@ -1522,6 +1522,87 @@ class HuyaHttpClient:
|
||||
return self._raw_from_wup(resp) if isinstance(resp, WupResponse) else None
|
||||
|
||||
|
||||
def _bind_game_account_has_role(resp: Any) -> bool:
|
||||
"""响应是否真的带出了角色名(角色名是判断「最新角色」的唯一可靠信号)。"""
|
||||
account_data = getattr(resp, "accountData", None)
|
||||
game_role = getattr(account_data, "gameRole", None)
|
||||
return bool(getattr(game_role, "roleName", ""))
|
||||
|
||||
|
||||
def query_bind_game_account(
|
||||
client: Any,
|
||||
*,
|
||||
uid: int,
|
||||
cookie: str,
|
||||
b_act_id: int,
|
||||
gid: int = 0,
|
||||
outer_act_id: str = "",
|
||||
is_use_outer_act_id: int = 1,
|
||||
scene: str = "",
|
||||
timeout: float | None = None,
|
||||
) -> tuple[Any, dict[str, Any]]:
|
||||
"""查询绑定游戏账号(官方活动页两条查询,返回最新角色)。
|
||||
|
||||
2026-11-09 真机定论:新版活动页先查 ``gid + outerActId``,再查
|
||||
``gid + outerActId=空``;**角色名以第二路为准**(第一路会返回旧的缓存角色)。
|
||||
旧协议(gid=0)第二路会被服务端判 501,保持外层优先、缺角色时补查内层。
|
||||
|
||||
返回 ``(status, detail)``:
|
||||
- ``status``:最终选中的 ``CheckUserBindGameAccountResp``(``None`` 表示接口无响应);
|
||||
- ``detail``:两路响应与来源标记,供任务结果透传(可 JSON 序列化)。
|
||||
"""
|
||||
|
||||
def _call(outer_id: str, use_outer: int):
|
||||
kwargs: dict[str, Any] = {
|
||||
"uid": uid,
|
||||
"cookie": cookie,
|
||||
"b_act_id": b_act_id,
|
||||
"gid": gid,
|
||||
"outer_act_id": outer_id,
|
||||
"is_use_outer_act_id": use_outer,
|
||||
"scene": scene,
|
||||
}
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = timeout
|
||||
return client.check_user_bind_game_account(**kwargs)
|
||||
|
||||
outer = _call(outer_act_id, is_use_outer_act_id)
|
||||
if outer is None:
|
||||
return None, {}
|
||||
detail: dict[str, Any] = {
|
||||
"bind_status_source": "outer",
|
||||
"outer_bind_status": outer.to_dict(),
|
||||
}
|
||||
if outer.status != 200:
|
||||
return outer, detail
|
||||
|
||||
chosen = outer
|
||||
if gid:
|
||||
# 第二路(gid + outerActId=空)是最新角色源,带出角色就优先采用。
|
||||
inner = _call("", 0)
|
||||
if inner is not None:
|
||||
detail["inner_bind_status"] = inner.to_dict()
|
||||
if inner.status == 200 and _bind_game_account_has_role(inner):
|
||||
chosen = inner
|
||||
detail["bind_status_source"] = "inner"
|
||||
else:
|
||||
account_data = getattr(outer, "accountData", None)
|
||||
if not _bind_game_account_has_role(outer) and bool(
|
||||
getattr(account_data, "isNeedActCheck", 0)
|
||||
or not getattr(account_data, "isBindAcount", 0)
|
||||
or not getattr(account_data, "isBindRole", 0)
|
||||
):
|
||||
inner = _call("", 0)
|
||||
if inner is not None:
|
||||
detail["inner_bind_status"] = inner.to_dict()
|
||||
if inner.status == 200 and _bind_game_account_has_role(inner):
|
||||
chosen = inner
|
||||
detail["bind_status_source"] = "inner"
|
||||
|
||||
detail["bind_status"] = chosen.to_dict()
|
||||
return chosen, detail
|
||||
|
||||
|
||||
def _self_check():
|
||||
uid = 1199647239697
|
||||
guid = "0a8973013ad3476a53029ddb9edb071f"
|
||||
|
||||
+2
-1
@@ -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,必须由当前使用人操作。
|
||||
- 无论是否更新,全局账号记录始终只有一条。
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 入口与账号范围
|
||||
|
||||
独立页面:虎牙 → 浏览器兑换。导航按角色区分:客服只看到浏览器兑换一个入口;管理员/运营仍显示完整的虎牙菜单(账号管理、精英宝典等)。
|
||||
独立页面:虎牙 → 浏览器兑换。导航按角色区分:客服默认看到精英宝典与浏览器兑换;管理员/运营仍显示完整的虎牙菜单(账号管理、精英宝典等)。
|
||||
|
||||
- 页面维护一份「兑换账号列表」,通过「导入账号」从自己可见的账号池勾选加入;客服只能看到分配给自己的账号。
|
||||
- 列表按用户隔离保存(工作台 scope `browser_exchange`),与精英宝典的账号集合互不影响。
|
||||
|
||||
@@ -18,8 +18,7 @@ from core.huya.taf_protocol import TafOutputStream, TafType
|
||||
from tests.support.db import get_test_engine, make_test_session
|
||||
from web.backend.deps import require_permission
|
||||
from web.backend.huya_defaults import (
|
||||
HUYA_DEFAULT_BIND_ACT_ID,
|
||||
HUYA_DEFAULT_OUTER_ACT_ID,
|
||||
HUYA_DEFAULT_BIND_GID,
|
||||
)
|
||||
from web.backend.models import (
|
||||
AuditLog,
|
||||
@@ -763,6 +762,13 @@ class FakeBindResp:
|
||||
),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"status": self.status,
|
||||
"msg": self.msg,
|
||||
"game_name": self.gameName,
|
||||
}
|
||||
|
||||
|
||||
class FakeGiftResp:
|
||||
"""合成一份 ``getActPrizeList`` 响应(礼包列表)。"""
|
||||
@@ -883,12 +889,21 @@ class FakeProbeClient:
|
||||
self.calls: list[dict] = []
|
||||
self.gift_calls: list[dict] = []
|
||||
self.points_calls: list[dict] = []
|
||||
self._current_bind_outcome: object | None = None
|
||||
|
||||
def check_user_bind_game_account(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
if not self.outcomes:
|
||||
raise AssertionError("探针被调用的次数超出用例预期")
|
||||
outcome = self.outcomes.pop(0)
|
||||
if kwargs.get("outer_act_id"):
|
||||
# 外层(第一路):按脚本弹一个响应。
|
||||
if not self.outcomes:
|
||||
raise AssertionError("探针被调用的次数超出用例预期")
|
||||
outcome = self.outcomes.pop(0)
|
||||
self._current_bind_outcome = outcome
|
||||
else:
|
||||
# 内层(第二路):复用当前外层响应;需要内外层不同时用自定义 client。
|
||||
outcome = self._current_bind_outcome
|
||||
if outcome is None:
|
||||
raise AssertionError("内层查询在没有外层结果时被调用")
|
||||
if isinstance(outcome, Exception):
|
||||
raise outcome
|
||||
return outcome
|
||||
@@ -947,21 +962,49 @@ class TestCollabCkProbe(TestCollabBase):
|
||||
|
||||
collab.verify_ck(self.db, self.user, order, client=client)
|
||||
|
||||
call = client.calls[0]
|
||||
assert call["uid"] == 1199647239697
|
||||
assert isinstance(call["uid"], int)
|
||||
assert call["cookie"] == order.cookie
|
||||
assert (
|
||||
call["b_act_id"]
|
||||
== collab.PROBE_BIND_ACT_ID
|
||||
== int(HUYA_DEFAULT_BIND_ACT_ID)
|
||||
)
|
||||
assert call["is_use_outer_act_id"] == 1
|
||||
assert (
|
||||
call["outer_act_id"]
|
||||
== collab.PROBE_OUTER_ACT_ID
|
||||
== HUYA_DEFAULT_OUTER_ACT_ID
|
||||
)
|
||||
# 通用查询两路:外层 gid+outerActId,内层 gid+outerActId=空。
|
||||
outer, inner = client.calls[0], client.calls[1]
|
||||
assert outer["uid"] == 1199647239697
|
||||
assert isinstance(outer["uid"], int)
|
||||
assert outer["cookie"] == order.cookie
|
||||
assert outer["b_act_id"] == 0
|
||||
assert outer["gid"] == collab.PROBE_GID == int(HUYA_DEFAULT_BIND_GID)
|
||||
assert outer["outer_act_id"] == collab.PROBE_OUTER_ACT_ID
|
||||
assert outer["is_use_outer_act_id"] == 0
|
||||
assert outer["scene"] == collab.PROBE_SCENE == "undefined"
|
||||
assert inner["gid"] == collab.PROBE_GID == 3203
|
||||
assert inner["outer_act_id"] == ""
|
||||
assert inner["is_use_outer_act_id"] == 0
|
||||
assert inner["scene"] == "undefined"
|
||||
|
||||
def test_verify_queries_second_path_for_latest_role(self):
|
||||
"""外层返回旧角色时,必须取第二路(outerActId=空)的最新角色。"""
|
||||
order = self._row_with_ck(uid="1199647239697")
|
||||
|
||||
class StaleThenLatestClient:
|
||||
def __init__(self):
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def check_user_bind_game_account(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
if kwargs["outer_act_id"]:
|
||||
return FakeBindResp(nick="冰点", role="软芙乎")
|
||||
return FakeBindResp(nick="冰点", role="唉哟酪酪")
|
||||
|
||||
client = StaleThenLatestClient()
|
||||
|
||||
collab.verify_ck(self.db, self.user, order, client=client)
|
||||
|
||||
outer, inner = client.calls[0], client.calls[1]
|
||||
assert outer["outer_act_id"] == collab.PROBE_OUTER_ACT_ID
|
||||
assert inner["outer_act_id"] == ""
|
||||
assert inner["gid"] == collab.PROBE_GID == 3203
|
||||
assert inner["scene"] == "undefined"
|
||||
# 快照落的是第二路的最新角色,而不是外层旧角色
|
||||
assert order.bind_game_role == "唉哟酪酪"
|
||||
assert order.bind_game_account == "冰点"
|
||||
assert "唉哟酪酪" in order.ck_check_message
|
||||
assert "软芙乎" not in order.ck_check_message
|
||||
|
||||
def test_verify_marks_invalid_when_status_is_not_200(self):
|
||||
order = self._row_with_ck()
|
||||
@@ -1241,7 +1284,7 @@ class TestCollabCkProbe(TestCollabBase):
|
||||
# 没 CK 的行保持「未验证」,不能谎报成「已失效」
|
||||
assert without_ck.ck_status == "unverified"
|
||||
assert "CK" in result["results"][1]["message"]
|
||||
assert len(client.calls) == 1 # 没 CK 的行不发请求
|
||||
assert len(client.calls) == 2 # 有 CK 的行两路(外层+内层),没 CK 的行不发请求
|
||||
|
||||
def test_batch_keeps_interval_between_rows(self, monkeypatch):
|
||||
"""人工触发也要串行 + 间隔,别把账号送进风控名单。"""
|
||||
@@ -2134,8 +2177,8 @@ class TestCollabRecharge(TestCollabBase):
|
||||
assert (
|
||||
order.points_check_detail["group_key"] == f"{self.BIND} / {self.ROLE}"
|
||||
)
|
||||
# 同一 uid 只验一次 CK、只查一次积分
|
||||
assert len(client.calls) == 1
|
||||
# 同一 uid 只验一次 CK(两路查询)、只查一次积分
|
||||
assert len(client.calls) == 2
|
||||
assert len(client.score_calls) == 1
|
||||
assert "huya:collab:recharge_check" in [
|
||||
row.action for row in self._audits(first)
|
||||
@@ -3136,7 +3179,7 @@ class TestProbeCache(TestCollabBase):
|
||||
collab.probe_ck(order)
|
||||
collab.probe_ck(order)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert len(calls) == 2 # 第一次探针两路(外层+内层),第二次命中缓存
|
||||
collab._probe_cache.clear()
|
||||
|
||||
def test_different_ck_is_probed_separately(self, monkeypatch):
|
||||
@@ -3155,7 +3198,7 @@ class TestProbeCache(TestCollabBase):
|
||||
collab.probe_ck(first)
|
||||
collab.probe_ck(second)
|
||||
|
||||
assert len(calls) == 2
|
||||
assert len(calls) == 4 # 两个 CK 各自两路(外层+内层)
|
||||
collab._probe_cache.clear()
|
||||
|
||||
|
||||
|
||||
@@ -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}"
|
||||
@@ -134,12 +135,20 @@ class _FakeGameRole:
|
||||
|
||||
|
||||
class _FakeBindAccountData:
|
||||
def __init__(self, nick: str, role_name: str):
|
||||
def __init__(
|
||||
self,
|
||||
nick: str,
|
||||
role_name: str,
|
||||
*,
|
||||
is_bind_account: int = 1,
|
||||
is_bind_role: int = 1,
|
||||
is_need_act_check: int = 0,
|
||||
):
|
||||
self.gameAccount = _FakeGameAccount(nick)
|
||||
self.gameRole = _FakeGameRole(role_name, "苹果(iOS)", "手Q")
|
||||
self.isBindAcount = 1
|
||||
self.isBindRole = 1
|
||||
self.isNeedActCheck = 0
|
||||
self.isBindAcount = is_bind_account
|
||||
self.isBindRole = is_bind_role
|
||||
self.isNeedActCheck = is_need_act_check
|
||||
self.changBindTime = 0
|
||||
|
||||
|
||||
@@ -151,12 +160,22 @@ class _FakeBindResponse:
|
||||
msg: str = "",
|
||||
nick: str = "微信用户",
|
||||
role_name: str = "吃鸡199706",
|
||||
is_bind_account: int = 1,
|
||||
is_bind_role: int = 1,
|
||||
is_need_act_check: int = 0,
|
||||
):
|
||||
self.status = status
|
||||
self.msg = msg
|
||||
self.gameName = "和平精英"
|
||||
self.gameAuthScene = ""
|
||||
self.changeBindDay = 7
|
||||
self.accountData = _FakeBindAccountData(nick, role_name)
|
||||
self.accountData = _FakeBindAccountData(
|
||||
nick,
|
||||
role_name,
|
||||
is_bind_account=is_bind_account,
|
||||
is_bind_role=is_bind_role,
|
||||
is_need_act_check=is_need_act_check,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -787,8 +806,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 +883,218 @@ 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_inner_bind_probe_keeps_gid_and_empties_outer_act_id(monkeypatch):
|
||||
"""官方页面第二路查询必须是 gid=3203 + outerActId='',才能取到角色名。"""
|
||||
calls: list[dict] = []
|
||||
responses = [
|
||||
_FakeBindResponse(role_name="", is_bind_role=0, is_need_act_check=1),
|
||||
_FakeBindResponse(role_name="和平精英角色", is_bind_role=1),
|
||||
]
|
||||
client = _CkProbeClient(None)
|
||||
|
||||
def fake_check(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return responses[len(calls) - 1]
|
||||
|
||||
monkeypatch.setattr(client, "check_user_bind_game_account", fake_check)
|
||||
runner, _marks = _task_runner(monkeypatch, "query_game_name")
|
||||
status, _query = runner._resolve_bind_status(
|
||||
client,
|
||||
UID,
|
||||
COOKIE,
|
||||
b_act_id_int=0,
|
||||
outer_act_id="17096",
|
||||
gid=3203,
|
||||
is_use_outer_act_id=0,
|
||||
scene="undefined",
|
||||
)
|
||||
assert status.accountData.gameRole.roleName == "和平精英角色"
|
||||
assert calls[0]["outer_act_id"] == "17096"
|
||||
assert calls[0]["gid"] == 3203
|
||||
assert calls[1]["outer_act_id"] == ""
|
||||
assert calls[1]["gid"] == 3203
|
||||
|
||||
|
||||
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",
|
||||
}
|
||||
]
|
||||
# 通用查询会连打两路:外层 gid+outerActId=17096,内层 gid+outerActId=空。
|
||||
assert len(client.check_calls) == 4 # 绑定前查一次 + 确认后刷新查一次
|
||||
for call in client.check_calls[0::2]:
|
||||
assert call["b_act_id"] == 0
|
||||
assert call["gid"] == 3203
|
||||
assert call["outer_act_id"] == "17096"
|
||||
assert call["is_use_outer_act_id"] == 0
|
||||
assert call["scene"] == "undefined"
|
||||
for call in client.check_calls[1::2]:
|
||||
assert call["b_act_id"] == 0
|
||||
assert call["gid"] == 3203
|
||||
assert call["outer_act_id"] == ""
|
||||
assert call["is_use_outer_act_id"] == 0
|
||||
assert call["scene"] == "undefined"
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,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")
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)。"""
|
||||
|
||||
|
||||
@@ -30,8 +30,10 @@ from loguru import logger
|
||||
from sqlalchemy import func, or_, tuple_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.huya.http_client import query_bind_game_account
|
||||
|
||||
from ..huya_defaults import (
|
||||
HUYA_DEFAULT_BIND_ACT_ID,
|
||||
HUYA_DEFAULT_BIND_GID,
|
||||
HUYA_DEFAULT_OUTER_ACT_ID,
|
||||
HUYA_DEFAULT_SID,
|
||||
)
|
||||
@@ -1412,9 +1414,12 @@ def set_images(
|
||||
# CK 探针(S5,只读验证)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# 探针参数与 web/backend/huya_defaults.py 的默认活动一致(2026-10-16 真机实测)。
|
||||
PROBE_BIND_ACT_ID = int(HUYA_DEFAULT_BIND_ACT_ID)
|
||||
# 探针参数与 web/backend/huya_defaults.py 的默认活动一致(2026-11-09 真机实测)。
|
||||
# 新版活动页绑定走 gid=3203、bActId=0;最终角色由通用查询的第二路
|
||||
# (gid + outerActId=空)返回,这里只给出外层活动参数。
|
||||
PROBE_GID = int(HUYA_DEFAULT_BIND_GID)
|
||||
PROBE_OUTER_ACT_ID = HUYA_DEFAULT_OUTER_ACT_ID
|
||||
PROBE_SCENE = "undefined" # 与 runner 绑定协议保持一致
|
||||
PROBE_TIMEOUT_SECONDS = 15.0
|
||||
|
||||
# 探针独占的绑定快照列(每次验证**覆盖写入**,人工不编辑):
|
||||
@@ -1572,12 +1577,15 @@ def probe_ck(order: HuyaExternalOrder, *, client: Any | None = None) -> CkProbeR
|
||||
|
||||
try:
|
||||
probe_client = client or _build_probe_client()
|
||||
resp = probe_client.check_user_bind_game_account(
|
||||
resp, _detail = query_bind_game_account(
|
||||
probe_client,
|
||||
uid=uid,
|
||||
cookie=cookie,
|
||||
b_act_id=PROBE_BIND_ACT_ID,
|
||||
is_use_outer_act_id=1,
|
||||
b_act_id=0,
|
||||
gid=PROBE_GID,
|
||||
outer_act_id=PROBE_OUTER_ACT_ID,
|
||||
is_use_outer_act_id=0,
|
||||
scene=PROBE_SCENE,
|
||||
timeout=PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 探针异常=验证不通过,不能让接口 500
|
||||
|
||||
@@ -8,6 +8,8 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.huya.http_client import query_bind_game_account
|
||||
|
||||
from ..huya_defaults import HUYA_DEFAULT_OUTER_ACT_ID
|
||||
from ..models import HuyaAccount, HuyaTask
|
||||
from .huya_runner_core import HUYA_RECHARGE_SOURCE_ID, HuyaBatchRunnerCore
|
||||
@@ -48,10 +50,6 @@ class BindMixin:
|
||||
account_data = bind_status.accountData
|
||||
return account_data.gameRole.roleName or ""
|
||||
|
||||
@staticmethod
|
||||
def _has_bind_role(bind_status) -> bool:
|
||||
return bool(bind_status and BindMixin._role_name(bind_status))
|
||||
|
||||
@staticmethod
|
||||
def _bind_role_result(bind_status) -> dict:
|
||||
account_data = bind_status.accountData
|
||||
@@ -101,6 +99,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,51 +143,22 @@ 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,
|
||||
}
|
||||
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)
|
||||
if outer_status is None:
|
||||
return None, {}
|
||||
|
||||
query_result = {
|
||||
"bind_status_source": "outer",
|
||||
"outer_bind_status": outer_status.to_dict(),
|
||||
}
|
||||
if outer_status.status != 200:
|
||||
return outer_status, query_result
|
||||
|
||||
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
|
||||
"""按活动页逻辑解析绑定状态(统一委托 core.huya.http_client 的通用查询)。"""
|
||||
return query_bind_game_account(
|
||||
client,
|
||||
uid=uid,
|
||||
cookie=cookie,
|
||||
b_act_id=b_act_id_int,
|
||||
gid=gid,
|
||||
outer_act_id=outer_act_id,
|
||||
is_use_outer_act_id=is_use_outer_act_id,
|
||||
scene=scene,
|
||||
timeout=timeout,
|
||||
)
|
||||
if should_check_inner:
|
||||
inner_status = client.check_user_bind_game_account(
|
||||
uid=uid,
|
||||
cookie=cookie,
|
||||
b_act_id=b_act_id_int,
|
||||
is_use_outer_act_id=0,
|
||||
)
|
||||
if inner_status is not None:
|
||||
query_result["inner_bind_status"] = inner_status.to_dict()
|
||||
if inner_status.status == 200 and self._has_bind_role(inner_status):
|
||||
chosen_status = inner_status
|
||||
query_result["bind_status_source"] = "inner"
|
||||
|
||||
query_result["bind_status"] = chosen_status.to_dict()
|
||||
return chosen_status, query_result
|
||||
|
||||
def _probe_bind_status(
|
||||
self,
|
||||
@@ -162,6 +166,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 +184,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 +219,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 +273,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 +347,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 +369,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 +398,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 +417,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 +469,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 +504,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 +539,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 +555,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 +575,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 +589,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 +602,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 +628,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 +650,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 +673,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 +691,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 +700,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 +721,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 +745,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 +773,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(),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 />)} />
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 /> });
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user