From 47ded2bc3e6ca9e1ae79e3ae6b69843a05352564 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 13 Aug 2026 16:17:06 +0800 Subject: [PATCH] feat: add scoped cookie operations for support --- docs/鱼翅充值api/对接信息.md | 7 + docs/鱼翅充值api/接口规范.md | 106 +++++++++ docs/鱼翅充值api/订单接口/创建订单【必要】.md | 76 +++++++ docs/鱼翅充值api/订单接口/查询订单【必要】.md | 54 +++++ .../订单接口/订单异步通知【推荐】.md | 34 +++ docs/鱼翅充值api/订单接口/账户信息查询.md | 20 ++ docs/鱼翅充值api/账户接口/账户信息查询.md | 20 ++ tests/test_cookie_operations.py | 130 +++++++++++ web/backend/permissions.py | 2 + web/backend/routers/cookies.py | 137 ++++++++++-- web/frontend/src/App.tsx | 2 + web/frontend/src/api/cookies.ts | 8 +- web/frontend/src/api/types.ts | 14 ++ web/frontend/src/layouts/MainLayout.tsx | 3 + .../src/pages/CookieOperationsPage.tsx | 207 ++++++++++++++++++ 15 files changed, 806 insertions(+), 14 deletions(-) create mode 100644 docs/鱼翅充值api/对接信息.md create mode 100644 docs/鱼翅充值api/接口规范.md create mode 100644 docs/鱼翅充值api/订单接口/创建订单【必要】.md create mode 100644 docs/鱼翅充值api/订单接口/查询订单【必要】.md create mode 100644 docs/鱼翅充值api/订单接口/订单异步通知【推荐】.md create mode 100644 docs/鱼翅充值api/订单接口/账户信息查询.md create mode 100644 docs/鱼翅充值api/账户接口/账户信息查询.md create mode 100644 tests/test_cookie_operations.py create mode 100644 web/frontend/src/pages/CookieOperationsPage.tsx diff --git a/docs/鱼翅充值api/对接信息.md b/docs/鱼翅充值api/对接信息.md new file mode 100644 index 0000000..d165819 --- /dev/null +++ b/docs/鱼翅充值api/对接信息.md @@ -0,0 +1,7 @@ +店铺ID:A20260813141939 +默认账号 +B178660196602145988 +默认密码 +afac3645 +AppKey: 2AJjbWmZ28cu723Q +AppSecret: 5SDFbYEMgDITOKYzDUhBOQAPyTYBQ4UK diff --git a/docs/鱼翅充值api/接口规范.md b/docs/鱼翅充值api/接口规范.md new file mode 100644 index 0000000..eba5326 --- /dev/null +++ b/docs/鱼翅充值api/接口规范.md @@ -0,0 +1,106 @@ +# 接口规范 + +# 一、请求格式 + +# 二、AppId、AppKey 和 AppSercet + +- 由双方协定,或由我平台提供 + +- AppId:商户号 + +- AppKey:长度为16位 + +- AppSecret:长度为32位 + +- 用于 + + - 卡券、卡密类订单的数据的加密 + + - 签名时的补充数据 + +- *AppSercet需考虑定期更换* + + + +# 三、请求接口公共参数 + +***可选参数在对接时,请联系对接方,非特殊说明情况下,默认不传递*** + + + +# 四、请求签名规则 + +**注意:签名前,需双方协定或由厂商提供AppSecret** + +sign签名时 + +1. 去掉空格:剔除参数名和参数值的前后空格,包括\\n和\\r等空值 + +2. 剔除空值:如果参数没有值,不参与签名 + +3. **sign不参与签名,其他字段均参与签名** + +4. 排序生成待签名串: + + 1. 所有参数名**按照ascii顺序**进行排序 + + 2. 并且参数名与参数值**通过a=1\&b=2的方式**将所有参数进行组合 + + 3. 生成新的字符串后在字符串后**加上请求的方式** + + 4. 再**加上AppSecret**后获得待签名字符串 + +5. 进行加密 + + 1. 默认进行MD5加密 + + 2. 将第三步的待签名字符串进行utf8编码 + + 3. 再进行md5操作后得到字符串 + + 4. 最后转小写后为签名字符串 + +```JSON +// 假定 AppSercet 为 ac9fa79db418fd2c61d9bd4c60956f2d5db3a6d781d6f91f2e96327c74dfaccd +// 假定是下订单的接口 POST +// 待签名参数 +{ + // 公共参数 + app_id: '15945681 ', + timestamp: 1740916504, + + // 业务参数 + charge_account: '18888888888', + buy_num: 2, + customer_price: 1.21, + customer_order_no: 'DD202507010010208888', + product_id: '66668888' +} +// 上述参数的签名字符串(去掉空值、去掉空格;最后加上请求方法和AppSecret) +app_id=15945681&buy_num=2&charge_account=18888888888& +customer_order_no=DD202507010010208888&customer_price=1.21& +product_id=66668888×tamp=1740916504 +POSTac9fa79db418fd2c61d9bd4c60956f2d5db3a6d781d6f91f2e96327c74dfaccd + +// MD5后转小写,签名字符串为: +4087a959f3488ecb13efe6ef58e3bc67 +``` + + + +# 五、响应公共信息 + +**响应签名规则请参考“请求签名规则”** + +**sign不参与签名,其他字段均参与签名** + + + + + + + + + + + diff --git a/docs/鱼翅充值api/订单接口/创建订单【必要】.md b/docs/鱼翅充值api/订单接口/创建订单【必要】.md new file mode 100644 index 0000000..a018abb --- /dev/null +++ b/docs/鱼翅充值api/订单接口/创建订单【必要】.md @@ -0,0 +1,76 @@ +# 创建订单【必要】 + +# 接口说明 + +- 下单接口 + +- POST 请求 + +- application/json + +- 接口地址 ***adapter\-apiaccess/open/api/createOrderV2*** + + + +# 请求参数 + +**notify\_url**: + +- 一些直充类的订单时间比较久,但不能持续占用连接 + +- 不传就不回调,订单的状态在查询时处理 + + + +**recharge\_arg**:(此处数据为用户填写和对应模板) + +```Plain Text +[ + { + "templateName": "模板名,一般是中文,如手机号,只是标识用,调用方可自行决定", + "templateVal": "用户填写的充值账号,比如:19876543210", + } +] +``` + +这里是用户填写的信息,比如:充值手机号:19876543210 + +templateName:充值手机号 + +templateVal:19876543210 + + + +**ext\_arg**:(此处数据均为双方约定) + +```Plain Text +{ + "skuid": 243241231238888888, + "skuname: "电商平台的商品名称" +} +``` + + + +# 响应信息 + +## code数据对业务流程的影响 + +- 如果返回 200 的状态码,则下单成功,后续通过【查询订单】或者【订单异步通知】来获取订单结果状态 + +- 如果返回非 200 的状态码,订单失败,流程结束 + + + +## order\_status数据对业务流程的影响 + +- 0,待处理;1,处理中;2,成功;3,失败; 4,异常 + +- 当为 0 和 1 时,订单没有结束, + + - 需要调用方自轮询查询订单; + + - 或本平台订单结束后,异步通知订单结果 + + + diff --git a/docs/鱼翅充值api/订单接口/查询订单【必要】.md b/docs/鱼翅充值api/订单接口/查询订单【必要】.md new file mode 100644 index 0000000..44a2033 --- /dev/null +++ b/docs/鱼翅充值api/订单接口/查询订单【必要】.md @@ -0,0 +1,54 @@ +# 查询订单【必要】 + +# 接口说明 + +- 订单查询接口 + +- GET 请求 + +- application/x\-www\-form\-urlencoded + +- 接口地址 ***adapter\-apiaccess/open/api/queryOrderV2*** + + + +# 请求参数 + + + +# 响应信息 + +## code数据对业务流程的影响 + +- 查询订单时如果返回非 200 的状态码,订单失败,流程结束 + + + +## order\_status数据对业务流程的影响 + +- 当为 0 和 1 时,订单没有结束, + + - 需要调用方自轮询查询订单; + + - 或本平台订单结束后,异步通知订单结果 + +- 当为 2 和 3 、4 时,订单结束 + + + +## cards解密后的结构如下 + +```JSON +[ + { + card_no: 'CN88889999',(无卡号就填""空串) + card_pwd: 'ABCD-DEFG-GHIJ-JKLM', + expired_time: '2026-09-27 08:36:58',(过期时间,可不传) + price: '8.8',(卡密单价,可不传) + card_type: '0'(不传时,默认为0。0.普通卡密 1.短链) + } +] +``` + + + diff --git a/docs/鱼翅充值api/订单接口/订单异步通知【推荐】.md b/docs/鱼翅充值api/订单接口/订单异步通知【推荐】.md new file mode 100644 index 0000000..ebf0d59 --- /dev/null +++ b/docs/鱼翅充值api/订单接口/订单异步通知【推荐】.md @@ -0,0 +1,34 @@ +# 订单异步通知【推荐】 + +# 接口说明 + +- 下单后,如订单流程、时间较长,需要第一时间返回下单成功状态,之后的流程中再去通知外部平台订单的状态 + +- POST 请求 + +- 接口地址一般为 ***http://IP******\[:PORT\]/网关/业务/callBackV2*** + +- 具体地址,请双方开发协定,或联系运营获取 + + + +# 请求参数 + +## cards解密后的结构如下: + +```JSON +[ + { + card_no: 'CN88889999',(无卡号就填""空串) + card_pwd: 'ABCD-DEFG-GHIJ-JKLM', + expired_time: '2026-09-27 08:36:58',(过期时间,可不传) + price: '8.8',(卡密单价,可不传) + card_type: '0'(不传时,默认为0。0.普通卡密 1.短链) + } +] +``` + +# 响应信息 + + + diff --git a/docs/鱼翅充值api/订单接口/账户信息查询.md b/docs/鱼翅充值api/订单接口/账户信息查询.md new file mode 100644 index 0000000..1c27446 --- /dev/null +++ b/docs/鱼翅充值api/订单接口/账户信息查询.md @@ -0,0 +1,20 @@ +# 账户信息查询 + +# 接口说明 + +- 下单接口 + +- GET 请求 + +- 接口地址 ***adapter\-apiaccess/open/api/userInfoV2*** + + + +# 请求参数 + + + +# 响应信息 + + + diff --git a/docs/鱼翅充值api/账户接口/账户信息查询.md b/docs/鱼翅充值api/账户接口/账户信息查询.md new file mode 100644 index 0000000..1c27446 --- /dev/null +++ b/docs/鱼翅充值api/账户接口/账户信息查询.md @@ -0,0 +1,20 @@ +# 账户信息查询 + +# 接口说明 + +- 下单接口 + +- GET 请求 + +- 接口地址 ***adapter\-apiaccess/open/api/userInfoV2*** + + + +# 请求参数 + + + +# 响应信息 + + + diff --git a/tests/test_cookie_operations.py b/tests/test_cookie_operations.py new file mode 100644 index 0000000..2c166a8 --- /dev/null +++ b/tests/test_cookie_operations.py @@ -0,0 +1,130 @@ +import os +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +os.environ.setdefault("DATABASE_URL", "sqlite://") +os.environ.setdefault("APP_ENCRYPTION_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=") + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from web.backend.database import Base +from web.backend.models import Account, LoginTask, User +from web.backend.routers.cookies import ( + check_cookie_operations, + get_cookie, + list_cookie_operations, + list_cookies, +) + + +class CookieOperationTests(unittest.TestCase): + def setUp(self): + self.engine = create_engine("sqlite://") + Base.metadata.create_all(self.engine) + self.session = sessionmaker(bind=self.engine)() + + self.support = User(username="support", password_hash="hash", role="support") + self.other_support = User(username="other", password_hash="hash", role="support") + self.session.add_all([self.support, self.other_support]) + self.session.commit() + + owned = Account( + username="owned-account", + password="owned-password", + email="owned@example.com", + email_password="mail-password", + assigned_to=self.support.id, + ) + other = Account( + username="other-account", + password="other-password", + email="other@example.com", + email_password="mail-password", + assigned_to=self.other_support.id, + ) + self.session.add_all([owned, other]) + self.session.commit() + self.session.add_all([ + LoginTask(batch_id="owned", account_id=owned.id, created_by=self.support.id, status="success", cookie="owned-secret"), + LoginTask(batch_id="other", account_id=other.id, created_by=self.other_support.id, status="success", cookie="other-secret"), + ]) + self.session.commit() + self.owned_task = self.session.query(LoginTask).filter(LoginTask.batch_id == "owned").one() + self.other_task = self.session.query(LoginTask).filter(LoginTask.batch_id == "other").one() + + def tearDown(self): + self.session.close() + Base.metadata.drop_all(self.engine) + self.engine.dispose() + + def test_support_operation_list_is_scoped_and_never_returns_credentials(self): + result = list_cookie_operations( + search="", + page=1, + page_size=20, + db=self.session, + current=self.support, + ) + + self.assertEqual(result["total"], 1) + self.assertEqual(result["items"], [{ + "id": self.owned_task.id, + "account_username": "owned-account", + "ck_check_status": "", + "ck_checked_at": None, + "created_at": None, + }]) + self.assertNotIn("owned-secret", str(result)) + self.assertNotIn("owned-password", str(result)) + + def test_support_cannot_use_cookie_management_endpoints(self): + with self.assertRaisesRegex(Exception, "cookie:view"): + list_cookies(db=self.session, current=self.support) + with self.assertRaisesRegex(Exception, "cookie:view"): + get_cookie(self.owned_task.id, db=self.session, current=self.support) + + @patch("web.backend.routers.cookies._check_one_cookie") + def test_support_can_check_only_assigned_cookie(self, check_one_cookie): + check_one_cookie.return_value = { + "id": self.owned_task.id, + "valid": True, + "message": "有效", + "fish_ball": 9, + "nickname": "tester", + "level": 1, + "checked_at": "2026-01-01T00:00:00+00:00", + } + + result = check_cookie_operations( + ids=f"{self.owned_task.id},{self.other_task.id}", + db=self.session, + current=self.support, + ) + + self.assertEqual([item["id"] for item in result["results"]], [self.owned_task.id]) + self.session.refresh(self.owned_task) + self.session.refresh(self.other_task) + self.assertEqual(self.owned_task.ck_check_status, "valid") + self.assertEqual(self.other_task.ck_check_status, "") + + def test_operation_permission_does_not_allow_unrelated_users(self): + no_permission_user = SimpleNamespace( + id=999, + username="no-permission", + role="support", + custom_permissions=[], + ) + with self.assertRaisesRegex(Exception, "cookie:operate"): + list_cookie_operations( + search="", + page=1, + page_size=20, + db=self.session, + current=no_permission_user, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/backend/permissions.py b/web/backend/permissions.py index 2a9ae51..322afbc 100644 --- a/web/backend/permissions.py +++ b/web/backend/permissions.py @@ -23,6 +23,7 @@ PERMISSIONS = { "login:view_assigned": "查看自己账号的登录任务", # Cookie "cookie:view": "查看 Cookie", + "cookie:operate": "检测/重登已分配账号 Cookie(不含查看)", "cookie:export": "导出 Cookie", # 斗鱼活动 "douyu:task": "斗鱼任务管理", @@ -88,6 +89,7 @@ ROLE_PERMISSIONS = { ], "support": [ "account:view_assigned", + "cookie:operate", "douyu:task", "yyb:session", "yyb:history", diff --git a/web/backend/routers/cookies.py b/web/backend/routers/cookies.py index 576ef7d..505d531 100644 --- a/web/backend/routers/cookies.py +++ b/web/backend/routers/cookies.py @@ -12,7 +12,7 @@ import io import csv from ..database import get_db, SessionLocal -from ..models import User, LoginTask, Account, ProxyConfig as ProxyConfigModel +from ..models import User, LoginTask, Account, AuditLog, ProxyConfig as ProxyConfigModel from ..deps import get_current_user, require_permission from ..permissions import user_has_permission, get_user_permissions from ..schemas import CookieReloginRequest @@ -30,6 +30,15 @@ def _fmt_dt(dt) -> str | None: router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"]) +def _require_cookie_operation_perm(current: User) -> None: + """允许脱敏的 CK 检测与重登,不授予 CK 查看或导出能力。""" + if not ( + user_has_permission(current, "cookie:operate") + or user_has_permission(current, "cookie:view") + ): + raise HTTPException(status_code=403, detail="无权限: cookie:operate") + + def _parse_account_names(raw_names: str) -> list[str]: """解析前端粘贴的 Excel 账号名,每行一个并去重。""" names = [] @@ -162,6 +171,8 @@ def list_cookies( current: User = Depends(get_current_user), ): """查看登录成功的 Cookie 列表。""" + if not user_has_permission(current, "cookie:view"): + raise HTTPException(status_code=403, detail="无权限: cookie:view") if not include_cookie: query = _visible_cookie_tasks_query(db, current).options(defer(LoginTask.cookie)) else: @@ -248,6 +259,8 @@ def cookies_summary( current: User = Depends(get_current_user), ): """Cookie 管理统计,避免前端为了卡片统计拉全量 Cookie。""" + if not user_has_permission(current, "cookie:view"): + raise HTTPException(status_code=403, detail="无权限: cookie:view") query = _visible_cookie_tasks_query(db, current) if user_has_permission(current, "login:view_all"): query = query.join(Account, LoginTask.account_id == Account.id) @@ -260,6 +273,47 @@ def cookies_summary( } +@router.get("/operations") +def list_cookie_operations( + search: 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), +): + """脱敏的 CK 操作列表,只用于检测与重登。""" + _require_cookie_operation_perm(current) + query = _visible_cookie_tasks_query(db, current) + if user_has_permission(current, "login:view_all"): + query = query.join(Account, LoginTask.account_id == Account.id) + search_text = (search or "").strip() + if search_text: + query = query.filter(Account.username.ilike(f"%{search_text}%")) + + total = query.order_by(None).count() + tasks = ( + query.order_by(LoginTask.finished_at.desc(), LoginTask.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return { + "items": [ + { + "id": task.id, + "account_username": task.account.username if task.account else "", + "ck_check_status": task.ck_check_status or "", + "ck_checked_at": _fmt_dt(task.ck_checked_at), + "created_at": _fmt_dt(task.finished_at), + } + for task in tasks + ], + "total": total, + "page": page, + "page_size": page_size, + } + + @router.get("/duplicates") def find_duplicate_cookies( db: Session = Depends(get_db), @@ -390,13 +444,8 @@ def export_cookies( ) -@router.post("/check") -def check_cookies( - ids: str = "", - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """批量检测斗鱼 Cookie 有效性(鱼丸接口),返回每条有效性与鱼丸数/昵称。""" +def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> dict: + """执行 CK 检测;客服操作页仅返回状态,不返回账号衍生信息。""" if not ids: raise HTTPException(status_code=400, detail="请指定记录ID") id_list = [int(x) for x in ids.split(",") if x.strip().isdigit()] @@ -439,16 +488,48 @@ def check_cookies( "message": item.get("message", ""), } task.ck_checked_at = datetime.now(timezone.utc) + db.add(AuditLog( + user_id=current.id, + username=current.username, + action="cookie:check", + target=f"检测 {len(results)} 条已分配账号 CK", + )) db.commit() - + if not detailed: + results = [ + {"id": item["id"], "valid": item["valid"], "checked_at": item["checked_at"]} + for item in results + ] return {"results": results, "success": True} -@router.post("/relogin") -def relogin_cookies( - req: CookieReloginRequest, +@router.post("/check") +def check_cookies( + ids: str = "", db: Session = Depends(get_db), - current: User = Depends(require_permission("login:batch")), + current: User = Depends(get_current_user), +): + """批量检测斗鱼 Cookie 有效性,供 Cookie 管理页查看详细结果。""" + if not user_has_permission(current, "cookie:view"): + raise HTTPException(status_code=403, detail="无权限: cookie:view") + return _check_cookies(ids, db, current, detailed=True) + + +@router.post("/operations/check") +def check_cookie_operations( + ids: str = "", + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """脱敏 CK 检测,只返回有效性与检测时间。""" + _require_cookie_operation_perm(current) + return _check_cookies(ids, db, current, detailed=False) + + +def _start_relogin( + req: CookieReloginRequest, + db: Session, + current: User, ): """重新登录失效的 Cookie:用账号信息重新登录,成功后替换旧 Cookie。 @@ -498,6 +579,13 @@ def relogin_cookies( thread = threading.Thread(target=runner.run, daemon=True) thread.start() + db.add(AuditLog( + user_id=current.id, + username=current.username, + action="cookie:relogin", + target=f"重登 {len(task_ids)} 条已分配账号 CK", + )) + db.commit() return { "batch_id": batch_id, "count": len(task_ids), @@ -506,6 +594,27 @@ def relogin_cookies( } +@router.post("/relogin") +def relogin_cookies( + req: CookieReloginRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("login:batch")), +): + """Cookie 管理页的重登入口,保留既有 login:batch 权限。""" + return _start_relogin(req, db, current) + + +@router.post("/operations/relogin") +def relogin_cookie_operations( + req: CookieReloginRequest, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """脱敏 CK 操作页的重登入口。""" + _require_cookie_operation_perm(current) + return _start_relogin(req, db, current) + + @router.get("/{task_id}") def get_cookie( task_id: int, @@ -513,6 +622,8 @@ def get_cookie( current: User = Depends(get_current_user), ): """获取单条 Cookie 详情,供复制操作按需读取完整敏感字段。""" + if not user_has_permission(current, "cookie:view"): + raise HTTPException(status_code=403, detail="无权限: cookie:view") task = _visible_cookie_tasks_query(db, current).filter(LoginTask.id == task_id).first() if not task: raise HTTPException(status_code=404, detail="记录不存在") diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 2d35d3d..a35e57e 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -17,6 +17,7 @@ const LoginTasksPage = lazy(() => import('./pages/LoginTasksPage')); const ProxyPage = lazy(() => import('./pages/ProxyPage')); const UsersPage = lazy(() => import('./pages/UsersPage')); const CookiePage = lazy(() => import('./pages/CookiePage')); +const CookieOperationsPage = lazy(() => import('./pages/CookieOperationsPage')); const DouyuTasksPage = lazy(() => import('./pages/DouyuTasksPage')); const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage')); const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage')); @@ -79,6 +80,7 @@ function AppContent() { )} /> )} /> )} /> + )} /> } /> )} /> )} /> diff --git a/web/frontend/src/api/cookies.ts b/web/frontend/src/api/cookies.ts index c5249f6..11c0901 100644 --- a/web/frontend/src/api/cookies.ts +++ b/web/frontend/src/api/cookies.ts @@ -1,11 +1,13 @@ import api from './client'; -import type { BasicSummary, CookieCheckResult, CookieDuplicateResponse, CookieItem, LoginTaskItem, PageParams, PaginatedResponse, MessageDeletedResponse, MessageResponse } from './types'; +import type { BasicSummary, CookieCheckResult, CookieDuplicateResponse, CookieItem, CookieOperationCheckResult, CookieOperationItem, LoginTaskItem, PageParams, PaginatedResponse, MessageDeletedResponse, MessageResponse } from './types'; export const cookieApi = { list: () => api.get('/cookies'), listPaged: (params: PageParams & { include_cookie?: boolean; account_names?: string }) => api.get, PaginatedResponse>('/cookies', { params }), summary: () => api.get('/cookies/summary'), + listOperations: (params: PageParams) => + api.get, PaginatedResponse>('/cookies/operations', { params }), duplicates: () => api.get('/cookies/duplicates'), get: (id: number) => api.get(`/cookies/${id}`), exportCsv: (format?: string, accountNames?: string) => api.get('/cookies/export', { @@ -17,8 +19,12 @@ export const cookieApi = { }), check: (ids: number[]) => api.post<{ results: CookieCheckResult[] }, { results: CookieCheckResult[] }>('/cookies/check', null, { params: { ids: ids.join(',') } }), + checkOperations: (ids: number[]) => + api.post<{ results: CookieOperationCheckResult[] }, { results: CookieOperationCheckResult[] }>('/cookies/operations/check', null, { params: { ids: ids.join(',') } }), relogin: (ids: number[]) => api.post<{ batch_id: string; count: number; skipped: number; success: boolean }, { batch_id: string; count: number; skipped: number; success: boolean }>('/cookies/relogin', { ids }), + reloginOperations: (ids: number[]) => + api.post<{ batch_id: string; count: number; skipped: number; success: boolean }, { batch_id: string; count: number; skipped: number; success: boolean }>('/cookies/operations/relogin', { ids }), loginTasks: (batchId: string) => api.get('/login/tasks', { params: { batch_id: batchId } }), delete: (id: number) => api.delete(`/cookies/${id}`), diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 0ea86f3..dc114af 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -253,6 +253,20 @@ export interface CookieItem { ck_checked_at: string | null; } +export interface CookieOperationItem { + id: number; + account_username: string; + ck_check_status: string; + ck_checked_at: string | null; + created_at: string | null; +} + +export interface CookieOperationCheckResult { + id: number; + valid: boolean; + checked_at: string; +} + export interface CookieCheckResult { id: number; valid: boolean; diff --git a/web/frontend/src/layouts/MainLayout.tsx b/web/frontend/src/layouts/MainLayout.tsx index a37376a..2180d61 100644 --- a/web/frontend/src/layouts/MainLayout.tsx +++ b/web/frontend/src/layouts/MainLayout.tsx @@ -71,6 +71,9 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) { if (can('cookie:view')) { douyuItems.push({ key: '/cookies', label: 'Cookie 管理', icon: }); } + if (can('cookie:operate')) { + douyuItems.push({ key: '/cookie-operations', label: 'CK 检测与重登', icon: }); + } if (can('douyu:task')) { douyuItems.push({ key: '/douyu/elite', label: '精英宝典', icon: }); douyuItems.push({ key: '/douyu/esports', label: '电竞手册', icon: }); diff --git a/web/frontend/src/pages/CookieOperationsPage.tsx b/web/frontend/src/pages/CookieOperationsPage.tsx new file mode 100644 index 0000000..9089947 --- /dev/null +++ b/web/frontend/src/pages/CookieOperationsPage.tsx @@ -0,0 +1,207 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Button, Input, Popconfirm, Space, Table, Tag, Typography } from 'antd'; +import { message } from '../utils/antdMessage'; +import { LoadingOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; +import { cookieApi, type CookieOperationCheckResult, type CookieOperationItem } from '../api/modules'; +import { formatTime } from '../utils/time'; +import { getErrorMessage } from '../utils/error'; + +const { Text } = Typography; + +export default function CookieOperationsPage() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [search, setSearch] = useState(''); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [checkingIds, setCheckingIds] = useState>(new Set()); + const [reloginIds, setReloginIds] = useState>(new Set()); + const [checkResults, setCheckResults] = useState>(new Map()); + const [total, setTotal] = useState(0); + const [pageSize, setPageSize] = useState(() => { + const value = localStorage.getItem('cookie_operations_page_size'); + return value ? Number(value) || 20 : 20; + }); + const [currentPage, setCurrentPage] = useState(1); + + const loadItems = useCallback(async () => { + setLoading(true); + try { + const data = await cookieApi.listOperations({ + page: currentPage, + page_size: pageSize, + search: search.trim() || undefined, + }); + setItems(data.items); + setTotal(data.total); + setCheckResults((previous) => { + const next = new Map(previous); + for (const item of data.items) { + if (!item.ck_check_status) continue; + next.set(item.id, { + id: item.id, + valid: item.ck_check_status === 'valid', + checked_at: item.ck_checked_at ?? '', + }); + } + return next; + }); + } catch (error: unknown) { + message.error(getErrorMessage(error)); + } finally { + setLoading(false); + } + }, [currentPage, pageSize, search]); + + useEffect(() => { + void loadItems(); + }, [loadItems]); + + const handleCheck = async (ids: number[]) => { + if (ids.length === 0) return; + setCheckingIds((previous) => new Set([...previous, ...ids])); + try { + const response = await cookieApi.checkOperations(ids); + setCheckResults((previous) => { + const next = new Map(previous); + response.results.forEach((item) => next.set(item.id, item)); + return next; + }); + message.success(`检测完成:${response.results.filter((item) => item.valid).length}/${response.results.length} 条有效`); + void loadItems(); + } catch (error: unknown) { + message.error(getErrorMessage(error)); + } finally { + setCheckingIds((previous) => { + const next = new Set(previous); + ids.forEach((id) => next.delete(id)); + return next; + }); + } + }; + + const handleRelogin = async (ids: number[]) => { + if (ids.length === 0) return; + setReloginIds((previous) => new Set([...previous, ...ids])); + try { + const response = await cookieApi.reloginOperations(ids); + const skipped = response.skipped ? `,${response.skipped} 条因缺少登录凭据跳过` : ''; + message.success(`已开始重登 ${response.count} 个账号${skipped}`); + setSelectedRowKeys([]); + window.setTimeout(() => void loadItems(), 1000); + } catch (error: unknown) { + message.error(getErrorMessage(error)); + } finally { + setReloginIds((previous) => { + const next = new Set(previous); + ids.forEach((id) => next.delete(id)); + return next; + }); + } + }; + + const resultFor = (item: CookieOperationItem) => checkResults.get(item.id); + const selectedIds = selectedRowKeys.map((key) => Number(key)); + const columns = [ + { title: '账号', dataIndex: 'account_username', ellipsis: true }, + { + title: '有效性', + width: 110, + render: (_: unknown, item: CookieOperationItem) => { + if (checkingIds.has(item.id)) return }>检测中; + const result = resultFor(item); + if (!result) return 未检测; + return {result.valid ? '有效' : '无效'}; + }, + }, + { + title: '检测时间', + width: 180, + render: (_: unknown, item: CookieOperationItem) => { + const checkedAt = resultFor(item)?.checked_at || item.ck_checked_at; + return checkedAt ? formatTime(checkedAt) : -; + }, + }, + { + title: '操作', + width: 190, + render: (_: unknown, item: CookieOperationItem) => ( + + + void handleRelogin([item.id])}> + + + + ), + }, + ]; + + return ( +
+
+

CK 检测与重登

+ + + void handleRelogin(selectedIds)} + > + + + +
+ } + placeholder="搜索账号" + style={{ width: 280, marginBottom: 12 }} + value={search} + onChange={(event) => { + setSearch(event.target.value); + setCurrentPage(1); + setSelectedRowKeys([]); + }} + /> + `共 ${count} 条`, + onChange: (page, size) => { + setCurrentPage(size === pageSize ? page : 1); + if (size !== pageSize) { + setPageSize(size); + localStorage.setItem('cookie_operations_page_size', String(size)); + } + }, + }} + /> + + ); +}