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 @@
-
+
diff --git a/frontend/src/components/ExtraScriptSection.vue b/frontend/src/components/ExtraScriptSection.vue
index 8b2bc9cf6..4b502c542 100644
--- a/frontend/src/components/ExtraScriptSection.vue
+++ b/frontend/src/components/ExtraScriptSection.vue
@@ -14,15 +14,29 @@
-
+
-
-
+
+
@@ -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 @@
-
+
⚠️
{{ title }}
@@ -13,9 +23,14 @@
等待后端倒计时...
-
+
取消操作
@@ -242,7 +257,6 @@ onUnmounted(() => {
/* 动画效果 */
@keyframes pulse {
-
0%,
100% {
transform: scale(1);
diff --git a/frontend/src/components/LogHighlightSettings.vue b/frontend/src/components/LogHighlightSettings.vue
index a69c3d929..cf9d0c920 100644
--- a/frontend/src/components/LogHighlightSettings.vue
+++ b/frontend/src/components/LogHighlightSettings.vue
@@ -7,19 +7,19 @@ import { message } from 'ant-design-vue'
const { isDark } = useTheme()
const {
- lightColors,
- darkColors,
- styles,
- editorConfig,
- defaultLightColors,
- defaultDarkColors,
- defaultStyles,
- defaultEditorConfig,
- setLightColors,
- setDarkColors,
- setStyles,
- setEditorConfig,
- resetToDefaults,
+ lightColors,
+ darkColors,
+ styles,
+ editorConfig,
+ defaultLightColors,
+ defaultDarkColors,
+ defaultStyles,
+ defaultEditorConfig,
+ setLightColors,
+ setDarkColors,
+ setStyles,
+ setEditorConfig,
+ resetToDefaults,
} = useLogHighlight()
// 当前编辑的主题
@@ -27,104 +27,104 @@ const editingTheme = ref<'light' | 'dark'>(isDark.value ? 'dark' : 'light')
// 当前编辑的颜色
const currentColors = computed(() =>
- editingTheme.value === 'light' ? lightColors.value : darkColors.value
+ editingTheme.value === 'light' ? lightColors.value : darkColors.value
)
// 颜色配置项分组
const colorGroups: {
- title: string
- items: { key: keyof LogHighlightColors; label: string; description: string }[]
+ title: string
+ items: { key: keyof LogHighlightColors; label: string; description: string }[]
}[] = [
- {
- title: '时间相关',
- items: [
- { key: 'timestamp', label: '时间戳', description: '完整的日期时间格式' },
- { key: 'date', label: '日期', description: '单独的日期格式' },
- ]
- },
- {
- title: '日志级别',
- items: [
- { key: 'error', label: '错误', description: 'ERROR/FATAL/CRITICAL 级别' },
- { key: 'warning', label: '警告', description: 'WARN/WARNING 级别' },
- { key: 'info', label: '信息', description: 'INFO/NOTICE 级别' },
- { key: 'debug', label: '调试', description: 'DEBUG 级别' },
- { key: 'trace', label: '追踪', description: 'TRACE/VERBOSE 级别' },
- ]
- },
- {
- title: '结构元素',
- items: [
- { key: 'module', label: '模块名', description: '方括号内的模块/线程名' },
- { key: 'bracket', label: '括号内容', description: '圆括号内的内容' },
- ]
- },
- {
- title: '网络相关',
- items: [
- { key: 'ip', label: 'IP 地址', description: 'IPv4 地址' },
- { key: 'url', label: 'URL', description: 'HTTP/HTTPS 链接' },
- { key: 'port', label: '端口', description: '端口号' },
- ]
- },
- {
- title: '文件系统',
- items: [
- { key: 'path', label: '文件路径', description: '完整的文件路径' },
- { key: 'filename', label: '文件名', description: '带扩展名的文件名' },
- ]
- },
- {
- title: '数据类型',
- items: [
- { key: 'number', label: '数字', description: '整数、小数、科学计数法' },
- { key: 'string', label: '字符串', description: '引号包裹的字符串' },
- { key: 'boolean', label: '布尔值', description: 'true/false/null' },
- { key: 'uuid', label: 'UUID', description: 'UUID 格式的标识符' },
- ]
- },
- {
- title: '关键词',
- items: [
- { key: 'errorKeyword', label: '错误关键词', description: 'Exception/Error/Failed 等' },
- { key: 'success', label: '成功关键词', description: 'Success/Complete/Done 等' },
- ]
- },
- {
- title: '特殊内容',
- items: [
- { key: 'stackTrace', label: '堆栈跟踪', description: 'Java/Python 堆栈信息' },
- { key: 'json', label: 'JSON', description: 'JSON 对象/数组' },
- { key: 'variable', label: '变量', description: 'key=value 格式的变量名' },
- { key: 'operator', label: '操作符', description: '= < > ! 等操作符' },
- ]
- },
- ]
+ {
+ title: '时间相关',
+ items: [
+ { key: 'timestamp', label: '时间戳', description: '完整的日期时间格式' },
+ { key: 'date', label: '日期', description: '单独的日期格式' },
+ ],
+ },
+ {
+ title: '日志级别',
+ items: [
+ { key: 'error', label: '错误', description: 'ERROR/FATAL/CRITICAL 级别' },
+ { key: 'warning', label: '警告', description: 'WARN/WARNING 级别' },
+ { key: 'info', label: '信息', description: 'INFO/NOTICE 级别' },
+ { key: 'debug', label: '调试', description: 'DEBUG 级别' },
+ { key: 'trace', label: '追踪', description: 'TRACE/VERBOSE 级别' },
+ ],
+ },
+ {
+ title: '结构元素',
+ items: [
+ { key: 'module', label: '模块名', description: '方括号内的模块/线程名' },
+ { key: 'bracket', label: '括号内容', description: '圆括号内的内容' },
+ ],
+ },
+ {
+ title: '网络相关',
+ items: [
+ { key: 'ip', label: 'IP 地址', description: 'IPv4 地址' },
+ { key: 'url', label: 'URL', description: 'HTTP/HTTPS 链接' },
+ { key: 'port', label: '端口', description: '端口号' },
+ ],
+ },
+ {
+ title: '文件系统',
+ items: [
+ { key: 'path', label: '文件路径', description: '完整的文件路径' },
+ { key: 'filename', label: '文件名', description: '带扩展名的文件名' },
+ ],
+ },
+ {
+ title: '数据类型',
+ items: [
+ { key: 'number', label: '数字', description: '整数、小数、科学计数法' },
+ { key: 'string', label: '字符串', description: '引号包裹的字符串' },
+ { key: 'boolean', label: '布尔值', description: 'true/false/null' },
+ { key: 'uuid', label: 'UUID', description: 'UUID 格式的标识符' },
+ ],
+ },
+ {
+ title: '关键词',
+ items: [
+ { key: 'errorKeyword', label: '错误关键词', description: 'Exception/Error/Failed 等' },
+ { key: 'success', label: '成功关键词', description: 'Success/Complete/Done 等' },
+ ],
+ },
+ {
+ title: '特殊内容',
+ items: [
+ { key: 'stackTrace', label: '堆栈跟踪', description: 'Java/Python 堆栈信息' },
+ { key: 'json', label: 'JSON', description: 'JSON 对象/数组' },
+ { key: 'variable', label: '变量', description: 'key=value 格式的变量名' },
+ { key: 'operator', label: '操作符', description: '= < > ! 等操作符' },
+ ],
+ },
+]
// 更新颜色
const updateColor = (key: keyof LogHighlightColors, value: string) => {
- const colorValue = value.replace('#', '')
- if (editingTheme.value === 'light') {
- setLightColors({ [key]: colorValue })
- } else {
- setDarkColors({ [key]: colorValue })
- }
+ const colorValue = value.replace('#', '')
+ if (editingTheme.value === 'light') {
+ setLightColors({ [key]: colorValue })
+ } else {
+ setDarkColors({ [key]: colorValue })
+ }
}
// 重置为默认
const handleReset = () => {
- resetToDefaults()
- message.success('已重置为默认配置')
+ resetToDefaults()
+ message.success('已重置为默认配置')
}
// 重置单个颜色
const resetSingleColor = (key: keyof LogHighlightColors) => {
- const defaultColors = editingTheme.value === 'light' ? defaultLightColors : defaultDarkColors
- if (editingTheme.value === 'light') {
- setLightColors({ [key]: defaultColors[key] })
- } else {
- setDarkColors({ [key]: defaultColors[key] })
- }
+ const defaultColors = editingTheme.value === 'light' ? defaultLightColors : defaultDarkColors
+ if (editingTheme.value === 'light') {
+ setLightColors({ [key]: defaultColors[key] })
+ } else {
+ setDarkColors({ [key]: defaultColors[key] })
+ }
}
// 字体大小选项
@@ -135,391 +135,471 @@ const lineHeightOptions = [1.2, 1.4, 1.5, 1.6, 1.8, 2.0]
-
-
-
-
文本配置
-
-
-
字体大小
-
-
setEditorConfig({ fontSize: v })">
-
- {{ size }}px
-
-
-
-
-
-
行高
-
-
setEditorConfig({ lineHeight: v })">
-
- {{ h }}
-
-
-
-
-
-
-
-
setStyles({ timestampBold: e.target.checked })">
- 时间戳加粗
-
-
setStyles({ levelBold: e.target.checked })">
- 日志级别加粗
-
-
setStyles({ keywordBold: e.target.checked })">
- 关键词加粗
-
-
setStyles({ urlUnderline: e.target.checked })">
- URL 下划线
-
-
-
+
+
+
+
文本配置
+
+
+
字体大小
+
+
setEditorConfig({ fontSize: v })"
+ >
+
+ {{ size }}px
+
+
+
+
+
+
行高
+
+
setEditorConfig({ lineHeight: v })"
+ >
+
+ {{ h }}
+
+
+
+
+
+
+
+
setStyles({ timestampBold: e.target.checked })"
+ >
+ 时间戳加粗
+
+
setStyles({ levelBold: e.target.checked })"
+ >
+ 日志级别加粗
+
+
setStyles({ keywordBold: e.target.checked })"
+ >
+ 关键词加粗
+
+
setStyles({ urlUnderline: e.target.checked })"
+ >
+ URL 下划线
+
+
+
-
-
-
-
handleSettingChange('Notify', 'IfPushPlyer', checked)">
+ handleSettingChange('Notify', 'IfPushPlyer', checked)"
+ >
是
否
@@ -102,8 +124,12 @@ const handleWebhookChange = async () => {
@@ -116,8 +142,12 @@ const handleWebhookChange = async () => {
- handleSettingChange('Notify', 'IfSendMail', checked)">
+ handleSettingChange('Notify', 'IfSendMail', checked)"
+ >
是
否
@@ -131,9 +161,13 @@ const handleWebhookChange = async () => {
-
handleSettingChange('Notify', 'SMTPServerAddress', e.target.value)" />
+ handleSettingChange('Notify', 'SMTPServerAddress', e.target.value)"
+ />
@@ -146,9 +180,13 @@ const handleWebhookChange = async () => {
-
handleSettingChange('Notify', 'FromAddress', e.target.value)" />
+ handleSettingChange('Notify', 'FromAddress', e.target.value)"
+ />
@@ -159,9 +197,13 @@ const handleWebhookChange = async () => {
-
handleSettingChange('Notify', 'AuthorizationCode', e.target.value)" />
+ handleSettingChange('Notify', 'AuthorizationCode', e.target.value)"
+ />
@@ -174,9 +216,13 @@ const handleWebhookChange = async () => {
- handleSettingChange('Notify', 'ToAddress', e.target.value)" />
+ handleSettingChange('Notify', 'ToAddress', e.target.value)"
+ />
@@ -185,8 +231,12 @@ const handleWebhookChange = async () => {
@@ -202,8 +252,12 @@ const handleWebhookChange = async () => {
- handleSettingChange('Notify', 'IfServerChan', checked)">
+ handleSettingChange('Notify', 'IfServerChan', checked)"
+ >
是
否
@@ -220,9 +274,13 @@ const handleWebhookChange = async () => {
- handleSettingChange('Notify', 'ServerChanKey', e.target.value)" />
+ handleSettingChange('Notify', 'ServerChanKey', e.target.value)"
+ />
@@ -241,8 +299,12 @@ const handleWebhookChange = async () => {
- handleSettingChange('Notify', 'IfKoishiSupport', checked)">
+ handleSettingChange('Notify', 'IfKoishiSupport', checked)"
+ >
是
否
@@ -258,9 +320,15 @@ const handleWebhookChange = async () => {
- handleSettingChange('Notify', 'KoishiServerAddress', e.target.value)" />
+ handleSettingChange('Notify', 'KoishiServerAddress', e.target.value)
+ "
+ />
@@ -271,9 +339,13 @@ const handleWebhookChange = async () => {
- handleSettingChange('Notify', 'KoishiToken', e.target.value)" />
+ handleSettingChange('Notify', 'KoishiToken', e.target.value)"
+ />
@@ -282,14 +354,17 @@ const handleWebhookChange = async () => {
-
@@ -341,9 +416,11 @@ const handleWebhookChange = async () => {
transition:
transform 0.16s ease,
box-shadow 0.16s ease;
- background: linear-gradient(135deg,
- var(--ant-color-primary),
- var(--ant-color-primary-hover)) !important;
+ background: linear-gradient(
+ 135deg,
+ var(--ant-color-primary),
+ var(--ant-color-primary-hover)
+ ) !important;
border: 1px solid var(--ant-color-primary) !important;
/* subtle border to match doc-link rhythm */
color: #fff !important;
diff --git a/frontend/src/views/setting/TabOthers.vue b/frontend/src/views/setting/TabOthers.vue
index ef159d47f..acaef566f 100644
--- a/frontend/src/views/setting/TabOthers.vue
+++ b/frontend/src/views/setting/TabOthers.vue
@@ -63,7 +63,6 @@ const copyAllInfo = async () => {
document.body.removeChild(textArea)
}
}
-
@@ -74,7 +73,8 @@ const copyAllInfo = async () => {
检查更新
@@ -89,8 +89,12 @@ const copyAllInfo = async () => {
- handleSettingChange('Update', 'IfAutoUpdate', checked)">
+ handleSettingChange('Update', 'IfAutoUpdate', checked)"
+ >
是
否
@@ -104,20 +108,32 @@ const copyAllInfo = async () => {
- handleSettingChange('Update', 'Source', value)" />
+ handleSettingChange('Update', 'Source', value)"
+ />
@@ -126,12 +142,18 @@ const copyAllInfo = async () => {
@@ -142,18 +164,26 @@ const copyAllInfo = async () => {
- handleSettingChange('Update', 'MirrorChyanCDK', e.target.value)" />
+ handleSettingChange('Update', 'MirrorChyanCDK', e.target.value)"
+ />
@@ -172,7 +202,9 @@ const copyAllInfo = async () => {
软件官网
查看最新版本和功能介绍
-
访问官网
+
访问官网
@@ -184,8 +216,12 @@ const copyAllInfo = async () => {
GitHub仓库
查看源代码、提交issue和捐赠
-
访问仓库
+
访问仓库
@@ -197,7 +233,12 @@ const copyAllInfo = async () => {
用户QQ群
加入社区,获取帮助和交流
-
加入群聊
+
加入群聊
diff --git a/frontend/src/views/setting/index.vue b/frontend/src/views/setting/index.vue
index ac6358e98..722ed8d1d 100644
--- a/frontend/src/views/setting/index.vue
+++ b/frontend/src/views/setting/index.vue
@@ -221,11 +221,13 @@ const checkUpdate = async () => {
try {
await globalCheckUpdate(false, true) // silent=false, forceCheck=true
- logger.info(`全局更新检查完成,状态: ${JSON.stringify({
- updateVisible: updateVisible.value,
- updateData: updateData.value,
- latestVersion: latestVersion.value,
- })}`)
+ logger.info(
+ `全局更新检查完成,状态: ${JSON.stringify({
+ updateVisible: updateVisible.value,
+ updateData: updateData.value,
+ latestVersion: latestVersion.value,
+ })}`
+ )
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
logger.error(`全局更新检查失败: ${errorMsg}`)
@@ -275,26 +277,47 @@ onMounted(() => {
diff --git a/frontend/src/views/tools/TabArknightsPC.vue b/frontend/src/views/tools/TabArknightsPC.vue
index e8001738a..1ad588c21 100644
--- a/frontend/src/views/tools/TabArknightsPC.vue
+++ b/frontend/src/views/tools/TabArknightsPC.vue
@@ -2,443 +2,521 @@
import { QuestionCircleOutlined, WarningOutlined, ThunderboltOutlined } from '@ant-design/icons-vue'
import type { ToolsConfig_ArknightsPC } from '@/api'
-const { config, disabled, onFieldChange, recordingKeyField, startRecordKey, stopRecordKey, onSelectVisibleChange } = defineProps<{
- config: ToolsConfig_ArknightsPC
- disabled?: boolean
- onFieldChange?: (key: string, value: any) => void
- recordingKeyField?: string | null
- startRecordKey?: (fieldName: string) => void
- stopRecordKey?: () => void
- onSelectVisibleChange?: (visible: boolean) => void
+const {
+ config,
+ disabled,
+ onFieldChange,
+ recordingKeyField,
+ startRecordKey,
+ stopRecordKey,
+ onSelectVisibleChange,
+} = defineProps<{
+ config: ToolsConfig_ArknightsPC
+ disabled?: boolean
+ onFieldChange?: (key: string, value: any) => void
+ recordingKeyField?: string | null
+ startRecordKey?: (fieldName: string) => void
+ stopRecordKey?: () => void
+ onSelectVisibleChange?: (visible: boolean) => void
}>()
// 处理字段变更
const handleChange = (key: string, value: any) => {
- if (onFieldChange) {
- onFieldChange(key, value)
- }
+ if (onFieldChange) {
+ onFieldChange(key, value)
+ }
}
// 检查是否正在录制指定字段
const isRecording = (fieldName: string) => {
- return recordingKeyField === fieldName
+ return recordingKeyField === fieldName
}
-