diff --git a/app/api/__init__.py b/app/api/__init__.py index e3a78254c..b3c8a80bd 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -35,6 +35,12 @@ from .ocr import router as ocr_router from .ws_debug import router as ws_debug_router +# 可选补丁:米游社扫码登录(可安全删除以下 2 行及 app/api/qr_login.py) +try: + from .qr_login import router as qr_login_router +except ImportError: + qr_login_router = None + __all__ = [ "core_router", "info_router", @@ -49,4 +55,5 @@ "update_router", "ocr_router", "ws_debug_router", + "qr_login_router", ] diff --git a/app/api/qr_login.py b/app/api/qr_login.py new file mode 100644 index 000000000..aaaa09bf0 --- /dev/null +++ b/app/api/qr_login.py @@ -0,0 +1,110 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025-2026 AUTO-MAS Team + +# This file is part of AUTO-MAS. + +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. + +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +# Contact: DLmaster_361@163.com + +""" +米游社扫码登录 API 路由(可选补丁) + +可安全删除本文件,不会影响任何已有功能。 +删除后同时移除 main.py 中的 include_router 调用。 + +流程(Passport 模式): + 1. /create → 生成二维码 (ticket, qr_url, device) + 2. /check → 轮询状态 (Init/Scanned/Confirmed),确认后返回 cookies_str + 3. /save → 将 cookies 保存到账号配置 +""" + +from fastapi import APIRouter, Body +from pydantic import BaseModel, Field +from app.core import Config +from app.models.schema import OutBase + + +router = APIRouter(prefix="/api/tools/sign/miyoushe/qr", tags=["扫码登录"]) + + +# ---- 请求/响应模型 ---- + + +class QrCreateOut(OutBase): + ticket: str = Field(default="", description="二维码 ticket") + qr_url: str = Field(default="") + device: str = Field(default="") + + +class QrCheckIn(BaseModel): + ticket: str + device: str + + +class QrCheckOut(OutBase): + status: str = Field(default="", description="Init/Scanned/Confirmed/Error") + cookies_str: str = Field(default="", description="确认后返回的完整 cookie 字符串") + + +class QrSaveIn(BaseModel): + account_uid: str = Field(..., description="MAS 账号组 UUID") + cookie: str + + +# ---- 端点 ---- + + +@router.post("/create", summary="创建二维码", response_model=QrCreateOut) +async def qr_create() -> QrCreateOut: + try: + from app.tools.miyoushe_qr import create_qr_login + result = await create_qr_login() + except Exception as e: + return QrCreateOut(code=500, status="error", message=str(e)) + if "error" in result: + return QrCreateOut(code=500, status="error", message=result["error"]) + return QrCreateOut(ticket=result["ticket"], qr_url=result["qr_url"], device=result["device"]) + + +@router.post("/check", summary="轮询扫码状态", response_model=QrCheckOut) +async def qr_check(body: QrCheckIn = Body(...)) -> QrCheckOut: + """轮询状态,确认后 cookies 直接从响应头获取""" + try: + from app.tools.miyoushe_qr import check_qr_status + result = await check_qr_status(body.ticket, body.device) + except Exception as e: + return QrCheckOut(code=500, status="error", message=str(e)) + if "error" in result: + return QrCheckOut(code=500, status="error", message=result["error"]) + return QrCheckOut( + status=result.get("status", ""), + cookies_str=result.get("cookies_str", ""), + ) + + +@router.post("/save", summary="保存 cookie 到账号配置", response_model=OutBase) +async def qr_save(body: QrSaveIn = Body(...)) -> OutBase: + try: + data = {"GameSignAccount": {"MiyousheToken": body.cookie}} + await Config.update_game_sign_account(body.account_uid, data) + result = Config.ToolsConfig._game_sign_result_data + for platform in list(result.keys()): + result[platform] = [g for g in result[platform] if g.get("account_uid") != body.account_uid] + if not result[platform]: + del result[platform] + except Exception as e: + return OutBase(code=500, status="error", message=str(e)) + return OutBase(message="米游社 Token 已保存") diff --git a/app/api/tools.py b/app/api/tools.py index 6cc1f1781..ed5aedf15 100644 --- a/app/api/tools.py +++ b/app/api/tools.py @@ -22,8 +22,21 @@ from fastapi import APIRouter, Body +from datetime import datetime from app.core import Config -from app.models.schema import ToolsGetOut, ToolsConfig, OutBase, ToolsUpdateIn +from app.models.schema import ( + ToolsGetOut, + ToolsConfig, + OutBase, + ToolsUpdateIn, + GameSignAccountCreateOut, + GameSignAccountGroupConfig, + GameSignAccountGetIn, + GameSignAccountUpdateIn, + GameSignAccountDeleteIn, + GameSignAccountReorderIn, + GameSignAccountsListOut, +) router = APIRouter(prefix="/api/tools", tags=["工具设置"]) @@ -36,7 +49,7 @@ status_code=200, ) async def get_tools() -> ToolsGetOut: - """查询工具配置""" + """获取工具设置""" try: data = await Config.get_tools() @@ -69,3 +82,215 @@ async def update_tools(script: ToolsUpdateIn = Body(...)) -> OutBase: code=500, status="error", message=f"{type(e).__name__}: {str(e)}" ) return OutBase() + + +@router.post( + "/sign", + tags=["Action"], + summary="手动触发游戏社区签到", + response_model=OutBase, + status_code=200, +) +async def manual_game_sign() -> OutBase: + """手动触发游戏社区签到""" + + try: + from app.tools.game_sign import run_all_sign_in, format_sign_results + from app.tools.game_sign import merge_sign_results + + results = await run_all_sign_in(force=True) + + # 格式化并存储结果 + formatted = format_sign_results(results) + # 合并结果(手动签到按 account_uid 替换旧数据) + Config.ToolsConfig._game_sign_result_data = merge_sign_results( + Config.ToolsConfig._game_sign_result_data, formatted, replace=True + ) + + # 标记今天已签到(仅当所有启用的用户都已签到时标记全局) + today = datetime.now().strftime("%Y-%m-%d") + all_signed = True + for uid, account in Config.ToolsConfig.GameSign_Accounts.items(): + if account.get("GameSignAccount", "Enabled"): + if account.get("GameSignAccount", "LastSignDate") != today: + all_signed = False + break + if all_signed: + await Config.ToolsConfig.set("GameSign", "LastSignDate", today) + # 清除计划时间 + await Config.ToolsConfig.set("GameSign", "ScheduledTime", "") + + except Exception as e: + return OutBase( + code=500, status="error", message=f"{type(e).__name__}: {str(e)}" + ) + return OutBase() + + +# ==================== 游戏签到账号组 CRUD ==================== + + +@router.post( + "/sign/account/list", + tags=["GameSign"], + summary="获取所有游戏签到账号组", + response_model=GameSignAccountsListOut, + status_code=200, +) +async def list_game_sign_accounts() -> GameSignAccountsListOut: + """获取所有游戏签到账号组""" + + try: + data = await Config.get_game_sign_accounts() + except Exception as e: + return GameSignAccountsListOut( + code=500, + status="error", + message=f"{type(e).__name__}: {str(e)}", + data={}, + ) + return GameSignAccountsListOut(data=data) + + +@router.post( + "/sign/account/add", + tags=["GameSign"], + summary="添加游戏签到账号组", + response_model=GameSignAccountCreateOut, + status_code=200, +) +async def add_game_sign_account() -> GameSignAccountCreateOut: + """添加游戏签到账号组""" + + try: + uid, config = await Config.add_game_sign_account() + # toDict() 返回 {"GameSignAccount": {fields}},需提取嵌套字典 + raw = await config.toDict() + flat = raw.get("GameSignAccount", raw) + data = GameSignAccountGroupConfig(**flat) + # 新增账号无需清空结果,因为新账号没有历史结果 + except Exception as e: + return GameSignAccountCreateOut( + code=500, + status="error", + message=f"{type(e).__name__}: {str(e)}", + accountId="", + data=GameSignAccountGroupConfig(**{}), + ) + return GameSignAccountCreateOut(accountId=str(uid), data=data) + + +@router.post( + "/sign/account/get", + tags=["GameSign"], + summary="获取游戏签到账号组详情", + response_model=GameSignAccountCreateOut, + status_code=200, +) +async def get_game_sign_account( + account: GameSignAccountGetIn = Body(...), +) -> GameSignAccountCreateOut: + """获取游戏签到账号组详情""" + + try: + raw = await Config.get_game_sign_account(account.accountId) + # toDict() 返回 {"GameSignAccount": {fields}},需提取嵌套字典 + flat = raw.get("GameSignAccount", raw) + account_data = GameSignAccountGroupConfig(**flat) + except Exception as e: + return GameSignAccountCreateOut( + code=500, + status="error", + message=f"{type(e).__name__}: {str(e)}", + accountId=account.accountId, + data=GameSignAccountGroupConfig(**{}), + ) + return GameSignAccountCreateOut(accountId=account.accountId, data=account_data) + + +@router.post( + "/sign/account/update", + tags=["GameSign"], + summary="更新游戏签到账号组配置", + response_model=OutBase, + status_code=200, +) +async def update_game_sign_account( + account: GameSignAccountUpdateIn = Body(...), +) -> OutBase: + """更新游戏签到账号组配置""" + + try: + # GameSignAccountGroupConfig 是扁平格式,需包装为 {group: {name: value}} 传给 ConfigBase.set + flat_data = account.data.model_dump(exclude_unset=True) + data = {"GameSignAccount": flat_data} + await Config.update_game_sign_account(account.accountId, data) + # Token 变更后只清空该账号的签到结果 + token_fields = {"MiyousheToken", "KuroToken", "SklandToken"} + if token_fields & set(flat_data.keys()): + result = Config.ToolsConfig._game_sign_result_data + account_uid = str(account.accountId) + for platform in list(result.keys()): + result[platform] = [ + group for group in result[platform] + if group.get("account_uid") != account_uid + ] + if not result[platform]: + del result[platform] + except Exception as e: + return OutBase( + code=500, status="error", message=f"{type(e).__name__}: {str(e)}" + ) + return OutBase() + + +@router.post( + "/sign/account/delete", + tags=["GameSign"], + summary="删除游戏签到账号组", + response_model=OutBase, + status_code=200, +) +async def delete_game_sign_account( + account: GameSignAccountDeleteIn = Body(...), +) -> OutBase: + """删除游戏签到账号组""" + + try: + await Config.delete_game_sign_account(account.accountId) + # 删除账号后清理该用户的签到结果 + account_uid = str(account.accountId) + result = Config.ToolsConfig._game_sign_result_data + for platform in list(result.keys()): + result[platform] = [ + group for group in result[platform] + if group.get("account_uid") != account_uid + ] + if not result[platform]: + del result[platform] + except Exception as e: + return OutBase( + code=500, status="error", message=f"{type(e).__name__}: {str(e)}" + ) + return OutBase() + + +@router.post( + "/sign/account/reorder", + tags=["GameSign"], + summary="调整游戏签到账号组顺序", + response_model=OutBase, + status_code=200, +) +async def reorder_game_sign_accounts( + account: GameSignAccountReorderIn = Body(...), +) -> OutBase: + """调整游戏签到账号组顺序""" + + try: + await Config.reorder_game_sign_accounts(account.order) + except Exception as e: + return OutBase( + code=500, status="error", message=f"{type(e).__name__}: {str(e)}" + ) + return OutBase() diff --git a/app/core/config.py b/app/core/config.py index de6c67336..97c3fbb07 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -61,6 +61,7 @@ Webhook, TimeSet, EmulatorConfig, + GameSignAccountGroup, ) from app.models.schema import WebSocketMessage from app.utils.constants import ( @@ -151,6 +152,16 @@ async def init_config(self) -> None: await self.QueueConfig.connect(self.config_path / "QueueConfig.json") await self.ToolsConfig.connect(self.config_path / "ToolsConfig.json") + # 游戏签到:连接账号组 MultipleConfig + await self.ToolsConfig.GameSign_Accounts.connect( + self.config_path / "GameSignAccounts.json" + ) + + # 游戏签到:如果不是今天签到的,清除计划时间以便重新计算 + last_sign_date = self.ToolsConfig.get("GameSign", "LastSignDate") + if last_sign_date != datetime.now().strftime("%Y-%m-%d"): + await self.ToolsConfig.set("GameSign", "ScheduledTime", "") + from app.services import System self.bind("Start", "IfSelfStart", System.set_SelfStart) @@ -1308,6 +1319,63 @@ async def update_tools(self, data: Dict[str, Dict[str, Any]]) -> None: logger.success("工具设置更新成功") + # ==================== 游戏签到账号组 CRUD ==================== + + async def get_game_sign_accounts(self) -> Dict[str, Any]: + """获取所有游戏签到账号组""" + + logger.debug("获取所有游戏签到账号组") + + return await self.ToolsConfig.GameSign_Accounts.toDict() + + async def add_game_sign_account(self) -> tuple[uuid.UUID, Any]: + """添加游戏签到账号组""" + + logger.info("添加游戏签到账号组") + + uid, config = await self.ToolsConfig.GameSign_Accounts.add( + GameSignAccountGroup + ) + return uid, config + + async def get_game_sign_account(self, account_id: str) -> Dict[str, Any]: + """获取游戏签到账号组详情""" + + logger.debug(f"获取游戏签到账号组: {account_id}") + + account_uid = uuid.UUID(account_id) + return await self.ToolsConfig.GameSign_Accounts[account_uid].toDict() + + async def update_game_sign_account( + self, account_id: str, data: Dict[str, Dict[str, Any]] + ) -> None: + """更新游戏签到账号组配置""" + + logger.info(f"更新游戏签到账号组: {account_id}") + + account_uid = uuid.UUID(account_id) + account = self.ToolsConfig.GameSign_Accounts[account_uid] + for group, items in data.items(): + for name, value in items.items(): + await account.set(group, name, value) + + async def delete_game_sign_account(self, account_id: str) -> None: + """删除游戏签到账号组""" + + logger.info(f"删除游戏签到账号组: {account_id}") + + account_uid = uuid.UUID(account_id) + await self.ToolsConfig.GameSign_Accounts.remove(account_uid) + + async def reorder_game_sign_accounts(self, order: list[str]) -> None: + """调整游戏签到账号组顺序""" + + logger.info("调整游戏签到账号组顺序") + + await self.ToolsConfig.GameSign_Accounts.setOrder( + [uuid.UUID(_) for _ in order] + ) + async def get_setting(self) -> Dict[str, Any]: """获取全局设置""" diff --git a/app/core/task_manager.py b/app/core/task_manager.py index 4b4ee51b6..092306f15 100644 --- a/app/core/task_manager.py +++ b/app/core/task_manager.py @@ -216,6 +216,16 @@ async def final_task(self) -> None: id="Main", type="Update", data={"PowerSign": Config.power_sign} ) + # 任务结束时触发游戏签到 + from app.core.timer import MainTimer + task = asyncio.create_task(MainTimer.try_game_sign_for_task()) + def _on_task_done(t): + if not t.cancelled(): + e = t.exception() + if e: + logger.error("任务触发的游戏签到执行异常", exc_info=e) + task.add_done_callback(_on_task_done) + async def on_crash(self, e: Exception) -> None: logger.exception(f"任务 {self.task_info.task_id} 出现异常: {e}") await Config.send_websocket_message( diff --git a/app/core/timer.py b/app/core/timer.py index 4ad7b422f..52ddbf717 100644 --- a/app/core/timer.py +++ b/app/core/timer.py @@ -20,7 +20,9 @@ # Contact: DLmaster_361@163.com import asyncio -from datetime import datetime +import json +import random +from datetime import datetime, timedelta from app.services import Matomo from app.MaaFW import ArknightWin32Toolkit @@ -71,6 +73,8 @@ async def second_task(self): if Config.ToolsConfig.get("ArknightsPC", "Enabled"): await ArknightWin32Toolkit.scheduled_task() + await self.check_game_sign() + await asyncio.sleep(1) async def hour_task(self): @@ -134,5 +138,182 @@ async def timed_start(self): ) await queue.set("Data", "LastTimedStart", curtime) + # 定时任务触发游戏签到 + await self.try_game_sign_for_task() + + async def check_game_sign(self) -> None: + """检查并执行游戏社区签到 + + 启用签到 + 时间窗口内随机时刻执行,窗口外补签。 + 仅在整分钟时执行(秒数 != 0 时跳过),因为调度精度为分钟级。 + """ + + if not Config.ToolsConfig.get("GameSign", "Enabled"): + return + + # 节流:非整分钟跳过,避免每秒都执行 + if datetime.now().second != 0: + return + + now = datetime.now() + today = now.strftime("%Y-%m-%d") + + # 检查是否所有启用的用户今日都已签到 + all_users_signed = True + for uid, account in Config.ToolsConfig.GameSign_Accounts.items(): + if account.get("GameSignAccount", "Enabled"): + if account.get("GameSignAccount", "LastSignDate") != today: + all_users_signed = False + break + + if all_users_signed: + return + + # 解析签到窗口 + try: + window_start_str = Config.ToolsConfig.get("GameSign", "WindowStart") + window_end_str = Config.ToolsConfig.get("GameSign", "WindowEnd") + window_start = datetime.strptime(window_start_str, "%H:%M").replace( + year=now.year, month=now.month, day=now.day + ) + window_end = datetime.strptime(window_end_str, "%H:%M").replace( + year=now.year, month=now.month, day=now.day + ) + except (ValueError, TypeError): + return + + # 如果在窗口开始之前,跳过 + if now < window_start: + return + + # 如果在窗口结束之后,立即补签 + if now > window_end: + await self._execute_game_sign() + return + + # 确定计划签到时间 + scheduled_time_str = Config.ToolsConfig.get("GameSign", "ScheduledTime") + + if not scheduled_time_str: + # 首次进入窗口:计算今天的随机时间 + remaining_seconds = int((window_end - now).total_seconds()) + if remaining_seconds <= 0: + await self._execute_game_sign() + return + random_offset = random.randint(0, remaining_seconds) + scheduled_time = now + timedelta(seconds=random_offset) + await Config.ToolsConfig.set( + "GameSign", "ScheduledTime", scheduled_time.strftime("%H:%M") + ) + return + + # 检查是否到达计划时间(分钟精度) + if now.strftime("%H:%M") == scheduled_time_str: + await self._execute_game_sign() + # Prevent duplicate trigger within same minute + await Config.ToolsConfig.set("GameSign", "ScheduledTime", "") + + async def _execute_game_sign(self) -> None: + """执行游戏签到并处理结果""" + from app.tools.game_sign import run_all_sign_in, format_sign_results + from app.tools.game_sign import merge_sign_results + + today = datetime.now().strftime("%Y-%m-%d") + + try: + logger.info("开始执行游戏社区签到") + results = await run_all_sign_in(force=False) + + # 如果所有用户都已签到(无新结果),保留已有结果 + if not results: + logger.info("所有用户今日已签到,跳过") + await Config.ToolsConfig.set("GameSign", "LastSignDate", today) + await Config.ToolsConfig.set("GameSign", "ScheduledTime", "") + return + + # 格式化并合并结果 + formatted = format_sign_results(results) + Config.ToolsConfig._game_sign_result_data = merge_sign_results( + Config.ToolsConfig._game_sign_result_data, formatted + ) + + # 清除计划时间 + await Config.ToolsConfig.set("GameSign", "ScheduledTime", "") + + # 检查是否所有用户都已签到,更新全局 LastSignDate + all_signed_after = True + for uid, account in Config.ToolsConfig.GameSign_Accounts.items(): + if account.get("GameSignAccount", "Enabled"): + if account.get("GameSignAccount", "LastSignDate") != today: + all_signed_after = False + break + if all_signed_after: + await Config.ToolsConfig.set("GameSign", "LastSignDate", today) + + logger.success("游戏社区签到执行完成") + + # 如果启用通知,发送签到结果 + if Config.ToolsConfig.get("GameSign", "NotifyEnabled"): + from app.tools.game_sign_notify import push_game_sign_notification + await push_game_sign_notification(results) + + except Exception as e: + logger.error(f"游戏社区签到执行失败: {e}") + # 保留已有结果,不覆盖为错误信息 + logger.exception("游戏社区签到执行异常堆栈") + + async def try_game_sign_for_task(self) -> None: + """任务生命周期触发的游戏签到(跳过已签到用户) + + 由定时任务启动、任务结束等事件触发。 + 不受全局 LastSignDate 限制,仅按用户 LastSignDate 过滤。 + """ + if not Config.ToolsConfig.get("GameSign", "Enabled"): + return + + today = datetime.now().strftime("%Y-%m-%d") + + # 快速检查:是否所有用户都已签到 + all_signed = True + for uid, account in Config.ToolsConfig.GameSign_Accounts.items(): + if account.get("GameSignAccount", "Enabled"): + if account.get("GameSignAccount", "LastSignDate") != today: + all_signed = False + break + if all_signed: + return + + from app.tools.game_sign import run_all_sign_in, format_sign_results, merge_sign_results + + try: + results = await run_all_sign_in(force=False) + if not results: + return + + formatted = format_sign_results(results) + Config.ToolsConfig._game_sign_result_data = merge_sign_results( + Config.ToolsConfig._game_sign_result_data, formatted + ) + + # 签到后检查是否所有用户都已完成 + all_signed_after = True + for uid, account in Config.ToolsConfig.GameSign_Accounts.items(): + if account.get("GameSignAccount", "Enabled"): + if account.get("GameSignAccount", "LastSignDate") != today: + all_signed_after = False + break + if all_signed_after: + await Config.ToolsConfig.set("GameSign", "LastSignDate", today) + await Config.ToolsConfig.set("GameSign", "ScheduledTime", "") + + logger.info("任务触发的游戏签到已完成") + + if Config.ToolsConfig.get("GameSign", "NotifyEnabled"): + from app.tools.game_sign_notify import push_game_sign_notification + await push_game_sign_notification(results) + + except Exception as e: + logger.error(f"任务触发的游戏签到失败: {e}") + MainTimer = _MainTimer() diff --git a/app/models/config.py b/app/models/config.py index abf93f02a..79a2e6c61 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -52,6 +52,7 @@ OptionsValidator, MultipleOptionsValidator, RangeValidator, + StringValidator, VirtualConfigValidator, FileValidator, FolderValidator, @@ -2550,6 +2551,39 @@ def __init__(self) -> None: super().__init__() +class GameSignAccountGroup(ConfigBase): + """游戏签到账号组配置""" + + def __init__(self) -> None: + + ## GameSignAccount - 账号组名称 + self.Name = ConfigItem( + "GameSignAccount", "Name", "用户 1", StringValidator() + ) + ## GameSignAccount - 是否启用(该用户是否参与签到) + self.Enabled = ConfigItem( + "GameSignAccount", "Enabled", True, BoolValidator() + ) + ## GameSignAccount - 米游社登录凭证 (DPAPI 加密) + self.MiyousheToken = ConfigItem( + "GameSignAccount", "MiyousheToken", "", EncryptValidator() + ) + ## GameSignAccount - 库街区登录凭证 (DPAPI 加密) + self.KuroToken = ConfigItem( + "GameSignAccount", "KuroToken", "", EncryptValidator() + ) + ## GameSignAccount - 森空岛登录凭证 (DPAPI 加密) + self.SklandToken = ConfigItem( + "GameSignAccount", "SklandToken", "", EncryptValidator() + ) + ## GameSignAccount - 上次签到日期 (按用户隔离,防止重复触发) + self.LastSignDate = ConfigItem( + "GameSignAccount", "LastSignDate", "2000-01-01", DateTimeValidator("%Y-%m-%d") + ) + + super().__init__() + + class ToolsConfig(ConfigBase): """工具配置""" @@ -2583,8 +2617,62 @@ def __init__(self) -> None: VirtualConfigValidator(self.arknights_pc_status), ) + ## GameSign - 启用签到 + self.GameSign_Enabled = ConfigItem( + "GameSign", "Enabled", False, BoolValidator() + ) + ## GameSign - 签到后发送通知 + self.GameSign_NotifyEnabled = ConfigItem( + "GameSign", "NotifyEnabled", False, BoolValidator() + ) + ## GameSign - 签到窗口起点 (HH:mm) + self.GameSign_WindowStart = ConfigItem( + "GameSign", "WindowStart", "08:00", DateTimeValidator("%H:%M") + ) + ## GameSign - 签到窗口终点 (HH:mm) + self.GameSign_WindowEnd = ConfigItem( + "GameSign", "WindowEnd", "22:00", DateTimeValidator("%H:%M") + ) + ## GameSign - 启动时运行 + self.GameSign_RunOnStartup = ConfigItem( + "GameSign", "RunOnStartup", False, BoolValidator() + ) + ## GameSign - 定时运行 + self.GameSign_ScheduledRun = ConfigItem( + "GameSign", "ScheduledRun", True, BoolValidator() + ) + ## GameSign - 是否立即开始 + self.GameSign_AutoStart = ConfigItem( + "GameSign", "AutoStart", False, BoolValidator() + ) + ## GameSign - 账号组 (MultipleConfig) + self.GameSign_Accounts = MultipleConfig([GameSignAccountGroup]) + ## GameSign - 上次签到日期 (防止重复触发) + self.GameSign_LastSignDate = ConfigItem( + "GameSign", "LastSignDate", "2000-01-01", DateTimeValidator("%Y-%m-%d") + ) + ## GameSign - 今日签到随机时间点 (HH:mm) + self.GameSign_ScheduledTime = ConfigItem( + "GameSign", "ScheduledTime", "", StringValidator() + ) + ## GameSign - 签到状态标签 (虚拟字段) + self.GameSign_Status = ConfigItem( + "GameSign", + "Status", + "-", + VirtualConfigValidator(self.game_sign_status), + ) + ## GameSign - 签到结果详情 (虚拟字段) + self.GameSign_Result = ConfigItem( + "GameSign", + "Result", + "{}", + VirtualConfigValidator(self.game_sign_result), + ) + self.arknights_pc_running = False self.arknights_pc_get_connected: Callable[[], bool] = lambda: False + self._game_sign_result_data: dict = {} super().__init__() @@ -2621,6 +2709,18 @@ def arknights_pc_keys(self) -> list[str]: ) ] + def game_sign_status(self) -> str: + """游戏签到状态标签""" + + if not self.get("GameSign", "Enabled"): + return TagItem(text="未启用", color="gray").model_dump_json() + return TagItem(text="已启用", color="green").model_dump_json() + + def game_sign_result(self) -> str: + """游戏签到结果 JSON""" + + return json.dumps(self._game_sign_result_data, ensure_ascii=False) + class GlobalConfig(ConfigBase): """全局配置""" diff --git a/app/models/schema.py b/app/models/schema.py index c960a9bce..4b8136133 100644 --- a/app/models/schema.py +++ b/app/models/schema.py @@ -131,10 +131,77 @@ class ToolsConfig_ArknightsPC(BaseModel): Status: str | None = Field(default=None, description="工具状态 Tag") +class ToolsConfig_GameSign(BaseModel): + Enabled: bool | None = Field(default=None, description="是否启用游戏签到") + NotifyEnabled: bool | None = Field(default=None, description="签到后是否发送通知") + WindowStart: str | None = Field(default=None, description="签到窗口起点 HH:mm") + WindowEnd: str | None = Field(default=None, description="签到窗口终点 HH:mm") + RunOnStartup: bool | None = Field(default=None, description="启动时运行") + ScheduledRun: bool | None = Field(default=None, description="定时运行") + AutoStart: bool | None = Field(default=None, description="是否立即开始") + LastSignDate: str | None = Field(default=None, description="上次签到日期") + ScheduledTime: str | None = Field(default=None, description="今日计划签到时间") + Status: str | None = Field(default=None, description="签到状态标签") + Result: str | None = Field(default=None, description="签到结果 JSON") + + +class GameSignAccountGroupConfig(BaseModel): + """游戏签到账号组配置""" + + Name: str | None = Field(default=None, description="账号组名称") + Enabled: bool | None = Field(default=None, description="是否启用") + MiyousheToken: str | None = Field(default=None, description="米游社登录凭证") + KuroToken: str | None = Field(default=None, description="库街区登录凭证") + SklandToken: str | None = Field(default=None, description="森空岛登录凭证") + + +class GameSignAccountCreateOut(OutBase): + """游戏签到账号组创建响应""" + + accountId: str = Field(default="", description="账号组 UUID") + data: GameSignAccountGroupConfig = Field( + default_factory=GameSignAccountGroupConfig, description="账号组配置" + ) + + +class GameSignAccountGetIn(BaseModel): + """游戏签到账号组查询请求""" + + accountId: str = Field(..., description="账号组 UUID") + + +class GameSignAccountsListOut(OutBase): + """游戏签到账号组列表响应""" + + data: Dict[str, Any] = Field(default_factory=dict, description="账号组列表") + + +class GameSignAccountUpdateIn(BaseModel): + """游戏签到账号组更新请求""" + + accountId: str = Field(..., description="账号组 UUID") + data: GameSignAccountGroupConfig = Field(..., description="账号组配置") + + +class GameSignAccountDeleteIn(BaseModel): + """游戏签到账号组删除请求""" + + accountId: str = Field(..., description="账号组 UUID") + + +class GameSignAccountReorderIn(BaseModel): + """游戏签到账号组排序请求""" + + order: list[str] = Field(..., description="账号组 UUID 顺序列表") + + class ToolsConfig(BaseModel): ArknightsPC: ToolsConfig_ArknightsPC | None = Field( default=None, description="明日方舟PC工具配置" ) + GameSign: ToolsConfig_GameSign | None = Field( + default=None, description="游戏社区签到配置" + ) class WebhookIndexItem(BaseModel): diff --git a/app/tools/__init__.py b/app/tools/__init__.py index e4668fad4..128b27bd6 100644 --- a/app/tools/__init__.py +++ b/app/tools/__init__.py @@ -20,5 +20,6 @@ from .skland import skland_sign_in +from .game_sign import run_all_sign_in, format_sign_results -__all__ = ["skland_sign_in"] +__all__ = ["skland_sign_in", "run_all_sign_in", "format_sign_results"] diff --git a/app/tools/game_sign.py b/app/tools/game_sign.py new file mode 100644 index 000000000..36e3049cc --- /dev/null +++ b/app/tools/game_sign.py @@ -0,0 +1,318 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025-2026 AUTO-MAS Team + +# This file is part of AUTO-MAS. + +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. + +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +# Contact: DLmaster_361@163.com + + +import asyncio +import time +from datetime import datetime + +import httpx + +from app.core import Config +from app.utils.logger import get_logger +from .game_sign_result import build_skland_sign_results + +logger = get_logger("游戏社区签到") + + +async def _check_system_time() -> bool: + """校准系统时间,避免因时间偏差导致签到失败 + + Returns: + True: 时间正常; False: 偏差过大 + """ + try: + async with httpx.AsyncClient(proxy=Config.proxy) as client: + resp = await client.get( + "http://worldtimeapi.org/api/timezone/Asia/Shanghai", timeout=5 + ) + api_time = resp.json().get("unixtime", 0) + local_time = time.time() + offset = abs(api_time - local_time) + if offset > 300: + logger.warning(f"系统时间偏差 {offset:.0f} 秒,签到可能失败,请校准系统时间") + return False + if offset > 30: + logger.info(f"系统时间偏差 {offset:.0f} 秒,在可接受范围内") + return True + except Exception as e: + logger.debug(f"时间校准跳过: {e}") + return True + + +async def run_all_sign_in(force: bool = False) -> list[dict]: + """执行所有已配置平台的签到 + + 遍历所有账号组,对每个账号组中有配置的平台执行签到 + + Args: + force: 为 True 时忽略每用户 LastSignDate,强制重新签到(手动签到用) + + Returns: + 签到结果列表,每项包含 account, game, platform, status, reward, reason + """ + results = [] + today = datetime.now().strftime("%Y-%m-%d") + + # 时间校准:偏差过大时跳过本轮签到,避免因时间错误导致 API 失败 + if not await _check_system_time(): + logger.warning("系统时间偏差过大,跳过本轮游戏社区签到") + return results + + for uid, account in Config.ToolsConfig.GameSign_Accounts.items(): + account_name = account.get("GameSignAccount", "Name") or "默认账号" + account_enabled = account.get("GameSignAccount", "Enabled") + account_uid = str(uid) + enabled_platforms = [] + + # 跳过已禁用的用户 + if not account_enabled: + continue + + # 非强制模式:跳过今日已签到的用户 + if not force: + user_last_sign = account.get("GameSignAccount", "LastSignDate") + if user_last_sign == today: + logger.debug(f"[{account_name}] 今日已签到,跳过") + continue + + # 森空岛签到(方舟 + 终末地,一次调用获取两个游戏结果) + skland_token = account.get("GameSignAccount", "SklandToken") + if skland_token: + enabled_platforms.append("森空岛") + logger.info(f"[{account_name}] 开始森空岛签到") + try: + from .skland import skland_sign_in + + skland_results = await skland_sign_in(skland_token, app_code="all") + if not any( + game_key in skland_results + for game_key in ("arknights", "endfield") + ): + results.extend( + build_skland_sign_results( + skland_results, + account_name=account_name, + account_uid=account_uid, + ) + ) + skland_results = {} + # 按游戏分组的结果:{"arknights": {成功/重复/失败}, "endfield": {成功/重复/失败}} + game_mapping = { + "arknights": "明日方舟", + "endfield": "终末地", + } + for game_key, game_name in game_mapping.items(): + game_data = skland_results.get(game_key, {}) + if not game_data: + continue + for item in game_data.get("成功", []): + results.append({ + "account": item if isinstance(item, str) else str(item), + "account_uid": account_uid, + "game": game_name, + "platform": "森空岛", + "status": "成功", + "reward": "", + "reason": "", + }) + for item in game_data.get("重复", []): + results.append({ + "account": item if isinstance(item, str) else str(item), + "account_uid": account_uid, + "game": game_name, + "platform": "森空岛", + "status": "已签到", + "reward": "", + "reason": "", + }) + for item in game_data.get("失败", []): + results.append({ + "account": item if isinstance(item, str) else str(item), + "account_uid": account_uid, + "game": game_name, + "platform": "森空岛", + "status": "失败", + "reward": "", + "reason": "签到失败", + }) + + except Exception as e: + logger.error(f"[{account_name}] 森空岛签到异常: {e}") + results.append({ + "account": f"{account_name}/森空岛", + "account_uid": account_uid, + "game": "森空岛", + "platform": "森空岛", + "status": "失败", + "reward": "", + "reason": str(e), + }) + + # 米游社签到 + miyoushe_token = account.get("GameSignAccount", "MiyousheToken") + if miyoushe_token: + enabled_platforms.append("米游社") + logger.info(f"[{account_name}] 开始米游社签到") + try: + from .miyoushe import miyoushe_sign_in + + miyoushe_results = await miyoushe_sign_in(miyoushe_token) + for item in miyoushe_results: + item["account"] = account_name + item["account_uid"] = account_uid + results.extend(miyoushe_results) + except Exception as e: + logger.error(f"[{account_name}] 米游社签到异常: {e}") + results.append({ + "account": f"{account_name}/米游社", + "account_uid": account_uid, + "game": "米游社", + "platform": "米游社", + "status": "失败", + "reward": "", + "reason": str(e), + }) + + # 库街区签到 + kuro_token = account.get("GameSignAccount", "KuroToken") + if kuro_token: + enabled_platforms.append("库街区") + logger.info(f"[{account_name}] 开始库街区签到") + try: + from .kuro import kuro_sign_in + + kuro_results = await kuro_sign_in(kuro_token) + for item in kuro_results: + item["account"] = account_name + item["account_uid"] = account_uid + results.extend(kuro_results) + except Exception as e: + logger.error(f"[{account_name}] 库街区签到异常: {e}") + results.append({ + "account": f"{account_name}/库街区", + "account_uid": account_uid, + "game": "库街区", + "platform": "库街区", + "status": "失败", + "reward": "", + "reason": str(e), + }) + + # 所有已配置平台均完成后,才标记该用户今日已签到 + signed_platforms = { + r.get("platform") + for r in results + if r.get("account_uid") == account_uid + and r.get("status") in ("成功", "已签到") + } + if enabled_platforms and set(enabled_platforms).issubset(signed_platforms): + try: + await account.set("GameSignAccount", "LastSignDate", today) + except Exception: + pass + + if not results: + logger.info("没有配置任何签到平台") + + return results + + +def merge_sign_results(existing: dict, formatted: dict, replace: bool = False) -> dict: + """将新签到结果合并到已有结果中 + + Args: + existing: 已有的 _game_sign_result_data + formatted: 本次 format_sign_results 的新结果 + replace: True 时按 account_uid 替换旧数据(手动签到用); + False 时仅追加不存在的 account_uid + + Returns: + 合并后的 _game_sign_result_data + """ + if not existing: + return formatted + + for platform, accounts in formatted.items(): + if platform not in existing: + existing[platform] = accounts + elif replace: + new_uids = {g.get("account_uid") for g in accounts if g.get("account_uid")} + if new_uids: + existing[platform] = [ + g for g in existing[platform] + if g.get("account_uid") not in new_uids + ] + existing[platform].extend(accounts) + else: + # 自动签到也按 platform + account_uid 替换整组结果,避免失败/风控 + # 状态被旧的成功结果遮蔽;同 uid 多账号组需批量替换后一次性追加。 + new_uids = {g.get("account_uid") for g in accounts if g.get("account_uid")} + if new_uids: + existing[platform] = [ + g for g in existing[platform] + if g.get("account_uid") not in new_uids + ] + existing[platform].extend(accounts) + + return existing + + +def format_sign_results(results: list[dict]) -> dict: + """将签到结果格式化为前端可展示的结构 + + 按平台分组,平台内按别名去重 + + Returns: + {platform: [{account_alias, account_uid, games: [{game, status, reward, reason}]}]} + """ + platforms: dict[str, dict[str, dict]] = {} + + for item in results: + platform = item.get("platform", "未知") + account = item.get("account", "未知") + account_uid = item.get("account_uid", "") + # 别名 = account 中 '/' 前的部分 + alias = account.split("/")[0] if "/" in account else account + + if platform not in platforms: + platforms[platform] = {} + + if alias not in platforms[platform]: + platforms[platform][alias] = { + "account_alias": alias, + "account_uid": account_uid, + "games": [], + } + + platforms[platform][alias]["games"].append({ + "game": item.get("game", "未知"), + "status": item.get("status", "失败"), + "reward": item.get("reward", ""), + "reason": item.get("reason", ""), + }) + + # 转为列表格式 + result = {} + for platform, accounts in platforms.items(): + result[platform] = list(accounts.values()) + + return result diff --git a/app/tools/game_sign_notify.py b/app/tools/game_sign_notify.py new file mode 100644 index 000000000..431dafa47 --- /dev/null +++ b/app/tools/game_sign_notify.py @@ -0,0 +1,168 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025-2026 AUTO-MAS Team + +# This file is part of AUTO-MAS. + +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. + +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +# Contact: DLmaster_361@163.com + + +from app.core import Config +from app.services import Notify +from app.utils.logger import get_logger + +logger = get_logger("游戏签到通知") + + +async def push_game_sign_notification(results: list[dict]) -> None: + """推送游戏签到结果通知 + + 遵循 Skland-Sign-In 通知格式风格: + - 标题:📅 游戏社区签到 + - 按别名分组:No.{别名}: + - 成功:✅ 游戏名: 成功 (奖励) + - 失败:❌ 游戏名: 失败 (原因) + - 已签到:✅ 游戏名: 已签 + - 底部:AUTO-MAS 敬上 + + Args: + results: 签到结果列表 + """ + if not results: + return + + title = "📅 游戏社区签到" + + # 按别名分组(别名 = account 中 '/' 前的部分) + grouped: dict[str, list[dict]] = {} + for item in results: + account = item.get("account", "未知") + alias = account.split("/")[0] if "/" in account else account + if alias not in grouped: + grouped[alias] = [] + grouped[alias].append(item) + + # 构建纯文本消息 + lines = [] + success_count = 0 + fail_count = 0 + + for alias, items in grouped.items(): + lines.append(f"No.{alias}:") + for item in items: + game = item.get("game", "未知") + status = item.get("status", "失败") + reward = item.get("reward", "") + reason = item.get("reason", "") + + if status == "成功": + reward_text = f" ({reward})" if reward else "" + lines.append(f" ✅ {game}: 成功{reward_text}") + success_count += 1 + elif status == "已签到": + lines.append(f" ✅ {game}: 已签") + success_count += 1 + else: + reason_text = f" ({reason})" if reason else "" + lines.append(f" ❌ {game}: 失败{reason_text}") + fail_count += 1 + + lines.append(f"\n共 {len(grouped)} 个账号,成功 {success_count},失败 {fail_count}") + lines.append("AUTO-MAS 敬上") + + plain_text = "\n".join(lines) + + # 构建 HTML 版本(用于邮件) + html_lines = [] + for alias, items in grouped.items(): + html_lines.append(f'

No.{alias}:

') + html_lines.append('
    ') + for item in items: + game = item.get("game", "未知") + status = item.get("status", "失败") + reward = item.get("reward", "") + reason = item.get("reason", "") + + if status == "成功": + reward_text = f" ({reward})" if reward else "" + html_lines.append( + f'
  • ' + f"{game}: 成功{reward_text}
  • " + ) + elif status == "已签到": + html_lines.append( + f'
  • ' + f"{game}: 已签
  • " + ) + else: + reason_text = f" ({reason})" if reason else "" + html_lines.append( + f'
  • ' + f"{game}: 失败{reason_text}
  • " + ) + html_lines.append('
') + + html_lines.append( + f"

共 {len(grouped)} 个账号,成功 {success_count},失败 {fail_count}

" + ) + html_lines.append("

AUTO-MAS 敬上

") + html_content = "".join(html_lines) + + # 分发到所有已启用的渠道 + try: + # 系统通知 + await Notify.push_plyer(title, plain_text, title, 5) + except Exception as e: + logger.warning(f"推送系统通知失败: {e}") + + try: + # 邮件通知 + if Config.get("Notify", "IfSendMail"): + to_address = Config.get("Notify", "ToAddress") + if to_address: + await Notify.send_mail("网页", title, html_content, to_address) + except Exception as e: + logger.warning(f"推送邮件通知失败: {e}") + + try: + # Server酱通知 + if Config.get("Notify", "IfServerChan"): + send_key = Config.get("Notify", "ServerChanKey") + if send_key: + await Notify.ServerChanPush(title, plain_text, send_key) + except Exception as e: + logger.warning(f"推送Server酱通知失败: {e}") + + try: + # Webhook 通知 + for uid, webhook in Config.Notify_CustomWebhooks.items(): + if webhook.get("Info", "Enabled"): + try: + await Notify.WebhookPush(title, plain_text, webhook) + except Exception as e: + logger.warning(f"推送 Webhook {uid} 通知失败: {e}") + except Exception as e: + logger.warning(f"推送Webhook通知失败: {e}") + + try: + # Koishi 通知 + if Config.get("Notify", "IfKoishiSupport"): + await Notify.send_koishi(plain_text) + except Exception as e: + logger.warning(f"推送Koishi通知失败: {e}") diff --git a/app/tools/game_sign_result.py b/app/tools/game_sign_result.py new file mode 100644 index 000000000..5ed40c799 --- /dev/null +++ b/app/tools/game_sign_result.py @@ -0,0 +1,51 @@ +SKLAND_GAME_MAPPING = { + "arknights": "明日方舟", + "endfield": "终末地", +} + + +def build_skland_sign_results( + raw_result: dict, + *, + account_name: str, + account_uid: str, +) -> list[dict]: + """将森空岛返回值归一化为游戏社区签到结果。""" + results = [] + status_mapping = { + "成功": "成功", + "重复": "已签到", + "失败": "失败", + } + + if any(game_key in raw_result for game_key in SKLAND_GAME_MAPPING): + for game_key, game_name in SKLAND_GAME_MAPPING.items(): + game_result = raw_result.get(game_key, {}) + for source_status, status in status_mapping.items(): + for item in game_result.get(source_status, []): + results.append( + { + "account": item if isinstance(item, str) else str(item), + "account_uid": account_uid, + "game": game_name, + "platform": "森空岛", + "status": status, + "reward": "", + "reason": "签到失败" if status == "失败" else "", + } + ) + return results + + failures = raw_result.get("失败", []) + reason = str(failures[0]) if failures else "未返回可识别的签到结果" + return [ + { + "account": f"{account_name}/森空岛", + "account_uid": account_uid, + "game": "森空岛", + "platform": "森空岛", + "status": "失败", + "reward": "", + "reason": reason, + } + ] diff --git a/app/tools/kuro.py b/app/tools/kuro.py new file mode 100644 index 000000000..b58fa147e --- /dev/null +++ b/app/tools/kuro.py @@ -0,0 +1,303 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025-2026 AUTO-MAS Team + +# This file incorporates work covered by the following copyright and +# permission notice: +# +# Kuro-autosignin Copyright © 2024 mxyooR +# https://github.com/mxyooR/Kuro-autosignin +# +# Kuro-API-Collection Copyright © 2024 TomyJan +# https://github.com/TomyJan/Kuro-API-Collection + +# This file is part of AUTO-MAS. + +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. + +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +# Contact: DLmaster_361@163.com + + +import uuid +import asyncio +import httpx +from datetime import datetime + +from typing import Dict, Any + +from app.core import Config +from app.utils.logger import get_logger + +logger = get_logger("库街区签到任务") + + +# ==================== 常量 ==================== + +# API 端点 +USER_INFO_URL = "https://api.kurobbs.com/user/mineV2" +ROLE_LIST_URL = "https://api.kurobbs.com/user/role/findRoleList" +SIGN_URL = "https://api.kurobbs.com/encourage/signIn/v2" + +# 游戏配置 +GAME_CONFIG = { + "2": {"name": "战双帕弥什"}, + "3": {"name": "鸣潮"}, +} + +# 请求头模板 +BBS_HEADERS = { + "User-Agent": "Kuro/2.2.0 (Android;13) okhttp/4.11.0", + "Host": "api.kurobbs.com", + "Connection": "keep-alive", + "Accept-Encoding": "gzip", + "Content-Type": "application/x-www-form-urlencoded", +} + +GAME_HEADERS = { + "User-Agent": "Kuro/2.2.0 (Android;13) okhttp/4.11.0", + "Host": "api.kurobbs.com", + "Connection": "keep-alive", + "Accept-Encoding": "gzip", + "Content-Type": "application/x-www-form-urlencoded", + "devCode": "", +} + + +# ==================== 签到主流程 ==================== + + +async def kuro_sign_in(token: str, proxy: str | None = None) -> list[dict]: + """库街区游戏签到 + + Args: + token: 库街区 JWT Token 字符串 + proxy: 代理地址 + + Returns: + 签到结果列表,每项包含 account, game, platform, status, reward, reason + """ + results = [] + + if not token or not token.strip(): + logger.error("库街区 Token 为空") + return [{ + "account": "未知/库街区", + "game": "库街区", + "platform": "库街区", + "status": "失败", + "reward": "", + "reason": "Token 为空", + }] + + token = token.strip() + dev_code = str(uuid.uuid4()) + distinct_id = str(uuid.uuid4()) + + # 获取用户信息 + try: + user_info = await _get_user_info(token, dev_code, distinct_id) + except Exception as e: + logger.error(f"获取库街区用户信息失败: {e}") + return [{ + "account": "未知/库街区", + "game": "库街区", + "platform": "库街区", + "status": "失败", + "reward": "", + "reason": f"获取用户信息失败: {e}", + }] + + user_id = user_info.get("userId", "") + nick_name = user_info.get("nickName", user_id) + + # 获取游戏角色列表 + try: + roles = await _get_role_list(token, dev_code, distinct_id, user_id) + except Exception as e: + logger.error(f"获取库街区游戏角色失败: {e}") + return [{ + "account": f"{nick_name}/库街区", + "game": "库街区", + "platform": "库街区", + "status": "失败", + "reward": "", + "reason": f"获取角色列表失败: {e}", + }] + + if not roles: + logger.warning("未找到库街区绑定的游戏角色") + return results + + # 逐游戏签到 + for role in roles: + game_id = str(role.get("gameId", "")) + server_id = role.get("serverId", "") + role_id = role.get("roleId", "") + role_name = role.get("roleName", "") + + game_cfg = GAME_CONFIG.get(game_id) + if not game_cfg: + continue + + # account 格式: 别名/角色名(角色ID) + account = f"{nick_name}/{role_name}({role_id})" if role_id else f"{nick_name}/库街区" + + # 执行签到 + try: + sign_result = await _do_sign( + token, dev_code, game_id, server_id, role_id, user_id, account, game_cfg + ) + results.append(sign_result) + except Exception as e: + results.append({ + "account": account, + "game": game_cfg["name"], + "platform": "库街区", + "status": "失败", + "reward": "", + "reason": str(e), + }) + logger.error(f"{account} {game_cfg['name']} 签到异常: {e}") + + # 间隔 + await asyncio.sleep(3) + + return results + + +async def _get_user_info( + token: str, dev_code: str, distinct_id: str +) -> dict: + """获取用户信息""" + headers = BBS_HEADERS.copy() + headers["token"] = token + headers["devcode"] = dev_code + headers["distinct_id"] = distinct_id + + async with httpx.AsyncClient(proxy=Config.proxy) as client: + response = await client.post( + USER_INFO_URL, + headers=headers, + data="", + timeout=30.0, + ) + rsp = response.json() + + if rsp.get("code") != 200: + raise Exception(f"获取用户信息失败: {rsp.get('msg', rsp.get('message', ''))}") + + return rsp.get("data", {}) + + +async def _get_role_list( + token: str, dev_code: str, distinct_id: str, user_id: str +) -> list[dict]: + """获取游戏角色列表""" + headers = BBS_HEADERS.copy() + headers["token"] = token + headers["devcode"] = dev_code + headers["distinct_id"] = distinct_id + + all_roles = [] + + for game_id in GAME_CONFIG: + async with httpx.AsyncClient(proxy=Config.proxy) as client: + response = await client.post( + ROLE_LIST_URL, + headers=headers, + data=f"gameId={game_id}&userId={user_id}", + timeout=30.0, + ) + rsp = response.json() + + if rsp.get("code") != 200: + logger.warning(f"获取 gameId={game_id} 角色失败: {rsp.get('msg', '')}") + continue + + for role in rsp.get("data", []): + role["gameId"] = game_id + all_roles.append(role) + + return all_roles + + +async def _do_sign( + token: str, + dev_code: str, + game_id: str, + server_id: str, + role_id: str, + user_id: str, + account: str, + game_cfg: dict, +) -> dict: + """执行库街区签到""" + + headers = GAME_HEADERS.copy() + headers["token"] = token + headers["devcode"] = f"{dev_code}, Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) " + + req_month = datetime.now().strftime("%m") + body = f"gameId={game_id}&serverId={server_id}&roleId={role_id}&userId={user_id}&reqMonth={req_month}" + + async with httpx.AsyncClient(proxy=Config.proxy) as client: + response = await client.post( + SIGN_URL, + headers=headers, + data=body, + timeout=30.0, + ) + rsp = response.json() + + code = rsp.get("code", -1) + + if code == 200: + # 尝试获取奖励 + reward = "" + data = rsp.get("data", {}) + if isinstance(data, dict): + reward_name = data.get("rewardName", "") + reward_cnt = data.get("rewardCnt", 1) + if reward_name: + reward = f"{reward_name}x{reward_cnt}" + logger.info(f"{account} {game_cfg['name']} 签到成功") + return { + "account": account, + "game": game_cfg["name"], + "platform": "库街区", + "status": "成功", + "reward": reward, + "reason": "", + } + elif code == 1511: + # 今日已签到 + return { + "account": account, + "game": game_cfg["name"], + "platform": "库街区", + "status": "已签到", + "reward": "", + "reason": "", + } + else: + message = rsp.get("msg", rsp.get("message", f"错误码 {code}")) + logger.error(f"{account} {game_cfg['name']} 签到失败: {message}") + return { + "account": account, + "game": game_cfg["name"], + "platform": "库街区", + "status": "失败", + "reward": "", + "reason": message, + } diff --git a/app/tools/miyoushe.py b/app/tools/miyoushe.py new file mode 100644 index 000000000..ec80cb128 --- /dev/null +++ b/app/tools/miyoushe.py @@ -0,0 +1,785 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025-2026 AUTO-MAS Team + +# This file incorporates work covered by the following copyright and +# permission notice: +# +# nonebot-plugin-mystool Copyright © 2023-2025 Ljzd-PRO +# https://github.com/Ljzd-PRO/nonebot-plugin-mystool +# +# MYS_Game_Singin Copyright © 2023 GildedFlames +# https://github.com/GildedFlames/MYS_Game_Singin + +# This file is part of AUTO-MAS. + +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. + +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +# Contact: DLmaster_361@163.com + + +import asyncio +import hashlib +import json +import random +import string +import time +import uuid + +import httpx + +from typing import Dict + +from app.core import Config +from app.utils.logger import get_logger + +logger = get_logger("米游社签到任务") + + +# ==================== 常量 ==================== + +# 签到 API(对齐参考项目域名) +ROLES_URL = "https://api-takumi.mihoyo.com/binding/api/getUserGameRolesByCookie" +SIGN_URL = "https://api-takumi.mihoyo.com/event/luna/sign" +ZZZ_SIGN_URL = "https://act-nap-api.mihoyo.com/event/luna/zzz/sign" +ZZZ_INFO_URL = "https://act-nap-api.mihoyo.com/event/luna/zzz/info" +HOME_URL = "https://api-takumi.mihoyo.com/event/luna/home" +INFO_URL = "https://api-takumi.mihoyo.com/event/luna/info" + +# Passport Token 派生 API(对齐 MihoyoBBSTools 域名) +PASSPORT_COOKIE_URL = ( + "https://api-takumi.mihoyo.com/auth/api/getCookieAccountInfoBySToken" +) + +# DS 签名 Salt(对齐 MihoyoBBSTools) +SALT_WEB = "DlOUwIupfU6YespEUWDJmXtutuXV6owG" # web 端 salt(游戏签到 GET 请求) +SALT_DATA = "t0qEgfub6cvueAPgR5m9aQWWVciEer7v" # 有 body/query 的请求(x6) + +# 验证码重试配置 +CAPTCHA_MAX_RETRIES = 3 +CAPTCHA_RETRY_DELAY = (6, 15) # 秒,随机范围 + +# 游戏配置(整合 MihoyoBBSTools 全部支持的游戏) +GAME_CONFIG = { + "hk4e_cn": { + "name": "原神", + "act_id": "e202311201442471", + "sign_url": SIGN_URL, + "signgame": "hk4e", + "extra_headers": { + "x-rpc-signgame": "hk4e", + "Origin": "https://act.mihoyo.com", + "Referer": "https://act.mihoyo.com/", + }, + }, + "hkrpg_cn": { + "name": "星穹铁道", + "act_id": "e202304121516551", + "sign_url": SIGN_URL, + "signgame": "", + "extra_headers": { + "Origin": "https://act.mihoyo.com", + "Referer": "https://act.mihoyo.com/", + }, + }, + "nap_cn": { + "name": "绝区零", + "act_id": "e202406242138391", + "sign_url": ZZZ_SIGN_URL, + "info_url": ZZZ_INFO_URL, + "signgame": "zzz", + "extra_headers": { + "x-rpc-signgame": "zzz", + "Origin": "https://act.mihoyo.com", + "Referer": "https://act.mihoyo.com/", + "Host": "act-nap-api.mihoyo.com", + }, + }, + "bh2_cn": { + "name": "崩坏学园2", + "act_id": "e202203291431091", + "sign_url": SIGN_URL, + "signgame": "", + "extra_headers": { + "Origin": "https://act.mihoyo.com", + "Referer": "https://act.mihoyo.com/", + }, + }, + "bh3_cn": { + "name": "崩坏3", + "act_id": "e202306201626331", + "sign_url": SIGN_URL, + "signgame": "", + "extra_headers": { + "Origin": "https://act.mihoyo.com", + "Referer": "https://act.mihoyo.com/", + }, + }, + "nxx_cn": { + "name": "未定事件簿", + "act_id": "e202202251749321", + "sign_url": SIGN_URL, + "signgame": "", + "extra_headers": { + "Origin": "https://act.mihoyo.com", + "Referer": "https://act.mihoyo.com/", + }, + }, +} + +# 通用请求头(对齐 MihoyoBBSTools 游戏签到 headers) +BASE_HEADERS = { + "Accept": "application/json, text/plain, */*", + "Origin": "https://webstatic.mihoyo.com", + "x-rpc-app_version": "2.99.1", + "User-Agent": "Mozilla/5.0 (Linux; Android 12; Unspecified Device) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Version/4.0 Chrome/103.0.5060.129 Mobile Safari/537.36 " + "miHoYoBBS/2.99.1", + "x-rpc-client_type": "5", + "Referer": "https://act.mihoyo.com/", + "Accept-Encoding": "gzip, deflate", + "Accept-Language": "zh-CN,en-US;q=0.8", + "X-Requested-With": "com.mihoyo.hyperion", + "x-rpc-channel": "miyousheluodi", + "Connection": "keep-alive", + "Content-Type": "application/json;charset=utf-8", +} + + +# ==================== 工具函数 ==================== + + +class _RiskControlError(Exception): + """风控异常:API 返回空响应或非 JSON 内容""" + pass + + +def _safe_json_parse(response: httpx.Response) -> dict: + """安全解析 API 响应 JSON + + 当响应为空或非 JSON 时(通常是风控拦截),抛出 _RiskControlError。 + + Args: + response: httpx 响应对象 + + Returns: + 解析后的 JSON 字典 + + Raises: + _RiskControlError: 响应为空或非 JSON(疑似风控) + """ + text = response.text.strip() + if not text: + raise _RiskControlError("API 返回空响应,疑似被风控") + try: + return response.json() + except Exception: + raise _RiskControlError(f"API 返回非 JSON 内容,疑似被风控: {text[:100]}") + + +def _parse_cookie(cookie_str: str) -> Dict[str, str]: + """解析 cookie 字符串为字典""" + cookies: Dict[str, str] = {} + for item in cookie_str.split(";"): + item = item.strip() + if "=" in item: + key, value = item.split("=", 1) + cookies[key.strip()] = value.strip() + return cookies + + +def _build_cookie_str(cookies: Dict[str, str]) -> str: + """将 cookie 字典序列化为字符串""" + return "; ".join(f"{k}={v}" for k, v in cookies.items()) + + +def _generate_ds(body: str = "", query: str = "") -> str: + """生成 DS (Dynamic Secret) 签名 + + 对齐 MihoyoBBSTools:游戏签到 GET 请求使用 SALT_WEB, + POST 请求(有 body)使用 SALT_DATA。 + + Args: + body: 请求体 JSON 字符串 + query: URL 查询参数字符串 + + Returns: + DS 签名字符串,格式 "t,r,md5hash" + """ + t = str(int(time.time())) + r = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) + + if body or query: + salt = SALT_DATA + raw = f"salt={salt}&t={t}&r={r}&b={body}&q={query}" + else: + salt = SALT_WEB + raw = f"salt={salt}&t={t}&r={r}" + + md5_hash = hashlib.md5(raw.encode()).hexdigest() + return f"{t},{r},{md5_hash}" + + +def _generate_device_id(cookie: str) -> str: + """从 cookie 生成确定性 device_id + + 使用 UUID3 基于 cookie 内容生成,同一 cookie 始终得到同一 device_id, + 与 MihoyoBBSTools 的设备指纹策略一致。 + + Args: + cookie: cookie 字符串 + + Returns: + 大写的 UUID 字符串 + """ + return str(uuid.uuid3(uuid.NAMESPACE_URL, cookie)) + + +def _get_stuid(cookies: Dict[str, str]) -> str: + """从 cookie 中提取米游社 UID + + 按优先级依次检查:stuid, ltuid, account_id, login_uid, + ltuid_v2, account_id_v2 + """ + for key in ( + "stuid", "ltuid", "account_id", "login_uid", + "ltuid_v2", "account_id_v2", + ): + if key in cookies and cookies[key]: + return cookies[key] + return "" + + +def _ensure_uid_aliases(cookies: Dict[str, str], uid: str) -> None: + """补全所有 UID 别名字段,确保 API 能识别 + + 米游社 API 可能检查 ltuid、account_id 等特定字段名, + 统一注入所有别名以最大程度兼容。 + """ + for key in ("stuid", "ltuid", "account_id", "login_uid"): + if key not in cookies or not cookies[key]: + cookies[key] = uid + + +# ==================== Token 派生 ==================== + + +async def _derive_cookie_token( + stoken: str, mid: str, stuid: str, proxy: str | None = None, +) -> tuple[str, str]: + """从 stoken_v2 + mid 派生 cookie_token + + 调用 Passport API getCookieAccountInfoBySToken 获取 cookie_token。 + + Args: + stoken: stoken 值(v2 格式,v2_ 前缀) + mid: v2 stoken 的配套 mid 字段 + stuid: 米游社 UID + proxy: 代理地址 + + Returns: + (cookie_token, uid) 元组 + + Raises: + Exception: 派生失败时抛出 + """ + headers = BASE_HEADERS.copy() + headers["x-rpc-device_id"] = _generate_device_id(stoken + mid) + + # stoken_v2 需要搭配 mid 和 stuid + stoken_cookies = { + "stoken": stoken, + "mid": mid, + "stuid": stuid, + } + + async with httpx.AsyncClient(proxy=proxy or Config.proxy) as client: + response = await client.get( + PASSPORT_COOKIE_URL, + headers=headers, + cookies=stoken_cookies, + timeout=30.0, + ) + rsp = _safe_json_parse(response) + + if rsp.get("retcode") != 0: + raise Exception(f"派生 cookie_token 失败: {rsp.get('message')}") + + data = rsp.get("data", {}) + cookie_token = data.get("cookie_token", "") + uid = data.get("uid", "") + if not cookie_token: + raise Exception("派生 cookie_token 失败: 返回数据无 cookie_token") + + logger.debug(f"成功从 stoken 派生 cookie_token, uid={uid}") + return cookie_token, uid + + +# ==================== 签到主流程 ==================== + + +async def miyoushe_sign_in(cookie: str, proxy: str | None = None) -> list[dict]: + """米游社游戏签到 + + 支持多种 cookie 认证策略: + 1. cookie_token + UID → 直接使用 + 2. stoken_v2 + mid + UID → 派生 cookie_token 后使用 + 3. stoken_v1 + UID → 暂不支持派生(需 mid),日志提示 + + Args: + cookie: cookie 字符串,至少包含 UID 字段 + (cookie_token 或 stoken) + proxy: 代理地址 + + Returns: + 签到结果列表,每项包含 account, game, platform, status, reward, reason + """ + results = [] + cookies = _parse_cookie(cookie) + stuid = _get_stuid(cookies) + + if not stuid: + logger.error("Cookie 缺少 UID 字段 (stuid/ltuid/account_id)") + return [{ + "account": "未知/米游社", + "game": "米游社", + "platform": "米游社", + "status": "失败", + "reward": "", + "reason": "Cookie 缺少 UID 字段", + }] + + # ---- 认证策略选择 ---- + effective_cookies = cookies.copy() + + if "cookie_token" in cookies: + # 策略 1: cookie_token + UID,直接使用 + logger.debug("使用 cookie_token + UID 认证") + elif "stoken" in cookies and "mid" in cookies: + # 策略 2: stoken_v2 + mid,派生 cookie_token + logger.info("缺少 cookie_token,尝试从 stoken 派生") + try: + derived_token, derived_uid = await _derive_cookie_token( + cookies["stoken"], cookies["mid"], stuid, proxy, + ) + effective_cookies["cookie_token"] = derived_token + if derived_uid: + _ensure_uid_aliases(effective_cookies, derived_uid) + except Exception as e: + logger.error(f"从 stoken 派生 cookie_token 失败: {e}") + return [{ + "account": f"{stuid}/米游社", + "game": "米游社", + "platform": "米游社", + "status": "失败", + "reward": "", + "reason": f"派生 cookie_token 失败: {e}", + }] + elif "stoken" in cookies: + # 策略 3: stoken_v1 无 mid,无法派生 + logger.error("仅有 v1 stoken 但缺少 mid,无法派生 cookie_token,请补充完整 cookie") + return [{ + "account": f"{stuid}/米游社", + "game": "米游社", + "platform": "米游社", + "status": "失败", + "reward": "", + "reason": "缺少 cookie_token 和 mid,无法完成认证", + }] + else: + logger.error("Cookie 缺少认证字段 (cookie_token 或 stoken)") + return [{ + "account": f"{stuid}/米游社", + "game": "米游社", + "platform": "米游社", + "status": "失败", + "reward": "", + "reason": "Cookie 缺少认证字段 (cookie_token 或 stoken)", + }] + + # 补全 UID 别名 + _ensure_uid_aliases(effective_cookies, stuid) + effective_cookie = _build_cookie_str(effective_cookies) + + # 获取游戏角色列表 + try: + roles = await _get_game_roles(effective_cookie) + except _RiskControlError: + logger.warning(f"获取米游社游戏角色被风控") + return [{ + "account": f"{stuid}/米游社", + "game": "米游社", + "platform": "米游社", + "status": "风控", + "reward": "", + "reason": "账号被风控,接口返回异常", + }] + except Exception as e: + logger.error(f"获取米游社游戏角色失败: {e}") + return [{ + "account": f"{stuid}/米游社", + "game": "米游社", + "platform": "米游社", + "status": "失败", + "reward": "", + "reason": f"获取角色列表失败: {e}", + }] + + if not roles: + logger.warning("未找到米游社绑定的游戏角色") + return results + + # 逐游戏签到 + for role in roles: + game_biz = role.get("game_biz", "") + region = role.get("region", "") + game_uid = role.get("game_uid", "") + nickname = role.get("nickname", "") + + game_cfg = GAME_CONFIG.get(game_biz) + if not game_cfg: + continue + + # account 格式: 别名/昵称(uid) + account = f"{nickname}/{nickname}({game_uid})" if game_uid else f"{nickname}/米游社" + + # 检查今日是否已签到 + try: + is_signed = await _check_sign_info(effective_cookie, game_cfg, region, game_uid) + if is_signed: + results.append({ + "account": account, + "game": game_cfg["name"], + "platform": "米游社", + "status": "已签到", + "reward": "", + "reason": "", + }) + logger.info(f"{account} {game_cfg['name']} 今日已签到") + continue + except _RiskControlError: + results.append({ + "account": account, + "game": game_cfg["name"], + "platform": "米游社", + "status": "风控", + "reward": "", + "reason": "账号被风控,签到接口返回异常", + }) + logger.warning(f"{account} {game_cfg['name']} 账号被风控") + continue + except Exception as e: + logger.warning(f"检查签到状态异常: {e}") + + # 执行签到 + try: + sign_result = await _do_sign(effective_cookie, game_cfg, region, game_uid, account) + results.append(sign_result) + except _RiskControlError: + results.append({ + "account": account, + "game": game_cfg["name"], + "platform": "米游社", + "status": "风控", + "reward": "", + "reason": "账号被风控,签到接口返回异常", + }) + logger.warning(f"{account} {game_cfg['name']} 签到时被风控") + except Exception as e: + results.append({ + "account": account, + "game": game_cfg["name"], + "platform": "米游社", + "status": "失败", + "reward": "", + "reason": str(e), + }) + logger.error(f"{account} {game_cfg['name']} 签到异常: {e}") + + # 间隔防风控(对齐 MihoyoBBSTools:随机 2-8 秒) + await asyncio.sleep(random.uniform(2.0, 8.0)) + + return results + + +async def _get_game_roles(cookie: str) -> list[dict]: + """获取游戏角色列表 + + 兼容两种 API 返回结构: + - 扁平结构: data.list 直接是角色数组 + - 嵌套结构: data.list[].list[] 是角色数组(按游戏分组) + """ + headers = BASE_HEADERS.copy() + headers["DS"] = _generate_ds() + headers["x-rpc-device_id"] = _generate_device_id(cookie) + + async with httpx.AsyncClient(proxy=Config.proxy) as client: + response = await client.get( + ROLES_URL, + headers=headers, + cookies=_parse_cookie(cookie), + timeout=30.0, + ) + rsp = _safe_json_parse(response) + + if rsp.get("retcode") != 0: + raise Exception(f"获取角色列表失败: {rsp.get('message')}") + + data_list = rsp.get("data", {}).get("list", []) + roles = [] + + if not data_list: + return roles + + # 判断数据结构:有 "list" 子键 = 嵌套,否则 = 扁平 + if "list" in data_list[0]: + # 嵌套结构: data.list[].list[] + for item in data_list: + for role in item.get("list", []): + if role.get("game_biz") in GAME_CONFIG: + roles.append(role) + else: + # 扁平结构: data.list[] + for role in data_list: + if role.get("game_biz") in GAME_CONFIG: + roles.append(role) + + return roles + + +async def _check_sign_info( + cookie: str, game_cfg: dict, region: str, uid: str +) -> bool: + """检查今日是否已签到""" + headers = BASE_HEADERS.copy() + query = f"lang=zh-cn&act_id={game_cfg['act_id']}®ion={region}&uid={uid}" + headers["DS"] = _generate_ds(query=query) + headers["x-rpc-device_id"] = _generate_device_id(cookie) + headers.update(game_cfg.get("extra_headers", {})) + + url = f"{game_cfg.get('info_url', INFO_URL)}?{query}" + + async with httpx.AsyncClient(proxy=Config.proxy) as client: + response = await client.get( + url, + headers=headers, + cookies=_parse_cookie(cookie), + timeout=30.0, + ) + rsp = _safe_json_parse(response) + + if rsp.get("retcode") != 0: + return False + + sign_info = rsp.get("data", {}) + is_sign = sign_info.get("is_sign", False) + return is_sign + + +async def _do_sign( + cookie: str, game_cfg: dict, region: str, uid: str, account: str = "" +) -> dict: + """执行签到(含验证码重试) + + 当签到返回极验(Geetest)验证码时,自动重试最多 CAPTCHA_MAX_RETRIES 次。 + 两次重试之间有随机延迟以防触发更严格的风控。 + + Args: + cookie: cookie 字符串 + game_cfg: 游戏配置 + region: 服务器区号 + uid: 游戏 UID + account: 账号标识(如 nickname/nickname(uid)),为空时用 stuid 构造 + """ + cookies = _parse_cookie(cookie) + stuid = _get_stuid(cookies) + if not account: + account = f"{stuid}/{stuid}({uid})" if uid else f"{stuid}/米游社" + + body = json.dumps( + {"act_id": game_cfg["act_id"], "region": region, "uid": uid}, + separators=(",", ":"), + ) + sign_url = game_cfg["sign_url"] + + for attempt in range(CAPTCHA_MAX_RETRIES + 1): + headers = BASE_HEADERS.copy() + headers["DS"] = _generate_ds(body=body) + headers["x-rpc-device_id"] = _generate_device_id(cookie) + headers.update(game_cfg.get("extra_headers", {})) + + async with httpx.AsyncClient(proxy=Config.proxy) as client: + response = await client.post( + sign_url, + headers=headers, + content=body, + cookies=cookies, + timeout=30.0, + ) + rsp = _safe_json_parse(response) + + retcode = rsp.get("retcode", 0) + data = rsp.get("data") or {} + + # ---- 成功 ---- + if retcode == 0: + reward = "" + award = data.get("award", {}) + if award: + reward = f"{award.get('name', '')}x{award.get('cnt', 1)}" + logger.info(f"{account} {game_cfg['name']} 签到成功") + return { + "account": account, + "game": game_cfg["name"], + "platform": "米游社", + "status": "成功", + "reward": reward, + "reason": "", + } + + # ---- 已签到 ---- + if retcode == -5003 or "请勿重复签到" in rsp.get("message", ""): + return { + "account": account, + "game": game_cfg["name"], + "platform": "米游社", + "status": "已签到", + "reward": "", + "reason": "", + } + + # ---- -100: cookie_token 过期,尝试多种方式恢复 ---- + if retcode == -100: + parsed = _parse_cookie(cookie) + has_stoken = "stoken" in parsed + has_v2 = "cookie_token_v2" in parsed + + refreshed = False + + # 方式 1: 用 stoken 派生新 cookie_token + if has_stoken and "mid" in parsed and attempt < CAPTCHA_MAX_RETRIES: + stuid = _get_stuid(parsed) + logger.warning( + f"{account} {game_cfg['name']} cookie_token 过期," + f"尝试用 stoken 刷新" + ) + try: + new_token, new_uid = await _derive_cookie_token( + parsed["stoken"], parsed["mid"], stuid, + ) + parsed["cookie_token"] = new_token + if new_uid: + _ensure_uid_aliases(parsed, new_uid) + cookie = _build_cookie_str(parsed) + refreshed = True + except Exception as e: + logger.error(f"stoken 刷新失败: {e}") + + # 方式 2: 用 cookie_token_v2 替换 cookie_token + if not refreshed and has_v2 and attempt < CAPTCHA_MAX_RETRIES: + logger.info( + f"{account} {game_cfg['name']} 尝试用 cookie_token_v2 替代" + ) + parsed["cookie_token"] = parsed["cookie_token_v2"] + cookie = _build_cookie_str(parsed) + refreshed = True + + # 方式 3: 用 ltoken 替换 cookie_token + if not refreshed and "ltoken" in parsed and attempt < CAPTCHA_MAX_RETRIES: + logger.info( + f"{account} {game_cfg['name']} 尝试用 ltoken 替代" + ) + parsed["cookie_token"] = parsed["ltoken"] + cookie = _build_cookie_str(parsed) + refreshed = True + + if refreshed: + await asyncio.sleep(random.uniform(1.0, 3.0)) + continue + + return { + "account": account, + "game": game_cfg["name"], + "platform": "米游社", + "status": "失败", + "reward": "", + "reason": "请登录后重试(cookie_token 过期,无可用替代 token)", + } + + # ---- 需要验证码(极验风控) ---- + if data.get("gt") and data.get("challenge"): + if attempt < CAPTCHA_MAX_RETRIES: + delay = random.uniform(*CAPTCHA_RETRY_DELAY) + logger.warning( + f"{account} {game_cfg['name']} 触发验证码 " + f"(第 {attempt + 1}/{CAPTCHA_MAX_RETRIES} 次)," + f"等待 {delay:.0f} 秒后重试" + ) + await asyncio.sleep(delay) + continue + else: + logger.error( + f"{account} {game_cfg['name']} 验证码重试耗尽,签到失败" + ) + return { + "account": account, + "game": game_cfg["name"], + "platform": "米游社", + "status": "风控", + "reward": "", + "reason": "触发极验验证码,重试次数耗尽", + } + + # ---- retcode 1034:高频风控 ---- + if retcode == 1034: + if attempt < CAPTCHA_MAX_RETRIES: + delay = random.uniform(*CAPTCHA_RETRY_DELAY) + logger.warning( + f"{account} {game_cfg['name']} 高频风控 (retcode=1034) " + f"(第 {attempt + 1}/{CAPTCHA_MAX_RETRIES} 次)," + f"等待 {delay:.0f} 秒后重试" + ) + await asyncio.sleep(delay) + continue + else: + return { + "account": account, + "game": game_cfg["name"], + "platform": "米游社", + "status": "风控", + "reward": "", + "reason": "高频风控 (retcode=1034),重试次数耗尽", + } + + # ---- 其他失败 ---- + message = rsp.get("message", "未知错误") + logger.error(f"{account} {game_cfg['name']} 签到失败: {message}") + return { + "account": account, + "game": game_cfg["name"], + "platform": "米游社", + "status": "失败", + "reward": "", + "reason": message, + } + + # 理论上不会到这里 + return { + "account": account, + "game": game_cfg["name"], + "platform": "米游社", + "status": "失败", + "reward": "", + "reason": "签到流程异常退出", + } diff --git a/app/tools/miyoushe_qr.py b/app/tools/miyoushe_qr.py new file mode 100644 index 000000000..ba6bf3ae1 --- /dev/null +++ b/app/tools/miyoushe_qr.py @@ -0,0 +1,191 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025-2026 AUTO-MAS Team + +# This file is part of AUTO-MAS. + +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. + +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +# Contact: DLmaster_361@163.com + +""" +米游社扫码登录模块(可选补丁) + +本模块为独立功能,不影响签到核心逻辑。 +可安全删除本文件及 app/api/qr_login.py、前端扫码按钮, +不会影响任何已有功能。 + +扫码流程(Passport 模式,参考 thesadru/genshin.py): + 1. createQRLogin → POST 获取二维码 URL + ticket + 2. queryQRLoginStatus → POST 轮询状态,确认后从响应头获取 cookies + 3. cookies 中直接包含 stoken + mid + +参考项目: + - https://github.com/thesadru/genshin.py (2026-06 最新) +""" + +import json +from http.cookies import SimpleCookie + +import httpx + +from app.core import Config +from app.utils.logger import get_logger + +logger = get_logger("米游社扫码登录") + +# ---- Passport QR 登录 API(对齐 genshin.py) ---- + +CREATE_QRCODE_URL = "https://passport-api.miyoushe.com/account/ma-cn-passport/web/createQRLogin" +CHECK_QRCODE_URL = "https://passport-api.miyoushe.com/account/ma-cn-passport/web/queryQRLoginStatus" + +# ---- 请求头(对齐 genshin.py QRCODE_HEADERS) ---- + +QR_HEADERS = { + "x-rpc-app_id": "bll8iq97cem8", + "x-rpc-client_type": "4", + "x-rpc-game_biz": "bbs_cn", + "x-rpc-device_fp": "38d7fa104e5d7", +} + + +def _qr_headers(device: str) -> dict: + """构建带 device_id 的请求头""" + headers = QR_HEADERS.copy() + headers["x-rpc-device_id"] = device + return headers + + +async def create_qr_login(proxy: str | None = None) -> dict: + """创建米游社扫码登录二维码(Passport 模式) + + POST /createQRLogin(无需 body) + + Returns: + {ticket, qr_url, device} 或 {error} + """ + from uuid import uuid4 + device = str(uuid4()) + + try: + headers = _qr_headers(device) + async with httpx.AsyncClient(proxy=proxy or Config.proxy) as client: + resp = await client.post( + CREATE_QRCODE_URL, + headers=headers, + timeout=30.0, + ) + data = resp.json() + logger.debug(f"QR create 响应: {data}") + + if data.get("retcode") != 0: + return {"error": data.get("message", "创建二维码失败")} + + qr_data = data.get("data", {}) + qr_url = qr_data.get("url", "") + ticket = qr_data.get("ticket", "") + + if not qr_url or not ticket: + return {"error": f"返回数据缺少 url 或 ticket: {qr_data}"} + + logger.info(f"QR 创建成功, ticket={ticket[:8]}...") + return {"ticket": ticket, "qr_url": qr_url, "device": device} + except Exception as e: + logger.error(f"创建扫码登录失败: {e}") + return {"error": str(e)} + + +async def check_qr_status( + ticket: str, device: str, proxy: str | None = None, +) -> dict: + """轮询扫码登录状态 + + POST /queryQRLoginStatus body: {"ticket": ticket} + + 确认后 cookies 直接在 Set-Cookie 响应头中返回。 + + Returns: + {status: "Init"|"Scanned"|"Confirmed"|"Expired"|"Error", + cookies_str?, error?} + """ + try: + headers = _qr_headers(device) + async with httpx.AsyncClient(proxy=proxy or Config.proxy) as client: + resp = await client.post( + CHECK_QRCODE_URL, + headers=headers, + json={"ticket": ticket}, + timeout=30.0, + ) + data = resp.json() + logger.debug(f"QR query 响应: retcode={data.get('retcode')}, data={data.get('data',{}).get('status','?')}") + + retcode = data.get("retcode", 0) + + if retcode != 0: + return {"status": "Error", "error": data.get("message", "查询失败")} + + qr_data = data.get("data", {}) + status = qr_data.get("status", "Init") + + if status == "Init": + return {"status": "Init"} + elif status == "Scanned": + return {"status": "Scanned"} + else: + # Confirmed — 从 Set-Cookie 响应头提取 cookies + cookies_str = _extract_cookies_from_headers(resp) + logger.info(f"QR 确认成功, 获取到 cookies: {bool(cookies_str)}") + return { + "status": "Confirmed", + "cookies_str": cookies_str, + } + except json.JSONDecodeError as e: + logger.error(f"解析扫码状态 JSON 失败: {e}") + return {"status": "Error", "error": "响应解析失败"} + except Exception as e: + logger.error(f"查询扫码状态失败: {e}") + return {"status": "Error", "error": str(e)} + + +def _extract_cookies_from_headers(resp: httpx.Response) -> str: + """从响应头的 Set-Cookie 中提取 stoken 等 cookies + + 对齐 genshin.py: 确认后服务器通过 Set-Cookie 返回 stoken、mid、cookie_token 等。 + """ + cookie_parts = {} + for name, value in resp.headers.multi_items(): + if name.lower() == "set-cookie": + # 解析每个 Set-Cookie 头 + sc = SimpleCookie() + sc.load(value) + for key, morsel in sc.items(): + cookie_parts[key] = morsel.value + + if not cookie_parts: + return "" + + # 构造 cookie 字符串 + parts = [f"{k}={v}" for k, v in cookie_parts.items() if v] + return "; ".join(parts) + + +async def exchange_stoken( + game_token: str, uid: str, proxy: str | None = None, +) -> dict: + """兼容接口:Passport 模式下此函数不被调用,cookies 直接从响应头获取。 + + 保留此函数以兼容 API 路由层的调用。 + """ + return {"error": "Passport 模式不需要 exchange_stoken,请直接使用 cookies_str"} diff --git a/app/tools/skland.py b/app/tools/skland.py index a19682842..4169eebd6 100644 --- a/app/tools/skland.py +++ b/app/tools/skland.py @@ -50,6 +50,7 @@ from app.core import Config from app.utils.constants import SKLAND_SM_CONFIG, BROWSER_ENV, DES_RULE from app.utils.logger import get_logger +from .skland_response import is_skland_already_signed logger = get_logger("森空岛签到任务") @@ -366,8 +367,13 @@ async def get_grant_code(token_value): ) return rsp["data"]["code"] - async def get_binding_list(cred, sign_token): - """查询已绑定的角色列表""" + async def get_binding_list(cred, sign_token, app_code_override: str | None = None): + """查询已绑定的角色列表 + + Args: + app_code_override: 覆盖外层 app_code,用于 all 模式下按游戏过滤 + """ + code = app_code_override if app_code_override else app_code v = [] async with httpx.AsyncClient(proxy=Config.proxy) as client: response = await client.get( @@ -387,7 +393,7 @@ async def get_binding_list(cred, sign_token): logger.error("用户登录可能失效了, 请重新登录!") return v for item in rsp["data"]["list"]: - if item.get("appCode") != app_code: + if item.get("appCode") != code: continue v.extend(item.get("bindingList")) return v @@ -429,14 +435,15 @@ async def check_attendance_today(cred, sign_token, uid, game_id) -> bool: async def sign_for_arknights(cred, sign_token) -> dict: """方舟签到""" - characters = await get_binding_list(cred, sign_token) + characters = await get_binding_list(cred, sign_token, app_code_override="arknights") result = {"成功": [], "重复": [], "失败": [], "总计": len(characters)} for character in characters: - character_name = ( - f"{character.get('nickName')}({character.get('channelName')})" - ) - uid = character.get("uid") + nick_name = character.get("nickName", "") + channel_name = character.get("channelName", "森空岛") + uid = character.get("uid", "") + # 统一 account 格式: 别名/昵称(uid) + character_name = f"{nick_name}/{nick_name}({uid})" if uid else f"{nick_name}/{channel_name}" game_id = character.get("channelMasterId") if await check_attendance_today(cred, sign_token, uid, game_id): @@ -446,8 +453,8 @@ async def sign_for_arknights(cred, sign_token) -> dict: continue body = { - "uid": uid, "gameId": game_id, + "uid": uid, } try: @@ -467,7 +474,7 @@ async def sign_for_arknights(cred, sign_token) -> dict: rsp = response.json() if rsp["code"] != 0: - if rsp.get("message") == "请勿重复签到!": + if is_skland_already_signed(rsp): result["重复"].append(character_name) logger.info(f"{character_name} 重复签到") else: @@ -489,7 +496,7 @@ async def do_sign_for_endfield(cred, sign_token, role: dict): headers = await get_sign_header( endfield_sign_url, "post", - None, + "", # 终末地签到不发 body,签名计算使用空字符串 copy_header(cred, sign_token), sign_token, ) @@ -508,7 +515,7 @@ async def do_sign_for_endfield(cred, sign_token, role: dict): async def sign_for_endfield(cred, sign_token) -> dict: """终末地签到""" - characters = await get_binding_list(cred, sign_token) + characters = await get_binding_list(cred, sign_token, app_code_override="endfield") result = {"成功": [], "重复": [], "失败": [], "总计": 0} for character in characters: @@ -519,7 +526,9 @@ async def sign_for_endfield(cred, sign_token) -> dict: for role in roles: nickname = str(role.get("nickname") or "").strip() - character_name = f"{nickname}({channel_name})" + role_id = role.get("roleId", "") + # 统一 account 格式: 别名/昵称(角色ID) + character_name = f"{nickname}/{nickname}({role_id})" if role_id else f"{nickname}/{channel_name}" try: rsp = await do_sign_for_endfield(cred, sign_token, role) @@ -562,9 +571,14 @@ async def sign_for_endfield(cred, sign_token) -> dict: try: cred, sign_token = await login_by_token(token) await asyncio.sleep(1) + if app_code == "all": + ar = await sign_for_arknights(cred, sign_token) + await asyncio.sleep(3) + ef = await sign_for_endfield(cred, sign_token) + return {"arknights": ar, "endfield": ef} if app_code == "endfield": return await sign_for_endfield(cred, sign_token) return await sign_for_arknights(cred, sign_token) except Exception as e: logger.error(f"森空岛签到失败: {e}") - return {"成功": [], "重复": [], "失败": [], "总计": 0} + return {"成功": [], "重复": [], "失败": [str(e)], "总计": 0} diff --git a/app/tools/skland_response.py b/app/tools/skland_response.py new file mode 100644 index 000000000..edd95af88 --- /dev/null +++ b/app/tools/skland_response.py @@ -0,0 +1,4 @@ +def is_skland_already_signed(response: dict) -> bool: + """判断森空岛签到响应是否表示今日已签到。""" + message = str(response.get("message", "")) + return response.get("code") == 10001 or "请勿重复签到" in message diff --git a/frontend/electron/services/index.ts b/frontend/electron/services/index.ts index 79ee695f1..09fda4d56 100644 --- a/frontend/electron/services/index.ts +++ b/frontend/electron/services/index.ts @@ -1,17 +1,17 @@ /** * 初始化服务 - 统一导出 - * + * * 使用示例: - * + * * ```typescript * import { InitializationService } from './services' - * + * * const initService = new InitializationService(appRoot, 'dev') - * + * * const result = await initService.initialize((progress) => { * console.log(`[${progress.stage}] ${progress.message} - ${progress.progress}%`) * }) - * + * * if (result.success) { * console.log('初始化成功') * } else { @@ -28,51 +28,51 @@ export { SmartDownloader, DownloadProgress, ProgressCallback } from './downloadS // 镜像源轮替服务 export { - MirrorRotationService, - NetworkOperationProgress, - NetworkOperationCallback, - MirrorRotationProgress, - MirrorRotationProgressCallback + MirrorRotationService, + NetworkOperationProgress, + NetworkOperationCallback, + MirrorRotationProgress, + MirrorRotationProgressCallback, } from './mirrorRotationService' // 环境安装服务 export { - PythonInstaller, - PipInstaller, - GitInstaller, - EnvironmentCheckResult, - InstallProgress, - InstallProgressCallback + PythonInstaller, + PipInstaller, + GitInstaller, + EnvironmentCheckResult, + InstallProgress, + InstallProgressCallback, } from './environmentService' // 仓库服务 export { - RepositoryService, - RepositoryCheckResult, - RepositoryProgress, - RepositoryProgressCallback + RepositoryService, + RepositoryCheckResult, + RepositoryProgress, + RepositoryProgressCallback, } from './repositoryService' // 依赖服务 export { - DependencyService, - DependencyCheckResult, - DependencyProgress, - DependencyProgressCallback + DependencyService, + DependencyCheckResult, + DependencyProgress, + DependencyProgressCallback, } from './dependencyService' // 初始化总流程服务 export { - InitializationService, - InitializationProgress, - InitializationProgressCallback, - InitializationResult + InitializationService, + InitializationProgress, + InitializationProgressCallback, + InitializationResult, } from './initializationService' // 后端服务 export { - BackendService, - BackendStatus, - BackendStartOptions, - BackendStatusCallback + BackendService, + BackendStatus, + BackendStartOptions, + BackendStatusCallback, } from './backendService' diff --git a/frontend/electron/services/logger.ts b/frontend/electron/services/logger.ts index f2b8975d1..7fdbdd935 100644 --- a/frontend/electron/services/logger.ts +++ b/frontend/electron/services/logger.ts @@ -93,6 +93,10 @@ function getLevelColor(level: string): string { */ export function initializeLogger(): void { + if (!app) { + console.error('日志初始化失败: electron.app 不可用') + return + } const appPath = path.dirname(app.getPath('exe')) // 设置日志级别 diff --git a/frontend/package.json b/frontend/package.json index 4de70c3e6..e99b31ead 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,9 +9,9 @@ "dev": "concurrently \"vite\" \"yarn watch:main\" \"yarn electron-dev\"", "dev:fullstack": "concurrently \"yarn backend\" \"vite\" \"yarn watch:main\" \"yarn electron-dev:wait\"", "backend": "python ../main.py", - "electron-dev:wait": "wait-on http://localhost:5173 http://localhost:36163 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron .", + "electron-dev:wait": "wait-on http://localhost:5173 http://localhost:36163 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 node scripts/electron-spawn.mjs .", "watch:main": "tsc -p tsconfig.electron.json --watch", - "electron-dev": "wait-on http://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron .", + "electron-dev": "wait-on http://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 node scripts/electron-spawn.mjs .", "build:main": "tsc -p tsconfig.electron.json", "build": "vite build && yarn build:main && electron-builder --publish never", "web": "vite", diff --git a/frontend/scripts/electron-spawn.mjs b/frontend/scripts/electron-spawn.mjs new file mode 100644 index 000000000..92b492560 --- /dev/null +++ b/frontend/scripts/electron-spawn.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +/** + * Wrapper script to spawn Electron with ELECTRON_RUN_AS_NODE unset. + * + * In some environments (CI, IDE terminals, global npm config), + * ELECTRON_RUN_AS_NODE=1 is persisted, causing electron.exe to run + * as plain Node.js instead of the Electron runtime. This script + * explicitly deletes the variable before spawning the Electron CLI. + * + * Usage: node scripts/electron-spawn.mjs [-- ] + */ + +import { spawn } from 'child_process' +import { createRequire } from 'module' + +const require = createRequire(import.meta.url) +const electronPath = require('electron') + +const env = { ...process.env } +delete env.ELECTRON_RUN_AS_NODE + +const args = process.argv.slice(2) + +const child = spawn(electronPath, args, { + stdio: 'inherit', + env, + windowsHide: false, +}) + +child.on('close', (code, signal) => { + if (code === null) { + console.error(electronPath, 'exited with signal', signal) + process.exit(1) + } + process.exit(code) +}) + +const handleSignal = signal => { + process.on(signal, () => { + if (!child.killed) child.kill(signal) + }) +} + +handleSignal('SIGINT') +handleSignal('SIGTERM') diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 330bdb528..228d3a81f 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -37,6 +37,13 @@ export type { EmulatorSearchOut } from './models/EmulatorSearchOut'; export type { EmulatorSearchResult } from './models/EmulatorSearchResult'; export type { EmulatorStatusOut } from './models/EmulatorStatusOut'; export type { EmulatorUpdateIn } from './models/EmulatorUpdateIn'; +export type { GameSignAccountCreateOut } from './models/GameSignAccountCreateOut'; +export type { GameSignAccountDeleteIn } from './models/GameSignAccountDeleteIn'; +export type { GameSignAccountGetIn } from './models/GameSignAccountGetIn'; +export type { GameSignAccountGroupConfig } from './models/GameSignAccountGroupConfig'; +export type { GameSignAccountReorderIn } from './models/GameSignAccountReorderIn'; +export type { GameSignAccountsListOut } from './models/GameSignAccountsListOut'; +export type { GameSignAccountUpdateIn } from './models/GameSignAccountUpdateIn'; export type { GeneralConfig } from './models/GeneralConfig'; export type { GeneralConfig_Game } from './models/GeneralConfig_Game'; export type { GeneralConfig_Info } from './models/GeneralConfig_Info'; @@ -190,6 +197,7 @@ export type { TimeSetReorderIn } from './models/TimeSetReorderIn'; export type { TimeSetUpdateIn } from './models/TimeSetUpdateIn'; export type { ToolsConfig } from './models/ToolsConfig'; export type { ToolsConfig_ArknightsPC } from './models/ToolsConfig_ArknightsPC'; +export type { ToolsConfig_GameSign } from './models/ToolsConfig_GameSign'; export type { ToolsGetOut } from './models/ToolsGetOut'; export type { ToolsUpdateIn } from './models/ToolsUpdateIn'; export type { UpdateCheckIn } from './models/UpdateCheckIn'; @@ -237,6 +245,7 @@ export { Service } from './services/Service'; export { ActionService } from './services/ActionService'; export { AddService } from './services/AddService'; export { DeleteService } from './services/DeleteService'; +export { GameSignService } from './services/GameSignService'; export { GetService } from './services/GetService'; export { HsrService } from './services/HsrService'; export { M9AService } from './services/M9AService'; diff --git a/frontend/src/api/models/GameSignAccountCreateOut.ts b/frontend/src/api/models/GameSignAccountCreateOut.ts new file mode 100644 index 000000000..e67dbd4a8 --- /dev/null +++ b/frontend/src/api/models/GameSignAccountCreateOut.ts @@ -0,0 +1,30 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { GameSignAccountGroupConfig } from './GameSignAccountGroupConfig'; +/** + * 游戏签到账号组创建响应 + */ +export type GameSignAccountCreateOut = { + /** + * 状态码 + */ + code?: number; + /** + * 操作状态 + */ + status?: string; + /** + * 操作消息 + */ + message?: string; + /** + * 账号组 UUID + */ + accountId?: string; + /** + * 账号组配置 + */ + data?: GameSignAccountGroupConfig; +}; diff --git a/frontend/src/api/models/GameSignAccountDeleteIn.ts b/frontend/src/api/models/GameSignAccountDeleteIn.ts new file mode 100644 index 000000000..8f8f0dd91 --- /dev/null +++ b/frontend/src/api/models/GameSignAccountDeleteIn.ts @@ -0,0 +1,13 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 游戏签到账号组删除请求 + */ +export type GameSignAccountDeleteIn = { + /** + * 账号组 UUID + */ + accountId: string; +}; diff --git a/frontend/src/api/models/GameSignAccountGetIn.ts b/frontend/src/api/models/GameSignAccountGetIn.ts new file mode 100644 index 000000000..6e27d4fc6 --- /dev/null +++ b/frontend/src/api/models/GameSignAccountGetIn.ts @@ -0,0 +1,13 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 游戏签到账号组查询请求 + */ +export type GameSignAccountGetIn = { + /** + * 账号组 UUID + */ + accountId: string; +}; diff --git a/frontend/src/api/models/GameSignAccountGroupConfig.ts b/frontend/src/api/models/GameSignAccountGroupConfig.ts new file mode 100644 index 000000000..a283ada52 --- /dev/null +++ b/frontend/src/api/models/GameSignAccountGroupConfig.ts @@ -0,0 +1,41 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 游戏签到账号组配置 + */ +export type GameSignAccountGroupConfig = { + /** + * 账号组名称 + */ + Name?: (string | null); + /** + * 是否启用 + */ + Enabled?: (boolean | null); + /** + * 米游社是否启用 + */ + MiyousheEnabled?: (boolean | null); + /** + * 米游社登录凭证 + */ + MiyousheToken?: (string | null); + /** + * 库街区是否启用 + */ + KuroEnabled?: (boolean | null); + /** + * 库街区登录凭证 + */ + KuroToken?: (string | null); + /** + * 森空岛是否启用 + */ + SklandEnabled?: (boolean | null); + /** + * 森空岛登录凭证 + */ + SklandToken?: (string | null); +}; diff --git a/frontend/src/api/models/GameSignAccountReorderIn.ts b/frontend/src/api/models/GameSignAccountReorderIn.ts new file mode 100644 index 000000000..189730b4a --- /dev/null +++ b/frontend/src/api/models/GameSignAccountReorderIn.ts @@ -0,0 +1,13 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 游戏签到账号组排序请求 + */ +export type GameSignAccountReorderIn = { + /** + * 账号组 UUID 顺序列表 + */ + order: Array; +}; diff --git a/frontend/src/api/models/GameSignAccountUpdateIn.ts b/frontend/src/api/models/GameSignAccountUpdateIn.ts new file mode 100644 index 000000000..bb8397328 --- /dev/null +++ b/frontend/src/api/models/GameSignAccountUpdateIn.ts @@ -0,0 +1,18 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { GameSignAccountGroupConfig } from './GameSignAccountGroupConfig'; +/** + * 游戏签到账号组更新请求 + */ +export type GameSignAccountUpdateIn = { + /** + * 账号组 UUID + */ + accountId: string; + /** + * 账号组配置 + */ + data: GameSignAccountGroupConfig; +}; diff --git a/frontend/src/api/models/GameSignAccountsListOut.ts b/frontend/src/api/models/GameSignAccountsListOut.ts new file mode 100644 index 000000000..cf5fd8932 --- /dev/null +++ b/frontend/src/api/models/GameSignAccountsListOut.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 游戏签到账号组列表响应 + */ +export type GameSignAccountsListOut = { + /** + * 状态码 + */ + code?: number; + /** + * 操作状态 + */ + status?: string; + /** + * 操作消息 + */ + message?: string; + /** + * 账号组列表 + */ + data?: Record; +}; diff --git a/frontend/src/api/models/ToolsConfig.ts b/frontend/src/api/models/ToolsConfig.ts index cd478ad4b..908da3565 100644 --- a/frontend/src/api/models/ToolsConfig.ts +++ b/frontend/src/api/models/ToolsConfig.ts @@ -3,10 +3,15 @@ /* tslint:disable */ /* eslint-disable */ import type { ToolsConfig_ArknightsPC } from './ToolsConfig_ArknightsPC'; +import type { ToolsConfig_GameSign } from './ToolsConfig_GameSign'; export type ToolsConfig = { /** * 明日方舟PC工具配置 */ ArknightsPC?: (ToolsConfig_ArknightsPC | null); + /** + * 游戏社区签到配置 + */ + GameSign?: (ToolsConfig_GameSign | null); }; diff --git a/frontend/src/api/models/ToolsConfig_GameSign.ts b/frontend/src/api/models/ToolsConfig_GameSign.ts new file mode 100644 index 000000000..a92e536f5 --- /dev/null +++ b/frontend/src/api/models/ToolsConfig_GameSign.ts @@ -0,0 +1,50 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ToolsConfig_GameSign = { + /** + * 是否启用游戏签到 + */ + Enabled?: (boolean | null); + /** + * 签到后是否发送通知 + */ + NotifyEnabled?: (boolean | null); + /** + * 签到窗口起点 HH:mm + */ + WindowStart?: (string | null); + /** + * 签到窗口终点 HH:mm + */ + WindowEnd?: (string | null); + /** + * 启动时运行 + */ + RunOnStartup?: (boolean | null); + /** + * 定时运行 + */ + ScheduledRun?: (boolean | null); + /** + * 是否立即开始 + */ + AutoStart?: (boolean | null); + /** + * 上次签到日期 + */ + LastSignDate?: (string | null); + /** + * 今日计划签到时间 + */ + ScheduledTime?: (string | null); + /** + * 签到状态标签 + */ + Status?: (string | null); + /** + * 签到结果 JSON + */ + Result?: (string | null); +}; diff --git a/frontend/src/api/services/ActionService.ts b/frontend/src/api/services/ActionService.ts index 100d5b77a..3633a9e46 100644 --- a/frontend/src/api/services/ActionService.ts +++ b/frontend/src/api/services/ActionService.ts @@ -174,6 +174,18 @@ export class ActionService { url: '/api/dispatch/cancel/power', }); } + /** + * 手动触发游戏社区签到 + * 手动触发游戏社区签到 + * @returns OutBase Successful Response + * @throws ApiError + */ + public static manualGameSignApiToolsSignPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/tools/sign', + }); + } /** * 测试通知 * 测试通知 diff --git a/frontend/src/api/services/GameSignService.ts b/frontend/src/api/services/GameSignService.ts new file mode 100644 index 000000000..b89e9fc07 --- /dev/null +++ b/frontend/src/api/services/GameSignService.ts @@ -0,0 +1,120 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { GameSignAccountCreateOut } from '../models/GameSignAccountCreateOut'; +import type { GameSignAccountDeleteIn } from '../models/GameSignAccountDeleteIn'; +import type { GameSignAccountGetIn } from '../models/GameSignAccountGetIn'; +import type { GameSignAccountReorderIn } from '../models/GameSignAccountReorderIn'; +import type { GameSignAccountsListOut } from '../models/GameSignAccountsListOut'; +import type { GameSignAccountUpdateIn } from '../models/GameSignAccountUpdateIn'; +import type { OutBase } from '../models/OutBase'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class GameSignService { + /** + * 获取所有游戏签到账号组 + * 获取所有游戏签到账号组 + * @returns GameSignAccountsListOut Successful Response + * @throws ApiError + */ + public static listGameSignAccountsApiToolsSignAccountListPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/tools/sign/account/list', + }); + } + /** + * 添加游戏签到账号组 + * 添加游戏签到账号组 + * @returns GameSignAccountCreateOut Successful Response + * @throws ApiError + */ + public static addGameSignAccountApiToolsSignAccountAddPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/tools/sign/account/add', + }); + } + /** + * 获取游戏签到账号组详情 + * 获取游戏签到账号组详情 + * @param requestBody + * @returns GameSignAccountCreateOut Successful Response + * @throws ApiError + */ + public static getGameSignAccountApiToolsSignAccountGetPost( + requestBody: GameSignAccountGetIn, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/tools/sign/account/get', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * 更新游戏签到账号组配置 + * 更新游戏签到账号组配置 + * @param requestBody + * @returns OutBase Successful Response + * @throws ApiError + */ + public static updateGameSignAccountApiToolsSignAccountUpdatePost( + requestBody: GameSignAccountUpdateIn, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/tools/sign/account/update', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * 删除游戏签到账号组 + * 删除游戏签到账号组 + * @param requestBody + * @returns OutBase Successful Response + * @throws ApiError + */ + public static deleteGameSignAccountApiToolsSignAccountDeletePost( + requestBody: GameSignAccountDeleteIn, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/tools/sign/account/delete', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * 调整游戏签到账号组顺序 + * 调整游戏签到账号组顺序 + * @param requestBody + * @returns OutBase Successful Response + * @throws ApiError + */ + public static reorderGameSignAccountsApiToolsSignAccountReorderPost( + requestBody: GameSignAccountReorderIn, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/tools/sign/account/reorder', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } +} diff --git a/frontend/src/api/services/Service.ts b/frontend/src/api/services/Service.ts index ce3158516..0e81c58ae 100644 --- a/frontend/src/api/services/Service.ts +++ b/frontend/src/api/services/Service.ts @@ -16,6 +16,12 @@ import type { EmulatorReorderIn } from '../models/EmulatorReorderIn'; import type { EmulatorSearchOut } from '../models/EmulatorSearchOut'; import type { EmulatorStatusOut } from '../models/EmulatorStatusOut'; import type { EmulatorUpdateIn } from '../models/EmulatorUpdateIn'; +import type { GameSignAccountCreateOut } from '../models/GameSignAccountCreateOut'; +import type { GameSignAccountDeleteIn } from '../models/GameSignAccountDeleteIn'; +import type { GameSignAccountGetIn } from '../models/GameSignAccountGetIn'; +import type { GameSignAccountReorderIn } from '../models/GameSignAccountReorderIn'; +import type { GameSignAccountsListOut } from '../models/GameSignAccountsListOut'; +import type { GameSignAccountUpdateIn } from '../models/GameSignAccountUpdateIn'; import type { GetStageIn } from '../models/GetStageIn'; import type { HistoryDataGetIn } from '../models/HistoryDataGetIn'; import type { HistoryDataGetOut } from '../models/HistoryDataGetOut'; @@ -1483,6 +1489,122 @@ export class Service { }, }); } + /** + * 手动触发游戏社区签到 + * 手动触发游戏社区签到 + * @returns OutBase Successful Response + * @throws ApiError + */ + public static manualGameSignApiToolsSignPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/tools/sign', + }); + } + /** + * 获取所有游戏签到账号组 + * 获取所有游戏签到账号组 + * @returns GameSignAccountsListOut Successful Response + * @throws ApiError + */ + public static listGameSignAccountsApiToolsSignAccountListPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/tools/sign/account/list', + }); + } + /** + * 添加游戏签到账号组 + * 添加游戏签到账号组 + * @returns GameSignAccountCreateOut Successful Response + * @throws ApiError + */ + public static addGameSignAccountApiToolsSignAccountAddPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/tools/sign/account/add', + }); + } + /** + * 获取游戏签到账号组详情 + * 获取游戏签到账号组详情 + * @param requestBody + * @returns GameSignAccountCreateOut Successful Response + * @throws ApiError + */ + public static getGameSignAccountApiToolsSignAccountGetPost( + requestBody: GameSignAccountGetIn, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/tools/sign/account/get', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * 更新游戏签到账号组配置 + * 更新游戏签到账号组配置 + * @param requestBody + * @returns OutBase Successful Response + * @throws ApiError + */ + public static updateGameSignAccountApiToolsSignAccountUpdatePost( + requestBody: GameSignAccountUpdateIn, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/tools/sign/account/update', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * 删除游戏签到账号组 + * 删除游戏签到账号组 + * @param requestBody + * @returns OutBase Successful Response + * @throws ApiError + */ + public static deleteGameSignAccountApiToolsSignAccountDeletePost( + requestBody: GameSignAccountDeleteIn, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/tools/sign/account/delete', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * 调整游戏签到账号组顺序 + * 调整游戏签到账号组顺序 + * @param requestBody + * @returns OutBase Successful Response + * @throws ApiError + */ + public static reorderGameSignAccountsApiToolsSignAccountReorderPost( + requestBody: GameSignAccountReorderIn, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/tools/sign/account/reorder', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } /** * 查询配置 * 查询配置 diff --git a/frontend/src/components/AppLayout.vue b/frontend/src/components/AppLayout.vue index 089cec820..a60a4c96b 100644 --- a/frontend/src/components/AppLayout.vue +++ b/frontend/src/components/AppLayout.vue @@ -1,17 +1,39 @@ - + - - + + @@ -43,15 +57,29 @@ - + - - + + diff --git a/frontend/src/components/GlobalPowerCountdown.vue b/frontend/src/components/GlobalPowerCountdown.vue index fc21f51ac..12d9e67cf 100644 --- a/frontend/src/components/GlobalPowerCountdown.vue +++ b/frontend/src/components/GlobalPowerCountdown.vue @@ -1,7 +1,17 @@