From 98ebe4782f9716654ef2f1936cabc71afd569553 Mon Sep 17 00:00:00 2001 From: Lance0174 Date: Mon, 31 Aug 2026 12:28:17 +0800 Subject: [PATCH 01/15] =?UTF-8?q?feat(community):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E7=A4=BE=E5=8C=BA=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/mas-community/SKILL.md | 130 +++ .agents/skills/mas-game-sign/SKILL.md | 76 +- .agents/skills/mas-skills/SKILL.md | 6 +- app/api/qr_login.py | 9 +- app/api/tools.py | 165 +++- app/core/community_activity.py | 196 ++++ app/core/community_scheduler.py | 127 +++ app/core/community_sign.py | 257 +++++ app/core/config.py | 65 +- app/core/game_sign_scheduler.py | 49 + app/core/task_manager.py | 4 +- app/core/timer.py | 163 ++-- app/models/config.py | 6 +- app/models/schema.py | 78 +- app/models/task.py | 20 + app/task/HSR/manager.py | 16 +- app/task/M9A/manager.py | 16 +- app/task/MAA/manager.py | 16 +- app/task/MaaEnd/manager.py | 16 +- app/task/OkNte/manager.py | 16 +- app/task/Okww/manager.py | 16 +- app/task/SRC/manager.py | 16 +- app/task/general/manager.py | 16 +- app/tools/__init__.py | 8 + app/tools/community.py | 99 ++ app/tools/community_activity_provider.py | 899 ++++++++++++++++++ app/tools/community_activity_roles.py | 278 ++++++ app/tools/community_activity_transport.py | 505 ++++++++++ app/tools/community_notify.py | 80 ++ app/tools/community_plugins.py | 200 ++++ app/tools/game_sign.py | 482 +++++----- app/tools/game_sign_activity.py | 720 ++++++++++++++ app/tools/game_sign_contract.py | 164 ++++ app/tools/game_sign_credentials.py | 186 ++++ app/tools/game_sign_notify.py | 4 +- app/tools/kuro.py | 489 ++++++++-- app/tools/miyoushe.py | 26 +- app/tools/miyoushe_qr.py | 531 ++++++++++- app/tools/taygedo.py | 514 ++++++++-- frontend/src/components/AppLayout.vue | 113 ++- frontend/src/i18n/locales/en-US.ts | 18 +- frontend/src/i18n/locales/ja-JP.ts | 19 +- frontend/src/i18n/locales/zh-CN.ts | 18 +- frontend/src/router/index.ts | 8 +- .../views/gamesign/CommunityActivityView.vue | 449 +++++++++ frontend/src/views/gamesign/TabGameSign.vue | 8 +- .../src/views/gamesign/gameSignDisplay.ts | 2 +- frontend/src/views/gamesign/index.vue | 22 +- .../views/gamesign/useCommunityActivityApi.ts | 69 ++ frontend/src/views/gamesign/useGameSignApi.ts | 2 +- frontend/src/views/tools/index.vue | 2 +- res/version.json | 6 +- tests/core/test_task_game_sign.py | 2 +- 53 files changed, 6635 insertions(+), 767 deletions(-) create mode 100644 .agents/skills/mas-community/SKILL.md create mode 100644 app/core/community_activity.py create mode 100644 app/core/community_scheduler.py create mode 100644 app/core/community_sign.py create mode 100644 app/core/game_sign_scheduler.py create mode 100644 app/tools/community.py create mode 100644 app/tools/community_activity_provider.py create mode 100644 app/tools/community_activity_roles.py create mode 100644 app/tools/community_activity_transport.py create mode 100644 app/tools/community_notify.py create mode 100644 app/tools/community_plugins.py create mode 100644 app/tools/game_sign_activity.py create mode 100644 app/tools/game_sign_contract.py create mode 100644 app/tools/game_sign_credentials.py create mode 100644 frontend/src/views/gamesign/CommunityActivityView.vue create mode 100644 frontend/src/views/gamesign/useCommunityActivityApi.ts diff --git a/.agents/skills/mas-community/SKILL.md b/.agents/skills/mas-community/SKILL.md new file mode 100644 index 000000000..33a517c60 --- /dev/null +++ b/.agents/skills/mas-community/SKILL.md @@ -0,0 +1,130 @@ +--- +name: mas-community +description: >- + Define, add, refactor, or review AUTO-MAS community tools, including + credential parsing, role discovery, daily activity, sign scheduling, + notifications, provider capability evidence, and compatibility migration. +--- + +# 游戏社区工具 + +## 目标 + +将不同游戏社区的凭据、角色、日常活动、签到和通知纳入统一的社区工具语义, +为后续新增社区提供稳定边界。`game_sign`、`GameSign` 和历史路由/配置键是兼容 +标识,不作为新增业务的首选语义;不得为了改名批量迁移旧字段或破坏旧客户端。 + +## 术语 + +新增代码和用户可见文案优先使用: + +| 语义 | 首选名称 | 兼容说明 | +| --- | --- | --- | +| 总能力 | 游戏社区 / 社区工具 | 旧“游戏签到”只在兼容文案或历史记录中保留 | +| 外部平台 | 社区 provider / 社区平台 | 例如森空岛、米游社、库街区、塔吉多 | +| 凭据 | 社区凭据 | 继续读写旧 `GameSignAccount` 字段 | +| 角色 | 社区角色 / 游戏角色 | 由角色发现层归一化,不把 UID 当账号组 ID | +| 查询 | 日常活跃度 / 社区便笺 | 只读查询不能触发签到或任务提交 | +| 动作 | 社区签到 | 仍受既有签到锁、日期标记和结果契约约束 | +| 消费 | 社区通知 / 任务报告 | 复用同一结构化结果,不重复请求上游 | + +不要在新模块中用 `game_sign` 表达整个社区领域,也不要为同一语义同时新增 +`CommunityToken`、`GameSignToken` 等平行凭据字段。 + +## 模块边界 + +遵守 AUTO-MAS 的分层方向: + +| 层 | 归属 | 责任 | +| --- | --- | --- | +| schema | `app/models/schema.py` | API 输入/输出类型和字段描述 | +| api | `app/api/*.py` | 输入校验、调用 core、输出映射 | +| core | `app/core/community_*.py` | 账号组编排、锁、调度和状态协调 | +| tools | `app/tools/community*.py`、`app/tools/game_sign.py` | provider 注册与执行、角色归一化、请求规格、解析 | +| legacy | `app/tools/game_sign*.py` 中的旧导出 | 旧签到入口、旧符号和兼容结果 | + +provider 不得导入签到编排入口;schema 不得访问配置、网络或文件;API 不得持有 +角色遍历、重试或上游协议细节。社区 facade 可以提供新语义别名,但不能复制一份 +平台清单或绕过旧注册表。 + +## Provider 契约 + +新增社区时先明确以下能力,不得把参考项目中“存在接口”直接当作已接入: + +1. 凭据格式、字段完整性、刷新方式和失效表现。 +2. 角色发现路径、游戏标识、区服字段和角色 UID 的归一化规则。 +3. 日常活动的确认接口、请求头/设备要求、角色隔离和可识别响应字段。 +4. 签到路径、请求间隔、重试、已完成判据和结果错误码。 +5. 通知需要的结构化字段以及失败、受限、空数据和不可用状态。 + +请求规格可以包含公开 URL、角色参数、请求头配置和签名 profile,但不得包含 +Cookie、Token、Authorization、完整请求体或设备敏感值。认证值只在 provider 内存 +执行时注入,失败文案和日志不得回显原始响应。 + +参考项目只作为能力证据和协议线索。`CommunityPluginReference` 中的能力表示 +参考项目展示过该能力,不表示 AUTO-MAS 已接入、接口当前可用或可以跳过风控审查。 + +## 凭据规则 + +1. 旧账号组字段继续使用既有加密、校验和保存契约;不得因 UI 输入宽度截断长 Token。 +2. 解析器只返回格式、字段名和本地校验状态,不返回或持久化 Token 内容。 +3. Cookie、Token、设备信息和一次性登录值只在必要的内存作用域存在,日志只记录脱敏状态。 +4. provider 刷新后的凭据只能通过编排层的统一回写契约发布;查询失败不得覆盖原凭据。 +5. 缺少 `stoken_v2`、设备登记失败、上游风控和真实过期必须分别表达,不能统称为网络失败。 +6. Token 时效没有时间样本或官方依据时,不得承诺长时效;需要保留用户重新登录入口。 + +## 角色与日常活动 + +角色发现和日常查询必须与签到分离: + +1. 角色发现先按 provider 的游戏标识分流,再构造每个游戏的独立目标。 +2. 未明确游戏标识的绑定列表不得猜测归属;宁可返回不可用状态,也不能交叉映射。 +3. 每款游戏返回独立快照,包含账号、角色、完成数/目标数、任务、资源、状态、原因和来源。 +4. 活动查询使用独立的活动锁和并发上限,不获取签到锁,不修改签到日期,不触发签到通知。 +5. `success`、`empty`、`limited`、`unavailable`、`failed` 的含义必须稳定;上游 HTTP 200 不等于业务成功。 +6. 未确认的实时接口不得构造请求;应返回可见的 `limited` 状态并说明限制原因。 + +## 签到与调度 + +实际签到由 `app/core/community_sign.py` 统一编排,社区 facade 和旧入口都转发到 +该实现;provider 注册与平台执行仍以 `app/tools/game_sign.py` 的唯一注册表为来源: + +1. 平台凭据字段、平台日志名、runner、平台解析和错误游戏继续以既有注册表为单一来源。 +2. 社区日常锁与签到流程锁不可合并;签到请求和结果落盘在签到锁内,通知在锁外。 +3. 快速失败锁不得静默排队;API 返回冲突,定时器按既有策略跳过。 +4. 保留账号级/全局 `LastSignDate` 语义、跨零点重新取日期、重试和排序行为。 +5. 新平台的成功/失败状态必须进入既有结果兼容层,不得为通知单独重新请求上游。 +6. `game_sign.py`、`GameSignAccount`、`GameSign` 和 `/api/tools/sign` 属于历史兼容协议; + 新调用使用社区语义,但不得为了改名复制状态、迁移配置或破坏旧客户端。 + +## 通知与日志 + +只吸收日志采集配置 PR 中已验证的结果消费思路:先聚合结构化签到结果, +再复用到文本通知、Markdown、HTML 和任务报告。不要搬运完整日志采集配置、 +编辑器、采集器或规则 UI。 + +结构化结果必须保留平台、游戏、账号、状态、奖励和失败原因;业务失败不能被 +格式化成成功。Markdown 自动识别属于通知消费层能力,不能改变签到执行结果。 + +## 新社区接入顺序 + +1. 先补 provider 能力证据和脱敏合同,再决定是否支持角色发现或日常活动。 +2. 在 `tools` 层实现凭据解析、角色归一化、请求规格和响应解析。 +3. 在 `core` 层接入账号组编排和独立锁;复用旧注册表读取兼容凭据。 +4. 在 `schema`/`api` 层完成类型化契约;禁止手改 OpenAPI 生成客户端。 +5. 由维护者在隔离开发服务中重新生成客户端,再接入前端;原有 Token 保存和 UI 未经批准不得迁移。 +6. 补充纯逻辑、脱敏 Mock、锁隔离和结果映射测试;真实账号 E2E 单独说明,不用 Mock 冒充。 +7. 更新 `res/version.json` 的下一未发布版本,并在开发报告中更新阶段清单。 + +## 审查清单 + +- [ ] 新增代码使用社区工具/社区 provider 语义,旧 `game_sign` 仅为兼容入口。 +- [ ] 平台和凭据字段没有重复注册表;历史配置字段可继续读取。 +- [ ] 角色按明确游戏标识分流,未标识响应不猜测。 +- [ ] 日常活动不获取签到锁、不改签到日期、不触发签到通知。 +- [ ] 每个游戏独立返回空、成功、受限、不可用和失败状态。 +- [ ] Token 保持完整、不进入日志/API 错误、不因输入框宽度截断。 +- [ ] 上游 HTTP 200 与业务成功已分离,风控和失效状态可见。 +- [ ] 文本、Markdown、HTML 和任务报告复用同一结构化签到结果。 +- [ ] 没有手改 `frontend/src/api/**`;生成客户端由维护者在隔离服务中重新生成。 +- [ ] 测试、错误、真实 E2E 缺口和版本记录均已留痕。 diff --git a/.agents/skills/mas-game-sign/SKILL.md b/.agents/skills/mas-game-sign/SKILL.md index 8e83e07d4..17a80e0fb 100644 --- a/.agents/skills/mas-game-sign/SKILL.md +++ b/.agents/skills/mas-game-sign/SKILL.md @@ -15,8 +15,9 @@ description: >- 签到是**凭据驱动的批量外部请求**,不是脚本适配。它的风险集中在三处:**用户凭据**、**上游风控**、**重复触发**。改动时先确认属于哪一层: 1. **平台适配层**(`app/tools/.py`):单个社区的登录、角色发现、签到请求、风控退避。上游 API 知识来自被致谢的第三方项目,文件头的 AGPL/致谢声明必须保留。 -2. **编排层**(`app/tools/game_sign.py`):账号遍历、平台注册表、并发、锁、日期标记、凭据回写、结果归一。 -3. **消费层**(`app/tools/game_sign_notify.py`、`game_sign_result.py`、API、前端):结果展示与通知。 +2. **provider 层**(`app/tools/game_sign.py`):平台注册表、runner、凭据读取、平台错误映射和结果归一;旧签到符号只作兼容导出。 +3. **编排层**(`app/core/community_sign.py`):账号遍历、并发、锁、日期标记和凭据更新回写。 +4. **消费层**(`app/tools/community_notify.py`、`game_sign_notify.py`、`game_sign_result.py`、API、前端):结果展示与通知。 新增或修改平台时,**只应动第 1 层加注册表一行**。若发现必须改编排层才能容纳新平台,先停下来确认是不是抽象放错了位置。 @@ -24,19 +25,23 @@ description: >- ### 平台注册表是唯一入口 -`app/tools/game_sign.py` 的 `_GAME_SIGN_PROVIDERS` 是不可变元组,每项 `_GameSignProvider(frozen=True)` 声明五件事: +`app/tools/game_sign.py` 的 `_COMMUNITY_SIGN_PROVIDERS` 是不可变元组,每项 +`_CommunitySignProvider(frozen=True)` 声明五件事;`_GAME_SIGN_PROVIDERS` 和 +`_GameSignProvider` 只保留为指向同一对象的历史别名: | 字段 | 含义 | | --- | --- | | `token_field` | `GameSignAccountGroup` 上的凭据字段名,同时是「该平台是否已配置」的判据 | | `log_name` | 日志与错误文案中的平台名 | -| `runner` | `async (token, account_name, account_uid) -> _ProviderRun` | +| `runner` | `async (token, account_name, account_uid) -> _CommunityProviderRun` | | `resolve_platforms` | 由凭据推导本次涉及的平台名元组 | | `error_game` | 平台名 → 失败结果里的 `game` 字段 | 当前四项:`SklandToken`/森空岛、`MiyousheToken`/米游社、`KuroToken`/库街区、`TaygedoToken`/塔吉多。 -`GAME_SIGN_TOKEN_FIELDS` 由注册表派生,`has_game_sign_credentials()` 又由它派生。**不要另写一份平台或字段清单**:调度台跳过账号、前端判空、通知分组都应回到注册表。 +`COMMUNITY_TOKEN_FIELDS` 由注册表派生,`has_community_credentials()` 又由它派生; +`GAME_SIGN_TOKEN_FIELDS` 和 `has_game_sign_credentials()` 是兼容别名。**不要另写一份 +平台或字段清单**:调度台跳过账号、前端判空、通知分组都应回到注册表。 只有塔吉多使用动态 `resolve_platforms`(`_resolve_taygedo_platforms`):一份凭据可能同时覆盖**塔吉多**与**云异环**两个平台名。其余三家用 `_fixed_platforms(...)` 返回单元素元组。新增「一份凭据对多平台」的社区时照塔吉多写,不要在 `runner` 里硬编码平台名。 @@ -44,7 +49,9 @@ description: >- | 文件 | 职责 | | --- | --- | -| `app/tools/game_sign.py` | 注册表、锁、编排、结果归一、`format_sign_results` / `merge_sign_results` | +| `app/core/community_sign.py` | 签到流程锁、账号遍历、并发、日期标记和凭据更新回写 | +| `app/tools/game_sign.py` | provider 注册与执行、结果归一、旧入口兼容包装 | +| `app/tools/community.py` | 社区工具公开 facade,延迟转发到核心编排 | | `app/tools/skland.py` | 森空岛:凭据校验、角色发现、签到、账号密码登录 | | `app/tools/skland_response.py` | 森空岛响应形状判定(如 `is_skland_already_signed`),与请求逻辑分离 | | `app/tools/miyoushe.py` | 米游社:DS 签名、多游戏签到 | @@ -52,12 +59,14 @@ description: >- | `app/tools/kuro.py` | 库街区 | | `app/tools/taygedo.py` | 塔吉多 + 云异环:账号密码登录、Token 刷新、凭据序列化 | | `app/tools/game_sign_result.py` | 结果构建辅助(`SKLAND_GAME_MAPPING`、`build_skland_sign_results`) | -| `app/tools/game_sign_notify.py` | 通知文案格式化、平台排序、任务摘要挂载、多渠道推送与重试 | +| `app/tools/community_notify.py` | 社区通知消费 facade 和任务状态衔接 | +| `app/tools/game_sign_notify.py` | 历史通知实现、平台排序、多渠道推送与重试 | | `app/api/tools.py` | 手动签到、账号组 CRUD、塔吉多/森空岛一次性登录 | | `app/api/qr_login.py` | 米游社扫码登录三步路由 | | `app/core/timer.py` | 自动签到触发、任务前置签到、全局日期标记 | -`app/tools/__init__.py` 的 `__all__` 只有四项:`run_all_sign_in`、`format_sign_results`、`login_skland_with_password`、`skland_sign_in`。其余符号按需从具体模块导入,且多数调用点使用**函数内延迟导入**以避免启动期循环依赖;沿用这个写法,不要为了「统一出口」把签到模块提到包顶层导入。 +`app/tools/__init__.py` 同时导出社区语义入口和旧签到入口。涉及 core 的入口继续使用 +**函数内延迟导入**以避免启动期循环依赖;不要在工具包初始化时直接导入核心编排。 ### 前端表面 @@ -76,8 +85,8 @@ description: >- 1. **写平台适配模块** `app/tools/.py`。对外只暴露一个「跑一次签到」的入口与必要的凭据解析/校验函数。若参考了第三方项目的接口知识,按现有模块的写法在文件头加致谢与许可声明。 2. **加凭据字段**:在 `app/models/config.py` 的 `GameSignAccountGroup` 增加 `Token`,section 固定为 `"GameSignAccount"`,validator 必须是 `EncryptValidator()`,并在字段上方写 `## GameSignAccount - <说明> (DPAPI 加密)` 注释。 3. **加 schema 字段**:`app/models/schema.py` 的 `GameSignAccountGroupConfig` 增加同名可选字段。这是 API 出入口,字段名与配置项保持一致。 -4. **写 provider runner**:在 `game_sign.py` 加 `_run__provider()`,返回 `_ProviderRun(results=..., platforms=..., credential_updates=...)`。 -5. **注册**:在 `_GAME_SIGN_PROVIDERS` 追加一项。`GAME_SIGN_TOKEN_FIELDS`、`has_game_sign_credentials()`、并发执行、凭据回写、日期标记全部自动生效。 +4. **写 provider runner**:在 `game_sign.py` 加 `_run__provider()`,返回 `_CommunityProviderRun(results=..., platforms=..., credential_updates=...)`。 +5. **注册**:在 `_COMMUNITY_SIGN_PROVIDERS` 追加一项。`COMMUNITY_TOKEN_FIELDS`、`has_community_credentials()`、并发执行、凭据回写、日期标记全部自动生效。 6. **通知排序**:在 `game_sign_notify.py` 的 `_PLATFORM_ORDER` 追加平台名。缺失时该平台会落到排序尾部而不是报错,容易漏掉。 7. **重新生成 OpenAPI 客户端**,再在 `TabGameSign.vue` 加录入入口。禁止手改 `frontend/src/api/**`。 @@ -90,8 +99,8 @@ description: >- 3. 登录路由的 `except Exception` 分支**禁止** `exc_info` / `logger.exception` / 回显上游响应,只返回固定文案。参见 `login_taygedo` / `login_skland` 的注释:避免密码、请求对象或上游响应进入日志。 4. `ValueError` 才允许把消息带给用户(凭据不完整、格式错误等可预期原因);其他异常统一返回泛化文案。 5. 登录成功后必须**校验凭据完整性**再落盘(塔吉多校验 `accessToken`/`refreshToken`/`uid`,森空岛校验 `oauthToken`/`token`/`cred`),缺字段视为登录失败。 -6. 凭据刷新走 `_ProviderRun.credential_updates`:编排层在并发完成后统一比对新旧值并写回,值未变则跳过。**不要**在 runner 内部直接写 `account.set()`。 -7. 读取凭据统一用 `_read_game_sign_token()`:它容忍旧账号对象缺少新增字段(`AttributeError` / `KeyError` 均返回空串)。直接 `account.get(...)` 会在旧配置上炸。 +6. 凭据刷新走 `_CommunityProviderRun.credential_updates`:核心编排在并发完成后统一比对新旧值并写回,值未变则跳过。**不要**在 runner 内部直接写 `account.set()`。 +7. 读取凭据统一用 `read_community_token()`;`_read_game_sign_token()` 仅为兼容别名。它容忍旧账号对象缺少新增字段(`AttributeError` / `KeyError` 均返回空串)。直接 `account.get(...)` 会在旧配置上炸。 ## 并发与重复触发 @@ -99,23 +108,26 @@ description: >- | 锁 | 覆盖范围 | 违反时 | | --- | --- | --- | -| `_game_sign_flow_lock`(`game_sign_flow()` 上下文管理器) | 签到请求 **加** 结果落盘。通知在锁外发送。 | 直接抛 `GameSignInProgressError`,不排队等待 | -| `_game_sign_lock`(`run_all_sign_in` 内部) | 全局签到执行,配合 `_game_sign_lock_owner` ContextVar 支持**同任务嵌套重入** | 同上 | +| `_community_sign_flow_lock`(`community_sign_flow()` 上下文管理器) | 签到请求 **加** 结果落盘。通知在锁外发送。 | 直接抛 `CommunitySignInProgressError`,不排队等待 | +| `_community_sign_lock`(`run_community_sign_in` 内部) | 全局签到执行,配合 `_community_sign_lock_owner` ContextVar 支持**同任务嵌套重入** | 同上 | -两把锁都是**快速失败**而非等待:`if lock.locked(): raise`。API 层把 `GameSignInProgressError` 映射为 `code=409`;`timer` 层降级为 `logger.info` 后跳过本次触发。新增触发入口时必须选一种处理,不要静默 `await` 到锁释放。 +两把锁都是**快速失败**而非等待:`if lock.locked(): raise`。API 层把 +`CommunitySignInProgressError` 映射为 `code=409`;`timer` 层降级为 +`logger.info` 后跳过本次触发。旧异常名是同一类型的别名。新增触发入口时必须 +选一种处理,不要静默 `await` 到锁释放。 通知**必须在流程锁外**发送。慢渠道会阻塞后续操作,这是锁边界画在落盘之后的原因。 **日期标记有两层**,不要混用: -- 账号级 `GameSignAccount.LastSignDate`:`_run_all_sign_in` 内按账号写。自动模式(`force=False`)即使失败也标记当天,避免后续 MAS 任务反复请求上游;手动模式(`force=True`)仅在该账号所有已配置平台完成后才标记。 +- 账号级 `GameSignAccount.LastSignDate`:`run_community_sign_in` 内按账号写。自动模式(`force=False`)即使失败也标记当天,避免后续 MAS 任务反复请求上游;手动模式(`force=True`)仅在该账号所有已配置平台完成后才标记。 - 全局 `GameSign.LastSignDate`:由调用方在**所有**启用且有凭据的账号都完成后才写。 多账号串行签到可能跨越 0 点,写入时**重新取当前日期**而不是复用循环开始时的 `today`。改这段逻辑要保留这个行为。 ## 结果契约 -编排层的 `results` 是 `list[dict]`,键固定: +核心编排交付的 `results` 是 `list[dict]`,键固定: ```text account 账号名,失败结果为 "账号名/平台名" @@ -132,15 +144,17 @@ reason 失败原因 - `_notification_only`:占位结果(如「未获取到可签到角色」),进通知但被 `format_sign_results` 过滤,不进前端列表。 - `_completed`:完成态旁路标记,参与「是否全部完成」判定。 -**成功判据在三处重复出现**(`_all_enabled_platforms_signed`、`manual_game_sign`、`game_sign_notify._SUCCESS_STATUSES`):`status in ("成功", "已签到") or _completed`。改成功语义时必须同步这三处。 +**成功判据在三处重复出现**(`all_enabled_community_platforms_signed`、`manual_game_sign`、`game_sign_notify._SUCCESS_STATUSES`):`status in ("成功", "已签到") or _completed`。改成功语义时必须同步这三处。 `_decorate_provider_run()` 负责归一:补 `account` 与 `account_uid`、为没有结果的平台补占位。runner 返回的结果**不必**自己填 `account_uid`。 -`format_sign_results()` 输出 `{platform: [{account_alias, account_uid, games: [...]}]}`;`merge_sign_results()` 按 `account_uid` 替换受影响账号,避免旧成功状态遮蔽新失败结果(`replace` 参数已退化为兼容占位,两条路径行为相同)。 +`format_community_sign_results()` 输出 `{platform: [{account_alias, account_uid, games: [...]}]}`; +`merge_community_sign_results()` 按 `account_uid` 替换受影响账号,避免旧成功状态遮蔽 +新失败结果(`replace` 参数已退化为兼容占位,两条路径行为相同)。旧函数名继续转发。 ## 日志分级约定 -`_is_expected_provider_exception()` 是**唯一**的分级依据,`_run_provider()` 据此选择 `logger.warning` 还是 `logger.exception`: +`_is_expected_provider_exception()` 是**唯一**的分级依据,`run_community_provider()` 据此选择 `logger.warning` 还是 `logger.exception`: - 可预期(`warning` + 带消息给用户):`ValueError`、`httpx.HTTPError`、`TimeoutError`、`ConnectionError`;以及消息中含 `token`/`cookie`/`凭据`/`登录`/`风控`/`请求`/`接口`/`网络`/`offline`/`timeout`/`timed out` 的 `RuntimeError`。 - 非预期(`exception` + 只给用户泛化文案):其余全部。 @@ -151,13 +165,17 @@ reason 失败原因 | 入口 | 特点 | | --- | --- | -| `POST /api/tools/sign`(`manual_game_sign`) | `force=True`;通知走 `_dispatch_game_sign_notification`,`asyncio.shield` + 0.1s 超时后转后台,不阻塞响应 | -| `timer._execute_game_sign(source=...)` | `force=False`;`source` 在 `_TASK_GAME_SIGN_SOURCES` 内时结果交由**任务完成通知**消费,其余自动来源单独推送 | -| `timer.try_game_sign_for_task` | MAS 任务前置签到 | +| `POST /api/tools/sign`(历史路由和函数名保留) | `force=True`;调用 `run_community_sign_in`,通知走社区 facade,0.1s 超时后转后台 | +| `timer._execute_community_sign(source=...)` | `force=False`;任务来源的结果交由**任务完成通知**消费,其余自动来源单独推送 | +| `timer.try_community_for_task` | MAS 任务前置社区签到;旧方法名只作转发 | -`GameSign.NotifyEnabled` 是通知总开关,两条路径都要检查。后台通知任务必须持有强引用(`_PENDING_GAME_SIGN_NOTIFICATIONS`)并挂 `add_done_callback` 记录失败,否则任务可能被 GC。 +历史配置键 `GameSign.NotifyEnabled` 是通知总开关,两条路径都要检查。后台通知任务 +必须持有强引用(`_PENDING_COMMUNITY_NOTIFICATIONS`)并挂 `add_done_callback` 记录失败, +否则任务可能被 GC;不要为语义改名复制第二个配置键。 -`_check_system_time()` 只告警不阻断,且**不占签到锁**(在 `run_all_sign_in` 里以独立 task 起、finally 里取消)。时间源不可用时静默 `debug`。不要把它改成阻断条件。 +`check_community_system_time()` 只告警不阻断,且**不占签到锁**(在 +`run_community_sign_in` 里以独立 task 起、finally 里取消)。时间源不可用时静默 +`debug`。旧 `_check_system_time()` 名称仅为兼容别名。 ## 已知不一致(改动时留意,不要照抄) @@ -173,10 +191,10 @@ reason 失败原因 - [ ] 一次性登录密码用 `SecretStr`,异常分支未记录堆栈或上游响应 - [ ] 登录成功后校验了凭据完整性才落盘 - [ ] 凭据刷新经 `credential_updates` 回写,runner 内未直接写配置 -- [ ] 读凭据走 `_read_game_sign_token()`,兼容缺字段的旧账号 -- [ ] 新增触发入口显式处理 `GameSignInProgressError`(409 或跳过),未静默等待锁 +- [ ] 读凭据走 `read_community_token()`,兼容缺字段的旧账号 +- [ ] 新增触发入口显式处理 `CommunitySignInProgressError`(409 或跳过),未静默等待锁 - [ ] 通知在流程锁**外**发送,后台通知任务持有强引用 -- [ ] 成功判据三处(`_all_enabled_platforms_signed`、`manual_game_sign`、`_SUCCESS_STATUSES`)保持一致 +- [ ] 成功判据三处(`all_enabled_community_platforms_signed`、`manual_game_sign`、`_SUCCESS_STATUSES`)保持一致 - [ ] 账号级与全局 `LastSignDate` 语义未混用;跨 0 点重取日期的行为保留 - [ ] 新平台已加入 `_PLATFORM_ORDER` - [ ] 可预期失败走 `warning`,未因新异常类型扩大白名单 @@ -199,5 +217,3 @@ reason 失败原因 4. 不要在编排层按平台名写 `if`。需要平台差异时加 provider 字段。 5. 不要新增第二份平台清单或凭据字段清单。 6. 不要把凭据、密码或上游原始响应写进日志或 API 错误消息。 - - diff --git a/.agents/skills/mas-skills/SKILL.md b/.agents/skills/mas-skills/SKILL.md index 3f11d1b11..3fdf3a5db 100644 --- a/.agents/skills/mas-skills/SKILL.md +++ b/.agents/skills/mas-skills/SKILL.md @@ -41,6 +41,7 @@ Use these skills as needed: 9. `mas-script-specialized-adapter`: specialized script integration by script frontend architecture line; requires intake before implementation. 10. `mas-plan-schedule`: plan schedule type registration, backend/frontend plan dispatch, plan combobox consumers, and per-type table integration. 11. `mas-game-sign`: game community sign-in providers, credential encryption and login routes, sign-in locks and trigger paths, and result/notification contracts. +12. `mas-community`: community-tool semantics across credentials, role discovery, daily activity, sign scheduling, notifications, provider capability evidence, and compatibility migration. ## Global Constraints Apply these constraints before selecting or combining sub-skills. @@ -81,6 +82,8 @@ Use `mas-code-standards` for code-style decisions; use the docs site for contrib Use `mas-plan-schedule`. 12. Task mentions game community sign-in, `app/tools/game_sign*`, a sign-in platform such as Skland/Miyoushe/Kuro/Taygedo, sign-in credentials or QR login, or sign-in result and notification behavior: Use `mas-game-sign`. +13. Task mentions community plugins/tools, role discovery, daily activity or memo, provider capability references, community notification aggregation, or migration from `game_sign` semantics: +Use `mas-community`, and combine `mas-game-sign` when the change also touches the actual sign-in flow. ## Combined Execution Order When multiple concerns appear, apply this order: @@ -95,7 +98,8 @@ When multiple concerns appear, apply this order: 8. `mas-api-contract` 9. `mas-script-specialized-adapter` after architecture intake, when the task is a specialized adapter. 10. `mas-plan-schedule` -11. `mas-game-sign` +11. `mas-community` +12. `mas-game-sign` Reason: frontend tasks need their engineering and UI constraints loaded before implementation decisions; then establish local conventions, place code correctly, stabilize model structure, then naming, function behavior, and transport contract. Specialized adapters add a mandatory architecture-intake step, plan schedule rules apply when the task touches scheduler registration, and game sign rules apply last because they constrain credential handling and external request behavior inside an already-placed module. diff --git a/app/api/qr_login.py b/app/api/qr_login.py index f3fd00190..f7f93fc30 100644 --- a/app/api/qr_login.py +++ b/app/api/qr_login.py @@ -25,9 +25,9 @@ 可安全删除本文件,不会影响任何已有功能。 删除后同时移除 main.py 中的 include_router 调用。 -流程(Passport 模式): +流程(GameToken 优先,Passport 回退): 1. /create → 生成二维码 (ticket, qr_url, device) - 2. /check → 轮询状态 (Init/Scanned/Confirmed),确认后返回 cookies_str + 2. /check → 轮询状态 (Init/Scanned/Confirmed),确认后返回完整 cookies_str 3. /save → 将 cookies 保存到账号配置 """ @@ -98,7 +98,7 @@ async def qr_create() -> QrCreateOut: @router.post("/check", summary="轮询扫码状态", response_model=QrCheckOut) async def qr_check(body: QrCheckIn = Body(...)) -> QrCheckOut: - """轮询状态,确认后返回从 Passport 响应提取的 cookies。""" + """轮询状态,确认后返回 GameToken 兑换或 Passport 提取的 cookies。""" try: from app.tools.miyoushe_qr import check_qr_status @@ -138,7 +138,8 @@ async def qr_save(body: QrSaveIn = Body(...)) -> OutBase: try: from app.tools.miyoushe import validate_miyoushe_cookie - cookie = body.cookie.strip() + # 全量 Cookie 可能包含服务端兼容字段,保存路径不得重建或裁剪。 + cookie = body.cookie validate_miyoushe_cookie(cookie) data = {"GameSignAccount": {"MiyousheToken": cookie}} await Config.update_game_sign_account(body.account_uid, data) diff --git a/app/api/tools.py b/app/api/tools.py index 8efb05136..7bea1e881 100644 --- a/app/api/tools.py +++ b/app/api/tools.py @@ -28,6 +28,15 @@ from uuid import UUID from app.core import Config +from app.core.community_sign import ( + CommunitySignInProgressError, + community_sign_flow, + run_community_sign_in, +) +from app.core.community_scheduler import ( + CommunityActivityInProgressError, + community_activity_flow, +) from app.models.schema import ( ToolsGetOut, ToolsConfig, @@ -40,6 +49,11 @@ GameSignAccountDeleteIn, GameSignAccountReorderIn, GameSignAccountsListOut, + CommunityActivityOut, + CommunityActivityQueryIn, + CommunityActivityResourceOut, + CommunityActivitySnapshotOut, + CommunityActivityTaskOut, SklandLoginIn, TaygedoLoginIn, ) @@ -47,33 +61,35 @@ from app.utils.logger import get_logger router = APIRouter(prefix="/api/tools", tags=["工具设置"]) -logger = get_logger("游戏签到 API") -_PENDING_GAME_SIGN_NOTIFICATIONS: set[asyncio.Task] = set() +logger = get_logger("游戏社区 API") +_PENDING_COMMUNITY_NOTIFICATIONS: set[asyncio.Task] = set() -def _track_game_sign_notification(task: asyncio.Task) -> None: +def _track_community_notification(task: asyncio.Task) -> None: """保留后台通知任务引用,并消费完成后的异常。""" - _PENDING_GAME_SIGN_NOTIFICATIONS.discard(task) + _PENDING_COMMUNITY_NOTIFICATIONS.discard(task) if task.cancelled(): return try: failed_channels = task.result() except Exception as exc: - logger.warning(f"后台游戏签到通知发送失败: {exc}") + logger.warning(f"后台游戏社区通知发送失败: {exc}") return if failed_channels: - logger.warning(f"后台游戏签到通知部分失败: {'、'.join(failed_channels)}") + logger.warning( + f"后台游戏社区通知部分失败: {'、'.join(failed_channels)}" + ) -async def _dispatch_game_sign_notification(results: list[dict]) -> list[str] | None: +async def _dispatch_community_notification(results: list[dict]) -> list[str] | None: """发送签到通知;慢渠道转后台,避免阻塞签到完成响应。""" - from app.tools.game_sign_notify import push_game_sign_notification + from app.tools.community_notify import push_community_notification - task = asyncio.create_task(push_game_sign_notification(results)) - _PENDING_GAME_SIGN_NOTIFICATIONS.add(task) - task.add_done_callback(_track_game_sign_notification) + task = asyncio.create_task(push_community_notification(results)) + _PENDING_COMMUNITY_NOTIFICATIONS.add(task) + task.add_done_callback(_track_community_notification) try: return await asyncio.wait_for(asyncio.shield(task), timeout=0.1) except asyncio.TimeoutError: @@ -84,8 +100,8 @@ async def _dispatch_game_sign_notification(results: list[dict]) -> list[str] | N return ["通知服务"] -def _get_game_sign_field(account: object, field: str, default=None): - """读取签到账号字段时兼容未包含新增字段的旧账号对象。""" +def _get_community_account_field(account: object, field: str, default=None): + """读取社区账号字段时兼容未包含新增字段的旧账号对象。""" try: return account.get("GameSignAccount", field) # type: ignore[attr-defined] @@ -93,6 +109,9 @@ def _get_game_sign_field(account: object, field: str, default=None): return default +_get_game_sign_field = _get_community_account_field + + @router.post( "/get", tags=["Get"], @@ -147,22 +166,21 @@ async def manual_game_sign() -> OutBase: """手动触发游戏社区签到""" try: - from app.tools.game_sign import ( - GameSignInProgressError, - format_sign_results, - game_sign_flow, - has_game_sign_credentials, - run_all_sign_in, + from app.tools.community import ( + format_community_sign_results, + has_community_credentials, ) # 流程锁覆盖签到和结果落盘,通知在锁外发送,避免慢渠道阻塞后续操作。 - async with game_sign_flow(): - results = await run_all_sign_in(force=True) + async with community_sign_flow(): + results = await run_community_sign_in(force=True) # 格式化并存储结果 - formatted = format_sign_results(results) + formatted = format_community_sign_results(results) # 合并结果(手动签到按 account_uid 替换旧数据) - result_update = Config.update_game_sign_results(formatted, replace=True) + result_update = Config.update_community_results( + formatted, replace=True + ) if isawaitable(result_update): await result_update @@ -170,9 +188,14 @@ async def manual_game_sign() -> OutBase: today = datetime.now(tz=UTC8).strftime("%Y-%m-%d") all_signed = True for uid, account in Config.ToolsConfig.GameSign_Accounts.items(): - has_credentials = has_game_sign_credentials(account) - if _get_game_sign_field(account, "Enabled") and has_credentials: - if _get_game_sign_field(account, "LastSignDate") != today: + has_credentials = has_community_credentials(account) + if ( + _get_community_account_field(account, "Enabled") + and has_credentials + ): + if _get_community_account_field( + account, "LastSignDate" + ) != today: all_signed = False break if all_signed: @@ -185,7 +208,7 @@ async def manual_game_sign() -> OutBase: ): warning_messages.append("签到未全部完成,请查看签到结果") if results and Config.ToolsConfig.get("GameSign", "NotifyEnabled"): - failed_channels = await _dispatch_game_sign_notification(results) + failed_channels = await _dispatch_community_notification(results) if failed_channels: warning_messages.append( f"部分通知发送失败:{'、'.join(failed_channels)}" @@ -193,7 +216,7 @@ async def manual_game_sign() -> OutBase: if warning_messages: return OutBase(status="warning", message=";".join(warning_messages)) - except GameSignInProgressError as e: + except CommunitySignInProgressError as e: return OutBase(code=409, status="error", message=str(e)) except Exception as e: return OutBase( @@ -202,18 +225,78 @@ async def manual_game_sign() -> OutBase: return OutBase(message="签到完成") -# ==================== 游戏签到账号组 CRUD ==================== +@router.post( + "/community/activity/query", + tags=["Community"], + summary="查询游戏社区日常活跃度", + response_model=CommunityActivityOut, + status_code=200, +) +async def query_community_activity( + query: CommunityActivityQueryIn = Body(...), +) -> CommunityActivityOut: + """查询游戏社区日常活动,不占用签到流程锁。""" + + try: + from app.core.community_activity import ( + collect_configured_community_activity, + ) + + async with community_activity_flow(): + snapshots = await collect_configured_community_activity( + query.accountIds, + proxy=Config.proxy, + ) + except CommunityActivityInProgressError as exc: + return CommunityActivityOut(code=409, status="error", message=str(exc)) + except ValueError as exc: + return CommunityActivityOut(code=400, status="error", message=str(exc)) + except Exception: + return CommunityActivityOut( + code=500, + status="error", + message="游戏社区日常查询失败", + ) + + return CommunityActivityOut( + data=[ + CommunityActivitySnapshotOut( + account=snapshot.account, + accountUid=snapshot.account_uid, + game=snapshot.game, + platform=snapshot.platform, + status=snapshot.status, + completed=snapshot.completed, + target=snapshot.target, + tasks=[CommunityActivityTaskOut(**dict(task)) for task in snapshot.tasks], + resources=[ + CommunityActivityResourceOut(**dict(resource)) + for resource in snapshot.resources + ], + reason=snapshot.reason, + updatedAt=snapshot.updated_at, + roleName=snapshot.role_name, + roleUid=snapshot.role_uid, + server=snapshot.server, + source=snapshot.source, + ) + for snapshot in snapshots + ] + ) + + +# ==================== 游戏社区账号组 CRUD ==================== @router.post( "/sign/account/list", tags=["GameSign"], - summary="获取所有游戏签到账号组", + summary="获取所有游戏社区账号组", response_model=GameSignAccountsListOut, status_code=200, ) async def list_game_sign_accounts() -> GameSignAccountsListOut: - """获取所有游戏签到账号组""" + """获取所有游戏社区账号组""" try: data = await Config.get_game_sign_accounts() @@ -230,12 +313,12 @@ async def list_game_sign_accounts() -> GameSignAccountsListOut: @router.post( "/sign/account/add", tags=["GameSign"], - summary="添加游戏签到账号组", + summary="添加游戏社区账号组", response_model=GameSignAccountCreateOut, status_code=200, ) async def add_game_sign_account() -> GameSignAccountCreateOut: - """添加游戏签到账号组""" + """添加游戏社区账号组""" try: uid, config = await Config.add_game_sign_account() @@ -258,14 +341,14 @@ async def add_game_sign_account() -> GameSignAccountCreateOut: @router.post( "/sign/account/get", tags=["GameSign"], - summary="获取游戏签到账号组详情", + 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) @@ -286,14 +369,14 @@ async def get_game_sign_account( @router.post( "/sign/account/update", tags=["GameSign"], - summary="更新游戏签到账号组配置", + summary="更新游戏社区账号组配置", response_model=OutBase, status_code=200, ) async def update_game_sign_account( account: GameSignAccountUpdateIn = Body(...), ) -> OutBase: - """更新游戏签到账号组配置""" + """更新游戏社区账号组配置""" try: # GameSignAccountGroupConfig 是扁平格式,需包装为 {group: {name: value}} 传给 ConfigBase.set @@ -322,14 +405,14 @@ async def update_game_sign_account( @router.post( "/sign/account/delete", tags=["GameSign"], - summary="删除游戏签到账号组", + 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) @@ -343,14 +426,14 @@ async def delete_game_sign_account( @router.post( "/sign/account/reorder", tags=["GameSign"], - summary="调整游戏签到账号组顺序", + 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) diff --git a/app/core/community_activity.py b/app/core/community_activity.py new file mode 100644 index 000000000..79428d24a --- /dev/null +++ b/app/core/community_activity.py @@ -0,0 +1,196 @@ +# 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 __future__ import annotations + +from collections.abc import Sequence +from datetime import datetime +from typing import Any + +from app.utils.constants import UTC8 +from app.utils.logger import get_logger + +from app.tools.community_activity_provider import CommunityActivityProvider +from app.tools.community_activity_roles import ( + ACTIVITY_COMMUNITY_DEFINITIONS, + CommunityActivityProviderDefinition, + CommunityActivityRole, +) +from app.tools.community_activity_transport import ( + CommunityActivityTarget, + CommunityActivityTransportError, + collect_community_activity, +) +from app.tools.game_sign_contract import ActivityState, CommunityActivitySnapshot + +__all__ = ["collect_configured_community_activity"] + + +logger = get_logger("游戏社区活动") + + +def _account_value(account: object, name: str, default: Any = "") -> Any: + try: + return account.get("GameSignAccount", name) # type: ignore[attr-defined] + except (AttributeError, KeyError): + return default + + +def _failure_state(error: Exception) -> tuple[ActivityState, str]: + if isinstance(error, CommunityActivityTransportError): + return error.status, error.reason + if isinstance(error, ValueError): + text = str(error) + if any(marker in text for marker in ("风控", "限制", "非 JSON")): + return "limited", "社区角色列表受到上游限制" + return "unavailable", "社区角色列表响应无法识别" + return "failed", "社区角色列表请求失败" + + +def _empty_game_snapshot( + *, + account_uid: str, + account_name: str, + definition: CommunityActivityProviderDefinition, + game: str, + status: ActivityState = "empty", + reason: str = "未发现已绑定角色", +) -> CommunityActivitySnapshot: + return CommunityActivitySnapshot( + account=account_name, + account_uid=account_uid, + game=game, + platform=definition.platform, + status=status, + reason=reason, + updated_at=datetime.now(tz=UTC8).isoformat(), + ) + + +def _targets_for_roles( + *, + roles: Sequence[CommunityActivityRole], + account_uid: str, + account_name: str, +) -> tuple[CommunityActivityTarget, ...]: + return tuple( + role.to_target(account_uid=account_uid, account_name=account_name) + for role in roles + ) + + +async def collect_configured_community_activity( + account_ids: Sequence[str] | None = None, + *, + proxy: str | None = None, + max_concurrency: int = 4, +) -> tuple[CommunityActivitySnapshot, ...]: + """读取已配置账号组并查询已登记社区的日常活动。""" + + from app.core import Config + from app.tools.game_sign import ( + get_community_token_field, + read_community_token, + ) + + requested_ids = { + str(account_id).strip() for account_id in (account_ids or ()) if account_id + } + resolved_proxy = proxy if proxy is not None else Config.proxy + snapshots: list[CommunityActivitySnapshot] = [] + matched_ids: set[str] = set() + + for raw_uid, account in Config.ToolsConfig.GameSign_Accounts.items(): + account_uid = str(raw_uid) + if requested_ids and account_uid not in requested_ids: + continue + matched_ids.add(account_uid) + account_name = str(_account_value(account, "Name", "用户") or "用户") + if not _account_value(account, "Enabled", False): + continue + + for definition in ACTIVITY_COMMUNITY_DEFINITIONS: + token_field = get_community_token_field(definition.platform) + token = read_community_token(account, token_field) + if not token: + continue + + provider = CommunityActivityProvider( + platform=definition.platform, + raw_credential=token, + proxy=resolved_proxy, + ) + try: + discovered = await provider.discover_roles( + account_uid=account_uid, + account_name=account_name, + ) + except Exception as error: + status, reason = _failure_state(error) + logger.warning(f"{account_name} {definition.platform}角色发现失败: {reason}") + snapshots.extend( + _empty_game_snapshot( + account_uid=account_uid, + account_name=account_name, + definition=definition, + game=game, + status=status, + reason=reason, + ) + for game in definition.games + ) + continue + + targets = _targets_for_roles( + roles=discovered.roles, + account_uid=account_uid, + account_name=account_name, + ) + queried: tuple[CommunityActivitySnapshot, ...] = () + if targets: + queried = await collect_community_activity( + targets, + provider.request, + max_concurrency=max_concurrency, + ) + + for game in definition.games: + game_snapshots = tuple( + snapshot for snapshot in queried if snapshot.game == game + ) + if game_snapshots: + snapshots.extend(game_snapshots) + else: + snapshots.append( + _empty_game_snapshot( + account_uid=account_uid, + account_name=account_name, + definition=definition, + game=game, + ) + ) + + if requested_ids and matched_ids != requested_ids: + raise ValueError("未找到指定的游戏社区账号组") + return tuple(snapshots) diff --git a/app/core/community_scheduler.py b/app/core/community_scheduler.py new file mode 100644 index 000000000..3154f0615 --- /dev/null +++ b/app/core/community_scheduler.py @@ -0,0 +1,127 @@ +# 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 +from collections.abc import Callable, Mapping +from contextlib import asynccontextmanager +from typing import Any, Literal + + +CommunityTriggerSource = Literal[ + "scheduled", + "startup", + "task_scheduled", + "task_manual", + "task_startup", +] +TASK_COMMUNITY_SOURCES = frozenset( + {"task_scheduled", "task_manual", "task_startup"} +) +AccountCredentialChecker = Callable[[object], bool] + + +def _default_credential_checker(account: object) -> bool: + from app.tools.community import has_community_credentials + + return has_community_credentials(account) + + +def has_pending_community_account( + account: object, + today: str, + *, + has_credentials: AccountCredentialChecker | None = None, +) -> bool: + """判断账号是否启用、配置凭据且尚未完成今日社区签到。""" + + if not account.get("GameSignAccount", "Enabled"): # type: ignore[attr-defined] + return False + checker = has_credentials or _default_credential_checker + if not checker(account): + return False + return account.get("GameSignAccount", "LastSignDate") != today # type: ignore[attr-defined] + + +def all_community_accounts_signed( + accounts: Mapping[Any, object], + today: str, + *, + has_credentials: AccountCredentialChecker | None = None, +) -> bool: + """判断所有具备凭据的启用账号是否已完成今日社区签到。""" + + return not any( + has_pending_community_account( + account, + today, + has_credentials=has_credentials, + ) + for _, account in accounts.items() + ) + + +def should_run_community_for_source( + *, + enabled: bool, + run_on_startup: bool, + source: CommunityTriggerSource, +) -> bool: + """判断当前配置是否允许指定触发来源执行社区签到。""" + + if not enabled: + return False + if source == "startup": + return bool(run_on_startup) + return source in TASK_COMMUNITY_SOURCES + +__all__ = [ + "TASK_COMMUNITY_SOURCES", + "CommunityActivityInProgressError", + "CommunityTriggerSource", + "all_community_accounts_signed", + "community_activity_flow", + "has_pending_community_account", + "should_run_community_for_source", +] + + +class CommunityActivityInProgressError(RuntimeError): + """社区日常查询已在执行。""" + + +_community_activity_lock = asyncio.Lock() + + +@asynccontextmanager +async def community_activity_flow(): + """保护社区日常查询自身,不占用签到流程锁。""" + + if _community_activity_lock.locked(): + raise CommunityActivityInProgressError("社区日常查询正在执行,请稍后重试") + + await _community_activity_lock.acquire() + try: + yield + finally: + _community_activity_lock.release() diff --git a/app/core/community_sign.py b/app/core/community_sign.py new file mode 100644 index 000000000..194740149 --- /dev/null +++ b/app/core/community_sign.py @@ -0,0 +1,257 @@ +# 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 + + +"""游戏社区签到核心编排,平台请求仍由唯一 provider 注册表承载。""" + +import asyncio +from contextlib import asynccontextmanager +from contextvars import ContextVar +from datetime import datetime + +from app.tools.game_sign import ( + check_community_system_time, + get_community_sign_providers, + read_community_token, + run_community_provider, +) +from app.tools.game_sign_contract import CommunitySignInProgressError +from app.tools.game_sign_credentials import is_community_credential_configured +from app.utils.constants import UTC8 +from app.utils.logger import get_logger + +from .config import Config + +__all__ = [ + "CommunitySignInProgressError", + "all_enabled_community_platforms_signed", + "community_sign_flow", + "run_community_sign_in", +] + + +logger = get_logger("游戏社区签到") + +_community_sign_lock = asyncio.Lock() +_community_sign_flow_lock = asyncio.Lock() +_community_sign_lock_owner: ContextVar[asyncio.Task | None] = ContextVar( + "community_sign_lock_owner", default=None +) + + +@asynccontextmanager +async def community_sign_flow(): + """保护签到请求及结果落盘,通知由调用方在锁外发送。""" + + if _community_sign_flow_lock.locked(): + raise CommunitySignInProgressError("游戏社区签到正在执行,请稍后重试") + + await _community_sign_flow_lock.acquire() + try: + yield + finally: + _community_sign_flow_lock.release() + + +async def _enter_community_sign_lock() -> bool: + """获取全局签到锁;同一任务嵌套调用时复用已持有的锁。""" + + current_task = asyncio.current_task() + if current_task is not None and _community_sign_lock_owner.get() is current_task: + return False + + if _community_sign_lock.locked(): + raise CommunitySignInProgressError("游戏社区签到正在执行,请稍后重试") + + await _community_sign_lock.acquire() + _community_sign_lock_owner.set(current_task) + return True + + +def _exit_community_sign_lock(acquired: bool) -> None: + if not acquired: + return + _community_sign_lock_owner.set(None) + _community_sign_lock.release() + + +def all_enabled_community_platforms_signed( + results: list[dict], + *, + account_uid: str, + enabled_platforms: list[str], +) -> bool: + """判断账号的全部已配置平台结果是否均已完成。""" + + if not enabled_platforms: + return False + + for platform in enabled_platforms: + platform_results = [ + result + for result in results + if result.get("account_uid") == account_uid + and result.get("platform") == platform + ] + if not platform_results or any( + result.get("status") not in ("成功", "已签到") + and not result.get("_completed") + for result in platform_results + ): + return False + + return True + + +async def run_community_sign_in(force: bool = False) -> list[dict]: + """协调执行游戏社区签到,避免重复签到和重复通知。""" + + # 时间检查只提供告警,不应阻塞真实签到或占用签到锁。 + time_check_task = asyncio.create_task(check_community_system_time()) + acquired = False + try: + acquired = await _enter_community_sign_lock() + return await _run_configured_community_sign_in(force=force) + finally: + _exit_community_sign_lock(acquired) + if not time_check_task.done(): + time_check_task.cancel() + await asyncio.gather(time_check_task, return_exceptions=True) + + +async def _run_configured_community_sign_in(force: bool = False) -> list[dict]: + """执行所有已配置平台的签到。 + + 平台由凭据字段注册表驱动。同一账号的独立社区并发执行,适配器内部 + 仍自行控制请求间隔和风控策略;凭据刷新结果优先即时回写,收尾阶段再统一兜底。 + """ + + results: list[dict] = [] + today = datetime.now(tz=UTC8).strftime("%Y-%m-%d") + + providers = get_community_sign_providers() + 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) + + # 跳过已禁用的用户。 + 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 + + tokens = { + provider.token_field: read_community_token( + account, provider.token_field + ) + for provider in providers + } + configured = [ + provider + for provider in providers + if is_community_credential_configured( + provider.token_field, + tokens.get(provider.token_field, ""), + ) + ] + if not configured: + continue + + credential_update_lock = asyncio.Lock() + + async def persist_credential_update(field: str, value: str) -> None: + """在社区运行期间写穿轮换凭据,失败时交给收尾兜底。""" + + updated_token = str(value or "").strip() + if not updated_token: + return + async with credential_update_lock: + if updated_token == tokens.get(field, ""): + return + try: + # 直接 set 不重置 LastSignDate;刷新凭据不应改变签到去重语义。 + await account.set("GameSignAccount", field, updated_token) + except Exception as error: + logger.warning( + f"[{account_name}] 保存{field}失败: {type(error).__name__}" + ) + raise + tokens[field] = updated_token + + # 不同社区互不依赖,按注册顺序并发执行,完成后仍按固定顺序合并结果。 + provider_runs = await asyncio.gather( + *( + run_community_provider( + provider, + tokens[provider.token_field], + account_name=account_name, + account_uid=account_uid, + on_credential_update=persist_credential_update, + ) + for provider in configured + ) + ) + enabled_platforms: list[str] = [] + for provider, run in zip(configured, provider_runs): + for platform in run.platforms: + if platform not in enabled_platforms: + enabled_platforms.append(platform) + results.extend(run.results) + for field, updated_token in run.credential_updates.items(): + if not updated_token or updated_token == tokens.get(field, ""): + continue + try: + await account.set("GameSignAccount", field, updated_token) + tokens[field] = updated_token + except Exception as error: + logger.warning( + f"[{account_name}] 保存{field}失败: {type(error).__name__}" + ) + + # 自动签到每天只尝试一次。失败也要记住当天的尝试,避免后续 MAS 任务反复请求; + # 手动签到使用 force=True,仍只在所有已配置平台完成后更新日期。 + all_platforms_signed = all_enabled_community_platforms_signed( + results, + account_uid=account_uid, + enabled_platforms=enabled_platforms, + ) + should_mark_signed = bool(enabled_platforms) and ( + not force or all_platforms_signed + ) + if should_mark_signed: + try: + # 多账号串行签到可能跨越 0 点,写入时重新取当前日期, + # 避免把新一天的签到记成旧日期导致次日误判。 + sign_date = datetime.now(tz=UTC8).strftime("%Y-%m-%d") + await account.set("GameSignAccount", "LastSignDate", sign_date) + except Exception as error: + logger.warning(f"[{account_name}] 保存签到完成日期失败: {error}") + + if not results: + logger.info("没有配置任何社区平台") + + return results diff --git a/app/core/config.py b/app/core/config.py index 915ecac43..0a64c652c 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -92,7 +92,7 @@ def _load_game_sign_result_snapshot(path: Path, *, result_date: str) -> dict[str, Any]: - """读取当天的游戏签到结果快照。""" + """读取当天的游戏社区结果快照。""" if not path.exists(): return {} @@ -100,7 +100,7 @@ def _load_game_sign_result_snapshot(path: Path, *, result_date: str) -> dict[str try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e: - logger.warning(f"读取游戏签到结果快照失败: {e}") + logger.warning(f"读取游戏社区结果快照失败: {e}") return {} if not isinstance(payload, dict) or payload.get("date") != result_date: @@ -108,7 +108,7 @@ def _load_game_sign_result_snapshot(path: Path, *, result_date: str) -> dict[str result = payload.get("result") if not isinstance(result, dict): - logger.warning("游戏签到结果快照格式无效,已忽略") + logger.warning("游戏社区结果快照格式无效,已忽略") return {} return result @@ -116,7 +116,7 @@ def _load_game_sign_result_snapshot(path: Path, *, result_date: str) -> dict[str def _save_game_sign_result_snapshot( path: Path | None, result: dict[str, Any], *, result_date: str ) -> None: - """原子保存游戏签到结果快照(走 ``app.utils.io.write_file``)。""" + """原子保存游戏社区结果快照(走 ``app.utils.io.write_file``)。""" if path is None: return @@ -124,7 +124,7 @@ def _save_game_sign_result_snapshot( try: write_file(path, {"date": result_date, "result": result}) except (OSError, TypeError, ValueError) as e: - logger.warning(f"保存游戏签到结果快照失败: {e}") + logger.warning(f"保存游戏社区结果快照失败: {e}") def _parse_maa_drop_statistics(logs: list[str]) -> dict[str, dict[str, int]]: @@ -342,12 +342,12 @@ 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 + # 游戏社区:连接账号组 MultipleConfig await self.ToolsConfig.GameSign_Accounts.connect( self.config_path / "GameSignAccounts.json" ) - # 游戏签到:恢复当天的结果快照,跨日结果不继续展示 + # 游戏社区:恢复当天的结果快照,跨日结果不继续展示 today = datetime.now(tz=UTC8).strftime("%Y-%m-%d") self.ToolsConfig._game_sign_result_data = _load_game_sign_result_snapshot( self.config_path / GAME_SIGN_RESULT_FILENAME, @@ -1634,17 +1634,17 @@ async def get_tools(self) -> Dict[str, Any]: return await self.ToolsConfig.toDict() - async def update_game_sign_results( + async def update_community_results( self, formatted: dict[str, Any], *, replace: bool = False ) -> None: - """合并、持久化并广播游戏签到结果。 + """合并、持久化并广播游戏社区结果。 Args: formatted: 已按平台和账号分组的签到结果。 replace: 是否按账号 UID 替换已有结果。 """ - from app.tools.game_sign import merge_sign_results + from app.tools.game_sign import merge_community_sign_results today = datetime.now(tz=UTC8).strftime("%Y-%m-%d") existing = ( @@ -1652,7 +1652,9 @@ async def update_game_sign_results( if self._game_sign_result_date == today else {} ) - result = merge_sign_results(existing, formatted, replace=replace) + result = merge_community_sign_results( + existing, formatted, replace=replace + ) self.ToolsConfig._game_sign_result_data = result self._game_sign_result_date = today _save_game_sign_result_snapshot( @@ -1673,7 +1675,14 @@ async def update_game_sign_results( ), ) except Exception as e: - logger.warning(f"广播游戏签到结果失败: {e}") + logger.warning(f"广播游戏社区结果失败: {e}") + + async def update_game_sign_results( + self, formatted: dict[str, Any], *, replace: bool = False + ) -> None: + """兼容旧调用方,转发到社区结果更新入口。""" + + await self.update_community_results(formatted, replace=replace) async def update_tools(self, data: Dict[str, Dict[str, Any]]) -> None: """更新工具设置""" @@ -1684,21 +1693,21 @@ async def update_tools(self, data: Dict[str, Dict[str, Any]]) -> None: logger.success("工具设置更新成功") - # ==================== 游戏签到账号组 CRUD ==================== + # ==================== 游戏社区账号组 CRUD ==================== async def get_game_sign_accounts( self, *, if_decrypt: bool = True ) -> Dict[str, Any]: - """获取所有游戏签到账号组""" + """获取所有游戏社区账号组""" - logger.debug("获取所有游戏签到账号组") + logger.debug("获取所有游戏社区账号组") return await self.ToolsConfig.GameSign_Accounts.toDict(if_decrypt=if_decrypt) async def add_game_sign_account(self) -> tuple[uuid.UUID, Any]: - """添加游戏签到账号组""" + """添加游戏社区账号组""" - logger.info("添加游戏签到账号组") + logger.info("添加游戏社区账号组") uid, config = await self.ToolsConfig.GameSign_Accounts.add(GameSignAccountGroup) return uid, config @@ -1706,9 +1715,9 @@ async def add_game_sign_account(self) -> tuple[uuid.UUID, Any]: async def get_game_sign_account( self, account_id: str, *, if_decrypt: bool = True ) -> Dict[str, Any]: - """获取游戏签到账号组详情""" + """获取游戏社区账号组详情""" - logger.debug(f"获取游戏签到账号组: {account_id}") + logger.debug(f"获取游戏社区账号组: {account_id}") account_uid = uuid.UUID(account_id) return await self.ToolsConfig.GameSign_Accounts[account_uid].toDict( @@ -1716,7 +1725,7 @@ async def get_game_sign_account( ) def _clear_game_sign_account_results(self, account_id: str) -> None: - """清除指定游戏签到账号的结果。""" + """清除指定游戏社区账号的结果。""" today = datetime.now(tz=UTC8).strftime("%Y-%m-%d") result = self.ToolsConfig._game_sign_result_data @@ -1748,15 +1757,15 @@ def _clear_game_sign_account_results(self, account_id: str) -> None: async def update_game_sign_account( self, account_id: str, data: Dict[str, Dict[str, Any]] ) -> None: - """更新游戏签到账号组配置""" + """更新游戏社区账号组配置""" - logger.info(f"更新游戏签到账号组: {account_id}") + logger.info(f"更新游戏社区账号组: {account_id}") account_uid = uuid.UUID(account_id) account = self.ToolsConfig.GameSign_Accounts[account_uid] - from app.tools.game_sign import GAME_SIGN_TOKEN_FIELDS + from app.tools.game_sign import COMMUNITY_TOKEN_FIELDS - credential_fields = set(GAME_SIGN_TOKEN_FIELDS) + credential_fields = set(COMMUNITY_TOKEN_FIELDS) credential_changed = False for group, items in data.items(): @@ -1774,18 +1783,18 @@ async def update_game_sign_account( self._clear_game_sign_account_results(account_id) async def delete_game_sign_account(self, account_id: str) -> None: - """删除游戏签到账号组""" + """删除游戏社区账号组""" - logger.info(f"删除游戏签到账号组: {account_id}") + logger.info(f"删除游戏社区账号组: {account_id}") account_uid = uuid.UUID(account_id) await self.ToolsConfig.GameSign_Accounts.remove(account_uid) self._clear_game_sign_account_results(account_id) async def reorder_game_sign_accounts(self, order: list[str]) -> None: - """调整游戏签到账号组顺序""" + """调整游戏社区账号组顺序""" - logger.info("调整游戏签到账号组顺序") + logger.info("调整游戏社区账号组顺序") await self.ToolsConfig.GameSign_Accounts.setOrder([uuid.UUID(_) for _ in order]) diff --git a/app/core/game_sign_scheduler.py b/app/core/game_sign_scheduler.py new file mode 100644 index 000000000..ec24cdadb --- /dev/null +++ b/app/core/game_sign_scheduler.py @@ -0,0 +1,49 @@ +# 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 .community_scheduler import ( + AccountCredentialChecker, + CommunityTriggerSource, + TASK_COMMUNITY_SOURCES, + all_community_accounts_signed, + has_pending_community_account, + should_run_community_for_source, +) + + +# Keep historical imports working while new callers use community semantics. +GameSignSource = CommunityTriggerSource +TASK_GAME_SIGN_SOURCES = TASK_COMMUNITY_SOURCES +has_pending_game_sign_account = has_pending_community_account +all_game_sign_accounts_signed = all_community_accounts_signed +should_run_game_sign_for_source = should_run_community_for_source + +__all__ = [ + "AccountCredentialChecker", + "GameSignSource", + "TASK_GAME_SIGN_SOURCES", + "all_game_sign_accounts_signed", + "has_pending_game_sign_account", + "should_run_game_sign_for_source", +] diff --git a/app/core/task_manager.py b/app/core/task_manager.py index cde10bb0a..9b94b1f2a 100644 --- a/app/core/task_manager.py +++ b/app/core/task_manager.py @@ -287,8 +287,8 @@ async def _run_main_task(self): "manual_task": "task_manual", "startup_task": "task_startup", }.get(self.task_info.trigger_source, "task_manual") - self.task_info.game_sign_results = await MainTimer.try_game_sign_for_task( - source=sign_source + self.task_info.community_results = ( + await MainTimer.try_community_for_task(source=sign_source) ) await self.prepare() diff --git a/app/core/timer.py b/app/core/timer.py index e21519693..500ae6033 100644 --- a/app/core/timer.py +++ b/app/core/timer.py @@ -21,59 +21,39 @@ import asyncio from datetime import datetime -from typing import Literal from app.utils.platform import IS_WINDOWS from app.services import Matomo from app.utils.constants import UTC8 from app.utils import get_logger from .config import Config +from .community_scheduler import ( + TASK_COMMUNITY_SOURCES, + CommunityTriggerSource, + all_community_accounts_signed, + should_run_community_for_source, +) from .task_manager import TaskManager logger = get_logger("主业务定时器") -GameSignSource = Literal[ - "scheduled", - "startup", - "task_scheduled", - "task_manual", - "task_startup", -] -_TASK_GAME_SIGN_SOURCES = { - "task_scheduled", - "task_manual", - "task_startup", -} - - -def _has_pending_game_sign_account(account, today: str) -> bool: - """判断账号是否启用、配置了凭据且尚未完成今日签到。""" - - if not account.get("GameSignAccount", "Enabled"): - return False - from app.tools.game_sign import has_game_sign_credentials - - if not has_game_sign_credentials(account): - return False - return account.get("GameSignAccount", "LastSignDate") != today - - -def _all_game_sign_accounts_signed(accounts, today: str) -> bool: - """判断所有具备凭据的启用账号是否已完成今日签到。""" - - return not any( - _has_pending_game_sign_account(account, today) - for _, account in accounts.items() - ) - - class _MainTimer: def __init__(self): self.started = False self.second_timer: asyncio.Task[None] | None = None self.hour_timer: asyncio.Task[None] | None = None - self.game_sign_task: asyncio.Task | None = None + self.community_sign_task: asyncio.Task | None = None + + @property + def game_sign_task(self) -> asyncio.Task | None: + """兼容旧调用方,返回社区签到后台任务。""" + + return self.community_sign_task + + @game_sign_task.setter + def game_sign_task(self, task: asyncio.Task | None) -> None: + self.community_sign_task = task async def start(self): """启动定时器""" @@ -89,7 +69,7 @@ async def start(self): if Config.ToolsConfig.get("GameSign", "Enabled") and ( Config.ToolsConfig.get("GameSign", "RunOnStartup") ): - self.schedule_game_sign_for_startup() + self.schedule_community_for_startup() logger.info("主业务定时器启动") @@ -104,7 +84,7 @@ async def stop(self): for task in ( self.second_timer, self.hour_timer, - self.game_sign_task, + self.community_sign_task, ) if task is not None and not task.done() ] @@ -191,8 +171,8 @@ async def timed_start(self): ) await queue.set("Data", "LastTimedStart", curtime) - def schedule_game_sign_for_startup(self) -> None: - """Schedule one background sign-in after application startup.""" + def schedule_community_for_startup(self) -> None: + """Schedule one background community sign-in after application startup.""" if not ( Config.ToolsConfig.get("GameSign", "Enabled") @@ -200,19 +180,27 @@ def schedule_game_sign_for_startup(self) -> None: ): return - if self.game_sign_task is not None and not self.game_sign_task.done(): + if ( + self.community_sign_task is not None + and not self.community_sign_task.done() + ): logger.debug("游戏社区签到后台任务正在执行,跳过重复派发") return - task = asyncio.create_task(self.try_game_sign_for_task(source="startup")) - self.game_sign_task = task - task.add_done_callback(self._on_game_sign_check_done) + task = asyncio.create_task(self.try_community_for_task(source="startup")) + self.community_sign_task = task + task.add_done_callback(self._on_community_sign_done) - def _on_game_sign_check_done(self, task: asyncio.Task) -> None: - """清理签到任务并记录未处理异常。""" + def schedule_game_sign_for_startup(self) -> None: + """兼容旧调用方,转发到社区签到启动入口。""" - if self.game_sign_task is task: - self.game_sign_task = None + self.schedule_community_for_startup() + + def _on_community_sign_done(self, task: asyncio.Task) -> None: + """清理社区签到任务并记录未处理异常。""" + + if self.community_sign_task is task: + self.community_sign_task = None if task.cancelled(): return @@ -221,40 +209,45 @@ def _on_game_sign_check_done(self, task: asyncio.Task) -> None: except Exception as e: logger.error("游戏社区签到后台任务异常", exc_info=e) - async def _execute_game_sign( - self, *, source: GameSignSource = "scheduled" + def _on_game_sign_check_done(self, task: asyncio.Task) -> None: + """兼容旧调用方,转发到社区签到完成回调。""" + + self._on_community_sign_done(task) + + async def _execute_community_sign( + self, *, source: CommunityTriggerSource = "scheduled" ) -> list[dict]: - """执行游戏签到并按触发来源决定通知方式。""" - from app.tools.game_sign import ( - GameSignInProgressError, - format_sign_results, - game_sign_flow, - run_all_sign_in, + """执行游戏社区签到并按触发来源决定通知方式。""" + from app.core.community_sign import ( + CommunitySignInProgressError, + community_sign_flow, + run_community_sign_in, ) + from app.tools.community import format_community_sign_results today = datetime.now(tz=UTC8).strftime("%Y-%m-%d") try: # 流程锁覆盖签到和结果落盘,通知在锁外发送。 - async with game_sign_flow(): + async with community_sign_flow(): logger.info("开始执行游戏社区签到") - results = await run_all_sign_in(force=False) + results = await run_community_sign_in(force=False) # 如果所有用户都已签到(无新结果),保留已有结果 if not results: logger.info("所有用户今日已签到,跳过") - if _all_game_sign_accounts_signed( + if all_community_accounts_signed( Config.ToolsConfig.GameSign_Accounts, today ): await Config.ToolsConfig.set("GameSign", "LastSignDate", today) return [] # 格式化并合并结果 - formatted = format_sign_results(results) - await Config.update_game_sign_results(formatted) + formatted = format_community_sign_results(results) + await Config.update_community_results(formatted) # 检查是否所有用户都已签到,更新全局 LastSignDate - if _all_game_sign_accounts_signed( + if all_community_accounts_signed( Config.ToolsConfig.GameSign_Accounts, today ): await Config.ToolsConfig.set("GameSign", "LastSignDate", today) @@ -262,23 +255,24 @@ async def _execute_game_sign( logger.success("游戏社区签到执行完成") # 任务触发的结果由任务完成通知消费;其它自动来源单独发送。 - if source not in _TASK_GAME_SIGN_SOURCES and Config.ToolsConfig.get( - "GameSign", "NotifyEnabled" + if ( + source not in TASK_COMMUNITY_SOURCES + and Config.ToolsConfig.get("GameSign", "NotifyEnabled") ): - from app.tools.game_sign_notify import push_game_sign_notification + from app.tools.community_notify import push_community_notification try: - failed_channels = await push_game_sign_notification(results) + failed_channels = await push_community_notification(results) except Exception as exc: - logger.warning(f"游戏签到完成,但通知服务异常: {exc}") + logger.warning(f"游戏社区签到完成,但通知服务异常: {exc}") else: if failed_channels: logger.warning( - f"游戏签到结果通知部分失败: {'、'.join(failed_channels)}" + f"游戏社区结果通知部分失败: {'、'.join(failed_channels)}" ) return results - except GameSignInProgressError: + except CommunitySignInProgressError: logger.info("游戏社区签到正在执行,跳过本次触发") except Exception as e: logger.error(f"游戏社区签到执行失败: {e}") @@ -286,8 +280,8 @@ async def _execute_game_sign( logger.exception("游戏社区签到执行异常堆栈") return [] - async def try_game_sign_for_task( - self, *, source: GameSignSource | None = None + async def try_community_for_task( + self, *, source: CommunityTriggerSource | None = None ) -> list[dict]: """执行 MAS 自动签到并返回结果。 @@ -296,22 +290,29 @@ async def try_game_sign_for_task( if source is None: source = "task_manual" - if not Config.ToolsConfig.get("GameSign", "Enabled"): - return [] - - if source == "startup": - if not Config.ToolsConfig.get("GameSign", "RunOnStartup"): - return [] - elif source not in _TASK_GAME_SIGN_SOURCES: + if not should_run_community_for_source( + enabled=Config.ToolsConfig.get("GameSign", "Enabled"), + run_on_startup=Config.ToolsConfig.get("GameSign", "RunOnStartup"), + source=source, + ): return [] today = datetime.now(tz=UTC8).strftime("%Y-%m-%d") # 快速检查:是否没有待处理账号 - if _all_game_sign_accounts_signed(Config.ToolsConfig.GameSign_Accounts, today): + if all_community_accounts_signed( + Config.ToolsConfig.GameSign_Accounts, today + ): return [] - return await self._execute_game_sign(source=source) + return await self._execute_community_sign(source=source) + + async def try_game_sign_for_task( + self, *, source: CommunityTriggerSource | None = None + ) -> list[dict]: + """兼容旧调用方,转发到社区签到任务入口。""" + + return await self.try_community_for_task(source=source) MainTimer = _MainTimer() diff --git a/app/models/config.py b/app/models/config.py index 1004345b8..bcdf3591a 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -3473,7 +3473,7 @@ def __init__(self) -> None: class GameSignAccountGroup(ConfigBase): - """游戏签到账号组配置""" + """游戏社区账号组配置""" def __init__(self) -> None: @@ -3635,14 +3635,14 @@ 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""" + """游戏社区结果 JSON""" return json.dumps(self._game_sign_result_data, ensure_ascii=False) diff --git a/app/models/schema.py b/app/models/schema.py index 2cb293f31..c1c4e9280 100644 --- a/app/models/schema.py +++ b/app/models/schema.py @@ -153,7 +153,7 @@ class ToolsConfig_ArknightsPC(BaseModel): class ToolsConfig_GameSign(BaseModel): - Enabled: bool | None = Field(default=None, description="是否启用游戏签到") + 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") @@ -167,7 +167,7 @@ class ToolsConfig_GameSign(BaseModel): class GameSignAccountGroupConfig(BaseModel): - """游戏签到账号组配置""" + """游戏社区账号组配置""" Name: str | None = Field(default=None, description="账号组名称") Enabled: bool | None = Field(default=None, description="是否启用") @@ -178,7 +178,7 @@ class GameSignAccountGroupConfig(BaseModel): class GameSignAccountCreateOut(OutBase): - """游戏签到账号组创建响应""" + """游戏社区账号组创建响应""" accountId: str = Field(default="", description="账号组 UUID") data: GameSignAccountGroupConfig = Field( @@ -187,36 +187,98 @@ class GameSignAccountCreateOut(OutBase): 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 CommunityActivityQueryIn(BaseModel): + """游戏社区日常查询请求。""" + + accountIds: list[str] | None = Field( + default=None, + description="指定账号组 UUID 列表;为空时查询全部已配置账号组", + ) + + +class CommunityActivityTaskOut(BaseModel): + """日常活动中的单项任务。""" + + name: str = Field(..., description="任务名称") + completed: int = Field(..., description="已完成数量") + target: int = Field(..., description="目标数量") + status: str = Field(..., description="任务状态") + period: str = Field(default="daily", description="任务周期") + + +class CommunityActivityResourceOut(BaseModel): + """日常活动中的可用资源。""" + + name: str = Field(..., description="资源名称") + current: int = Field(..., description="当前数量") + target: int = Field(..., description="容量上限") + status: str = Field(..., description="资源状态") + + +class CommunityActivitySnapshotOut(BaseModel): + """单个游戏角色的日常活动快照。""" + + account: str = Field(..., description="账号组名称") + accountUid: str = Field(..., description="账号组 UUID") + game: str = Field(..., description="游戏名称") + platform: str = Field(..., description="社区平台名称") + status: Literal[ + "success", "empty", "limited", "unavailable", "failed" + ] = Field(..., description="活动查询状态") + completed: int | None = Field(default=None, description="已完成数量") + target: int | None = Field(default=None, description="目标数量") + tasks: list[CommunityActivityTaskOut] = Field( + default_factory=list, description="每日任务" + ) + resources: list[CommunityActivityResourceOut] = Field( + default_factory=list, description="可用资源" + ) + reason: str = Field(default="", description="失败或受限原因") + updatedAt: str = Field(default="", description="查询时间") + roleName: str = Field(default="", description="角色名称") + roleUid: str = Field(default="", description="角色 UID") + server: str = Field(default="", description="角色区服") + source: str = Field(default="", description="已确认的数据来源路径") + + +class CommunityActivityOut(OutBase): + """游戏社区日常活动查询响应。""" + + data: list[CommunityActivitySnapshotOut] = Field( + default_factory=list, description="按账号和游戏拆分的活动快照" + ) + + class TaygedoLoginIn(BaseModel): """塔吉多一次性账号密码登录请求。""" diff --git a/app/models/task.py b/app/models/task.py index 87b013833..02fe144ee 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -135,6 +135,26 @@ class TaskItem(ABC): ) _change_dirty: bool = field(default=False, init=False, repr=False, compare=False) + @property + def community_results(self) -> list[dict]: + """读取社区签到结果,底层继续使用历史任务字段。""" + + return self.game_sign_results + + @community_results.setter + def community_results(self, value: list[dict]) -> None: + self.game_sign_results = value + + @property + def community_summary_consumed(self) -> bool: + """读取社区汇总消费状态,保持旧任务协议字段不变。""" + + return self.game_sign_summary_consumed + + @community_summary_consumed.setter + def community_summary_consumed(self, value: bool) -> None: + self.game_sign_summary_consumed = value + def __setattr__(self, name, value): super().__setattr__(name, value) diff --git a/app/task/HSR/manager.py b/app/task/HSR/manager.py index d44e96503..cbc191185 100644 --- a/app/task/HSR/manager.py +++ b/app/task/HSR/manager.py @@ -36,9 +36,9 @@ from app.services import Notify from app.utils import get_logger from app.utils.constants import TASK_MODE_ZH, UTC4, UTC8 -from app.tools.game_sign_notify import ( - append_task_game_sign_summary, - mark_task_game_sign_summary_consumed, +from app.tools.community_notify import ( + append_task_community_summary, + mark_task_community_summary_consumed, ) from .AutoProxy import HSRAutoProxyTask from .tools.run_model import CompletionWriteback, HSRRuntimeState @@ -834,10 +834,10 @@ async def _push_result_notification(self) -> None: f"{datetime.now().strftime('%m-%d')} | " f"{self.script_info.name or '空白'}的{task_mode}任务报告" ) - task_result = append_task_game_sign_summary( + task_result = append_task_community_summary( self.task_info, self.script_info.result ) - has_game_sign_summary = task_result != self.script_info.result + has_community_summary = task_result != self.script_info.result result = { "title": f"{task_mode}任务报告", "script_name": self.script_info.name or "空白", @@ -846,7 +846,7 @@ async def _push_result_notification(self) -> None: "completed_count": len(over_user), "uncompleted_count": uncompleted_count, "result": task_result, - "game_sign_summary": has_game_sign_summary, + "game_sign_summary": has_community_summary, } try: @@ -862,8 +862,8 @@ async def _push_result_notification(self) -> None: try: await push_notification("代理结果", title, result, None) - if has_game_sign_summary: - mark_task_game_sign_summary_consumed(self.task_info) + if has_community_summary: + mark_task_community_summary_consumed(self.task_info) except Exception as e: # noqa: BLE001 logger.opt(exception=True).warning(f"推送 HSR 代理结果时出现异常: {e}") await self._send_notification_error(f"推送 HSR 代理结果时出现异常: {e}") diff --git a/app/task/M9A/manager.py b/app/task/M9A/manager.py index a6985093b..820789dda 100644 --- a/app/task/M9A/manager.py +++ b/app/task/M9A/manager.py @@ -36,9 +36,9 @@ from app.utils import get_logger from app.utils.io import read_file, write_file from app.utils.constants import TASK_MODE_ZH -from app.tools.game_sign_notify import ( - append_task_game_sign_summary, - mark_task_game_sign_summary_consumed, +from app.tools.community_notify import ( + append_task_community_summary, + mark_task_community_summary_consumed, ) from .tools import push_notification, push_version_update from .AutoProxy import AutoProxyTask @@ -332,10 +332,10 @@ async def final_task(self): ] title = f"{datetime.now().strftime('%m-%d')} | {self.script_info.name or '空白'}的{TASK_MODE_ZH[self.task_info.mode]}任务报告" - task_result = append_task_game_sign_summary( + task_result = append_task_community_summary( self.task_info, self.script_info.result ) - has_game_sign_summary = task_result != self.script_info.result + has_community_summary = task_result != self.script_info.result result = { "title": f"{TASK_MODE_ZH[self.task_info.mode]}任务报告", "script_name": self.script_info.name or "空白", @@ -344,7 +344,7 @@ async def final_task(self): "completed_count": len(over_user), "uncompleted_count": len(error_user) + len(wait_user), "result": task_result, - "game_sign_summary": has_game_sign_summary, + "game_sign_summary": has_community_summary, } await Notify.push_plyer( @@ -355,8 +355,8 @@ async def final_task(self): ) try: await push_notification("代理结果", title, result, None) - if has_game_sign_summary: - mark_task_game_sign_summary_consumed(self.task_info) + if has_community_summary: + mark_task_community_summary_consumed(self.task_info) except Exception as e: logger.opt(exception=True).warning(f"推送代理结果时出现异常: {e}") await Publisher.send( diff --git a/app/task/MAA/manager.py b/app/task/MAA/manager.py index 7ebf6b1af..c0cbc7e83 100644 --- a/app/task/MAA/manager.py +++ b/app/task/MAA/manager.py @@ -34,9 +34,9 @@ from app.services import Notify from app.utils import get_logger from app.utils.constants import TASK_MODE_ZH -from app.tools.game_sign_notify import ( - append_task_game_sign_summary, - mark_task_game_sign_summary_consumed, +from app.tools.community_notify import ( + append_task_community_summary, + mark_task_community_summary_consumed, ) from .tools import push_notification from .AutoProxy import AutoProxyTask @@ -219,10 +219,10 @@ async def final_task(self): ] title = f"{datetime.now().strftime('%m-%d')} | {self.script_info.name or '空白'}的{TASK_MODE_ZH[self.task_info.mode]}任务报告" - task_result = append_task_game_sign_summary( + task_result = append_task_community_summary( self.task_info, self.script_info.result ) - has_game_sign_summary = task_result != self.script_info.result + has_community_summary = task_result != self.script_info.result result = { "title": f"{TASK_MODE_ZH[self.task_info.mode]}任务报告", "script_name": self.script_info.name or "空白", @@ -231,7 +231,7 @@ async def final_task(self): "completed_count": len(over_user), "uncompleted_count": len(error_user) + len(wait_user), "result": task_result, - "game_sign_summary": has_game_sign_summary, + "game_sign_summary": has_community_summary, } await Notify.push_plyer( @@ -242,8 +242,8 @@ async def final_task(self): ) try: await push_notification("代理结果", title, result, None) - if has_game_sign_summary: - mark_task_game_sign_summary_consumed(self.task_info) + if has_community_summary: + mark_task_community_summary_consumed(self.task_info) except Exception as e: logger.opt(exception=True).warning(f"推送代理结果时出现异常: {e}") await Publisher.send( diff --git a/app/task/MaaEnd/manager.py b/app/task/MaaEnd/manager.py index 06861ecc6..c44e6969b 100644 --- a/app/task/MaaEnd/manager.py +++ b/app/task/MaaEnd/manager.py @@ -33,9 +33,9 @@ from app.services import Notify from app.utils import get_logger from app.utils.constants import TASK_MODE_ZH -from app.tools.game_sign_notify import ( - append_task_game_sign_summary, - mark_task_game_sign_summary_consumed, +from app.tools.community_notify import ( + append_task_community_summary, + mark_task_community_summary_consumed, ) from .tools import push_notification from .AutoProxy import AutoProxyTask @@ -213,10 +213,10 @@ async def final_task(self): ] title = f"{datetime.now().strftime('%m-%d')} | {self.script_info.name or '空白'}的{TASK_MODE_ZH[self.task_info.mode]}任务报告" - task_result = append_task_game_sign_summary( + task_result = append_task_community_summary( self.task_info, self.script_info.result ) - has_game_sign_summary = task_result != self.script_info.result + has_community_summary = task_result != self.script_info.result result = { "title": f"{TASK_MODE_ZH[self.task_info.mode]}任务报告", "script_name": self.script_info.name or "空白", @@ -225,7 +225,7 @@ async def final_task(self): "completed_count": len(over_user), "uncompleted_count": len(error_user) + len(wait_user), "result": task_result, - "game_sign_summary": has_game_sign_summary, + "game_sign_summary": has_community_summary, } await Notify.push_plyer( @@ -236,8 +236,8 @@ async def final_task(self): ) try: await push_notification("代理结果", title, result, None) - if has_game_sign_summary: - mark_task_game_sign_summary_consumed(self.task_info) + if has_community_summary: + mark_task_community_summary_consumed(self.task_info) except Exception as e: logger.opt(exception=True).warning(f"推送代理结果时出现异常: {e}") await Publisher.send( diff --git a/app/task/OkNte/manager.py b/app/task/OkNte/manager.py index 53e9356a0..327b62d19 100644 --- a/app/task/OkNte/manager.py +++ b/app/task/OkNte/manager.py @@ -32,9 +32,9 @@ from app.services import Notify from app.utils import get_logger, ProcessManager from app.utils.constants import TASK_MODE_ZH -from app.tools.game_sign_notify import ( - append_task_game_sign_summary, - mark_task_game_sign_summary_consumed, +from app.tools.community_notify import ( + append_task_community_summary, + mark_task_community_summary_consumed, ) from app.tools.push_log import build_user_result_text @@ -306,10 +306,10 @@ async def final_task(self): user_result_text = build_user_result_text( self.script_info.user_list, has_uncompleted ) - task_result = append_task_game_sign_summary( + task_result = append_task_community_summary( self.task_info, user_result_text ) - has_game_sign_summary = task_result != user_result_text + has_community_summary = task_result != user_result_text result = { "title": f"{TASK_MODE_ZH[self.task_info.mode]}任务报告", "script_name": self.script_info.name or "空白", @@ -318,7 +318,7 @@ async def final_task(self): "completed_count": len(over_user), "uncompleted_count": len(error_user) + len(wait_user), "result": task_result, - "game_sign_summary": has_game_sign_summary, + "game_sign_summary": has_community_summary, "push_log": "", # 节点已并入 result,不再单独推送 } @@ -330,8 +330,8 @@ async def final_task(self): ) try: await push_notification("代理结果", title, result, None) - if has_game_sign_summary: - mark_task_game_sign_summary_consumed(self.task_info) + if has_community_summary: + mark_task_community_summary_consumed(self.task_info) except Exception as e: logger.opt(exception=True).warning(f"推送代理结果时出现异常: {e}") await Publisher.send( diff --git a/app/task/Okww/manager.py b/app/task/Okww/manager.py index 51bcbafdf..3d3b88e06 100644 --- a/app/task/Okww/manager.py +++ b/app/task/Okww/manager.py @@ -30,9 +30,9 @@ from app.models.config import OkwwConfig, OkwwUserConfig from app.models.ConfigBase import MultipleConfig from app.services import Notify -from app.tools.game_sign_notify import ( - append_task_game_sign_summary, - mark_task_game_sign_summary_consumed, +from app.tools.community_notify import ( + append_task_community_summary, + mark_task_community_summary_consumed, ) from app.utils import get_logger, ProcessManager from app.utils.constants import TASK_MODE_ZH @@ -373,10 +373,10 @@ async def final_task(self): user_result_text = build_user_result_text( self.script_info.user_list, has_uncompleted ) - task_result = append_task_game_sign_summary( + task_result = append_task_community_summary( self.task_info, user_result_text ) - has_game_sign_summary = task_result != user_result_text + has_community_summary = task_result != user_result_text result = { "title": f"{task_mode}任务报告", "script_name": self.script_info.name or "空白", @@ -385,7 +385,7 @@ async def final_task(self): "completed_count": len(over_user), "uncompleted_count": len(error_user) + len(wait_user), "result": task_result, - "game_sign_summary": has_game_sign_summary, + "game_sign_summary": has_community_summary, "push_log": "", # 节点已并入 result,不再单独推送 } @@ -403,8 +403,8 @@ async def final_task(self): ) try: await push_notification("代理结果", title, result) - if has_game_sign_summary: - mark_task_game_sign_summary_consumed(self.task_info) + if has_community_summary: + mark_task_community_summary_consumed(self.task_info) except Exception as e: logger.opt(exception=True).warning(f"推送代理结果时出现异常: {e}") await Publisher.send( diff --git a/app/task/SRC/manager.py b/app/task/SRC/manager.py index 755ae128f..4685639d1 100644 --- a/app/task/SRC/manager.py +++ b/app/task/SRC/manager.py @@ -36,9 +36,9 @@ from app.services import Notify from app.utils import get_logger, ProcessManager from app.utils.constants import TASK_MODE_ZH -from app.tools.game_sign_notify import ( - append_task_game_sign_summary, - mark_task_game_sign_summary_consumed, +from app.tools.community_notify import ( + append_task_community_summary, + mark_task_community_summary_consumed, ) from .tools import ( has_committed_src_user_config_transaction, @@ -860,10 +860,10 @@ async def _send_final_notification(self) -> None: wait_user = [u.name for u in self.script_info.user_list if u.status == "等待"] title = f"{datetime.now().strftime('%m-%d')} | {self.script_info.name or '空白'}的{TASK_MODE_ZH[self.task_info.mode]}任务报告" - task_result = append_task_game_sign_summary( + task_result = append_task_community_summary( self.task_info, self.script_info.result ) - has_game_sign_summary = task_result != self.script_info.result + has_community_summary = task_result != self.script_info.result result = { "title": f"{TASK_MODE_ZH[self.task_info.mode]}任务报告", "script_name": self.script_info.name or "空白", @@ -872,7 +872,7 @@ async def _send_final_notification(self) -> None: "completed_count": len(over_user), "uncompleted_count": len(error_user) + len(wait_user), "result": task_result, - "game_sign_summary": has_game_sign_summary, + "game_sign_summary": has_community_summary, } completion_title = ( @@ -898,8 +898,8 @@ async def _send_final_notification(self) -> None: push_notification("代理结果", title, result, None), timeout=_NOTIFICATION_TIMEOUT_SECONDS, ) - if has_game_sign_summary: - mark_task_game_sign_summary_consumed(self.task_info) + if has_community_summary: + mark_task_community_summary_consumed(self.task_info) except Exception as e: await self._report_notification_error("推送代理结果", e) diff --git a/app/task/general/manager.py b/app/task/general/manager.py index a5811c078..ff07b2c87 100644 --- a/app/task/general/manager.py +++ b/app/task/general/manager.py @@ -34,9 +34,9 @@ from app.services import Notify from app.utils import get_logger, ProcessManager from app.utils.constants import TASK_MODE_ZH -from app.tools.game_sign_notify import ( - append_task_game_sign_summary, - mark_task_game_sign_summary_consumed, +from app.tools.community_notify import ( + append_task_community_summary, + mark_task_community_summary_consumed, ) from app.tools.push_log import build_user_result_text from .tools import push_notification @@ -331,10 +331,10 @@ async def final_task(self): user_result_text = build_user_result_text( self.script_info.user_list, has_uncompleted ) - task_result = append_task_game_sign_summary( + task_result = append_task_community_summary( self.task_info, user_result_text ) - has_game_sign_summary = task_result != user_result_text + has_community_summary = task_result != user_result_text result = { "title": f"{TASK_MODE_ZH[self.task_info.mode]}任务报告", "script_name": self.script_info.name or "空白", @@ -343,7 +343,7 @@ async def final_task(self): "completed_count": len(over_user), "uncompleted_count": len(error_user) + len(wait_user), "result": task_result, - "game_sign_summary": has_game_sign_summary, + "game_sign_summary": has_community_summary, "push_log": "", # 进程信息已并入 result,不再单独推送 } @@ -355,8 +355,8 @@ async def final_task(self): ) try: await push_notification("代理结果", title, result, None) - if has_game_sign_summary: - mark_task_game_sign_summary_consumed(self.task_info) + if has_community_summary: + mark_task_community_summary_consumed(self.task_info) except Exception as e: logger.opt(exception=True).warning(f"推送代理结果时出现异常: {e}") await Publisher.send( diff --git a/app/tools/__init__.py b/app/tools/__init__.py index e04f43453..b84ad8692 100644 --- a/app/tools/__init__.py +++ b/app/tools/__init__.py @@ -21,9 +21,17 @@ from .skland import login_skland_with_password, skland_sign_in from .game_sign import run_all_sign_in, format_sign_results +from .community import ( + format_community_sign_results, + has_community_credentials, + run_community_sign_in, +) __all__ = [ + "format_community_sign_results", + "has_community_credentials", "login_skland_with_password", + "run_community_sign_in", "skland_sign_in", "run_all_sign_in", "format_sign_results", diff --git a/app/tools/community.py b/app/tools/community.py new file mode 100644 index 000000000..6533d5b19 --- /dev/null +++ b/app/tools/community.py @@ -0,0 +1,99 @@ +# 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 contextlib import asynccontextmanager + +from .community_plugins import ( + COMMUNITY_PLUGIN_REFERENCES, + CommunityCapability, + CommunityPluginReference, + get_community_plugin_references, + get_confirmed_community_capabilities, + supports_community_capability, +) +from .game_sign import ( + format_community_sign_results, + has_community_credentials, +) +from .game_sign_contract import CommunitySignInProgressError +from .game_sign_activity import parse_activity_snapshot +from .community_activity_provider import ( + CommunityActivityProvider, + build_community_activity_requester, +) +from .community_activity_transport import ( + CommunityActivityRequest, + CommunityActivityTarget, + CommunityActivityTransportError, + build_community_activity_requests, + collect_community_activity, +) + +__all__ = [ + "COMMUNITY_PLUGIN_REFERENCES", + "CommunityCapability", + "CommunityPluginReference", + "CommunitySignInProgressError", + "CommunityToolInProgressError", + "CommunityActivityRequest", + "CommunityActivityTarget", + "CommunityActivityTransportError", + "CommunityActivityProvider", + "GameSignInProgressError", + "build_community_activity_requester", + "build_community_activity_requests", + "collect_community_activity", + "community_sign_flow", + "format_community_sign_results", + "get_community_plugin_references", + "get_confirmed_community_capabilities", + "has_community_credentials", + "parse_community_activity_snapshot", + "run_community_sign_in", + "supports_community_capability", +] + + +# 旧异常名称仅供历史调用方识别同一个并发冲突类型。 +CommunityToolInProgressError = CommunitySignInProgressError +GameSignInProgressError = CommunitySignInProgressError +parse_community_activity_snapshot = parse_activity_snapshot + + +@asynccontextmanager +async def community_sign_flow(): + """延迟进入核心社区签到流程,避免工具包初始化形成循环导入。""" + + from app.core.community_sign import community_sign_flow as core_flow + + async with core_flow(): + yield + + +async def run_community_sign_in(force: bool = False) -> list[dict]: + """延迟调用核心社区签到编排。""" + + from app.core.community_sign import run_community_sign_in as core_run + + return await core_run(force=force) diff --git a/app/tools/community_activity_provider.py b/app/tools/community_activity_provider.py new file mode 100644 index 000000000..18e8316c2 --- /dev/null +++ b/app/tools/community_activity_provider.py @@ -0,0 +1,899 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025-2026 AUTO-MAS Team + +# This file incorporates request knowledge from the following acknowledged +# community projects: +# +# nonebot-plugin-mystool Copyright © 2023-2025 Ljzd-PRO +# https://github.com/Ljzd-PRO/nonebot-plugin-mystool +# +# gxy12345/arknights-plugin +# https://github.com/gxy12345/arknights-plugin + +# 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 + + +"""森空岛和米游社的只读活动请求适配器。 + +该模块只负责把凭据暂存于本次查询的内存、完成 provider 请求和返回 JSON。 +活动解析仍由 ``game_sign_activity`` 负责,签到编排和配置保存不在本模块内。 +""" + +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import json +import random +import string +import time +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import urlparse + +import httpx + +from .community_activity_transport import ( + CommunityActivityRequest, + CommunityActivityTarget, + CommunityActivityTransportError, +) +from .community_activity_roles import ( + CommunityActivityRoleDiscovery, + normalize_miyoushe_roles, + normalize_skland_roles, +) + +__all__ = [ + "CommunityActivityProvider", + "build_community_activity_requester", +] + + +CredentialUpdate = Callable[[str], Awaitable[None]] + +_ACTIVITY_REQUEST_TIMEOUT = 12.0 +_MIYOUSHE_RECORD_SALT = "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs" +_MIYOUSHE_WIDGET_SALT = "9ttJY72HxbjwWRNHJvn0n2AYue47nYsK" +_MIYOUSHE_DEVICE_FP_URL = ( + "https://public-data-api.mihoyo.com/device-fp/api/getFp" +) +_MIYOUSHE_DEVICE_LOGIN_URL = ( + "https://bbs-api.mihoyo.com/apihub/api/deviceLogin" +) +_MIYOUSHE_DEVICE_SAVE_URL = "https://bbs-api.mihoyo.com/apihub/api/saveDevice" +_MIYOUSHE_DEVICE_MODEL = "MI 8 SE" +_MIYOUSHE_DEVICE_NAME = "Xiaomi MI 8 SE" +_MIYOUSHE_DEVICE_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" +) +_MIYOUSHE_RISK_CODES = frozenset({1034, 5003, 10035, 10041}) + + +def _response_code(payload: Mapping[str, Any]) -> int | None: + for key in ("retcode", "code", "status"): + value = payload.get(key) + if isinstance(value, bool) or value is None: + continue + try: + return int(value) + except (TypeError, ValueError): + continue + return None + + +def _response_json( + response: httpx.Response, + *, + platform: str, + game: str, +) -> Mapping[str, Any]: + """读取安全的 JSON 响应,不把 HTML 或请求正文带到错误文案。""" + + if response.status_code == 404: + raise CommunityActivityTransportError( + f"{platform}{game}角色数据不存在(HTTP 404)", + status="unavailable", + ) + if response.status_code in (401, 403, 429): + raise CommunityActivityTransportError( + f"{platform}{game}接口受到上游限制(HTTP {response.status_code})", + status="limited", + ) + if response.status_code >= 500: + raise CommunityActivityTransportError( + f"{platform}{game}接口暂时不可用(HTTP {response.status_code})", + status="unavailable", + ) + try: + payload = response.json() + except ValueError as exc: + raise CommunityActivityTransportError( + f"{platform}{game}接口返回非 JSON,可能触发风控", + status="limited", + ) from exc + if not isinstance(payload, Mapping): + raise CommunityActivityTransportError( + f"{platform}{game}接口返回的数据结构无法识别", + status="unavailable", + ) + return payload + + +def _raise_business_error( + payload: Mapping[str, Any], + *, + platform: str, + game: str, +) -> None: + code = _response_code(payload) + if code is None or code == 0: + return + limited = abs(code) in _MIYOUSHE_RISK_CODES or code in { + -100, + 10000, + 10001, + } + status = "limited" if limited else "unavailable" + raise CommunityActivityTransportError( + f"{platform}{game}接口返回业务失败(业务码 {code})", + status=status, + ) + + +def _skland_signature( + sign_token: str, + *, + path: str, + query: str, + device_id: str, + platform: str, + version: str, +) -> dict[str, str]: + """复用森空岛既有签名公式,但不调用签到模块的流程函数。""" + + timestamp = str(int(time.time() * 1000 - 2000))[:-3] + sign_header = { + "platform": platform, + "timestamp": timestamp, + "dId": device_id, + "vName": version, + } + sign_text = path + query + timestamp + json.dumps( + sign_header, separators=(",", ":") + ) + digest = hashlib.md5( + hmac.new( + sign_token.encode("utf-8"), + sign_text.encode("utf-8"), + hashlib.sha256, + ) + .hexdigest() + .encode("utf-8") + ).hexdigest() + return {**sign_header, "sign": digest} + + +def _miyoushe_ds(*, query: str = "", widget: bool = False) -> str: + timestamp = str(int(time.time())) + nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) + if widget: + raw = f"salt={_MIYOUSHE_WIDGET_SALT}&t={timestamp}&r={nonce}" + else: + raw = ( + f"salt={_MIYOUSHE_RECORD_SALT}&t={timestamp}&r={nonce}" + f"&b=&q={query}" + ) + return f"{timestamp},{nonce},{hashlib.md5(raw.encode()).hexdigest()}" + + +def _device_ds(body: str) -> str: + from .miyoushe import SALT_DATA + + timestamp = str(int(time.time())) + nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) + raw = f"salt={SALT_DATA}&t={timestamp}&r={nonce}&b={body}&q=" + return f"{timestamp},{nonce},{hashlib.md5(raw.encode()).hexdigest()}" + + +def _device_fp_body(device_id: str) -> dict[str, str]: + return { + "seed_id": "".join( + random.choices(string.ascii_lowercase + string.digits, k=16) + ), + "device_id": device_id.lower(), + "platform": "5", + "seed_time": str(int(time.time() * 1000)), + "ext_fields": json.dumps( + { + "userAgent": _MIYOUSHE_DEVICE_USER_AGENT, + "browserScreenSize": 243750, + "maxTouchPoints": 5, + "isTouchSupported": True, + "browserLanguage": "zh-CN", + "browserPlat": "Linux armv8l", + "browserTimeZone": "Asia/Shanghai", + "webGlRender": "Adreno (TM) 640", + "webGlVendor": "Qualcomm", + "numOfPlugins": 0, + "listOfPlugins": "unknown", + "screenRatio": 3, + "deviceMemory": "4", + "hardwareConcurrency": "8", + "cpuClass": "unknown", + "ifNotTrack": "unknown", + "ifAdBlock": 0, + "hasLiedResolution": 1, + "hasLiedOs": 0, + "hasLiedBrowser": 0, + }, + ensure_ascii=False, + ), + "app_name": "account_cn", + # getFp 需要一个本次申请用的种子;官方返回值才会用于后续请求。 + "device_fp": "".join(random.choices("0123456789abcdef", k=13)), + } + + +@dataclass +class CommunityActivityProvider: + """为一个账号组执行只读活动请求。 + + 一个 provider 实例应覆盖一个账号组的全部游戏,以便在同一轮查询内共享 + 森空岛刷新结果、米游社官方设备指纹和请求间隔。凭据默认只保留在内存中; + ``on_credential_update`` 只有由调用方显式提供时才会收到刷新后的森空岛值。 + """ + + platform: str + raw_credential: str = field(repr=False) + proxy: str | httpx.Proxy | None = None + on_credential_update: CredentialUpdate | None = field( + default=None, repr=False + ) + miyoushe_request_interval: float = 1.2 + _credential: dict[str, str] | None = field( + default=None, init=False, repr=False + ) + _device_id: str = field(default="", init=False, repr=False) + _device_fp: str = field(default="", init=False, repr=False) + _credential_ready: bool = field(default=False, init=False, repr=False) + _credential_update_sent: bool = field(default=False, init=False, repr=False) + _miyoushe_cookies: dict[str, str] | None = field( + default=None, init=False, repr=False + ) + _state_lock: asyncio.Lock = field( + default_factory=asyncio.Lock, init=False, repr=False + ) + _miyoushe_rate_lock: asyncio.Lock = field( + default_factory=asyncio.Lock, init=False, repr=False + ) + _last_miyoushe_request: float | None = field( + default=None, init=False, repr=False + ) + + def __post_init__(self) -> None: + self.raw_credential = str(self.raw_credential or "").strip() + + async def request(self, request: CommunityActivityRequest) -> object: + """执行一个已登记的请求规格并返回未解析的 JSON 对象。""" + + if request.target.platform != self.platform: + raise ValueError( + f"活动 provider 与目标平台不匹配:{request.target.platform}" + ) + if self.platform == "森空岛": + return await self._request_skland(request) + if self.platform == "米游社": + return await self._request_miyoushe(request) + raise ValueError(f"{self.platform}活动 provider 尚未登记") + + async def discover_roles( + self, + *, + account_uid: str, + account_name: str, + ) -> CommunityActivityRoleDiscovery: + """在同一 provider 会话内读取账号组的游戏角色。""" + + if self.platform == "森空岛": + return await self._discover_skland_roles( + account_uid=account_uid, + account_name=account_name, + ) + if self.platform == "米游社": + return await self._discover_miyoushe_roles( + account_uid=account_uid, + account_name=account_name, + ) + raise ValueError(f"{self.platform}活动 provider 尚未登记") + + async def _discover_skland_roles( + self, + *, + account_uid: str, + account_name: str, + ) -> CommunityActivityRoleDiscovery: + from .skland import ( + SKLAND_BINDING_URL, + _get_cred_by_code, + _get_grant_code, + get_cached_device_id, + parse_skland_credential, + serialize_skland_credential, + validate_skland_credential, + ) + + target = CommunityActivityTarget( + account_uid=account_uid, + account_name=account_name, + platform="森空岛", + game="角色绑定", + role_uid="", + ) + request = CommunityActivityRequest( + target=target, + source=SKLAND_BINDING_URL, + method="GET", + auth_scope="skland", + signature_profile="skland_widget", + ) + try: + async with httpx.AsyncClient( + proxy=self.proxy, trust_env=False + ) as client: + credential, device_id = await self._prepare_skland( + None, + client, + get_cached_device_id=get_cached_device_id, + parse_credential=parse_skland_credential, + validate_credential=validate_skland_credential, + get_grant_code=_get_grant_code, + get_cred_by_code=_get_cred_by_code, + serialize_credential=serialize_skland_credential, + ) + response = await client.get( + SKLAND_BINDING_URL, + headers=self._skland_request_headers( + request, + credential=credential, + device_id=device_id, + ), + timeout=_ACTIVITY_REQUEST_TIMEOUT, + ) + except httpx.TimeoutException as exc: + raise CommunityActivityTransportError( + "森空岛角色列表请求超时", status="failed" + ) from exc + except httpx.HTTPError as exc: + raise CommunityActivityTransportError( + "森空岛角色列表网络请求失败", status="failed" + ) from exc + + payload = _response_json( + response, + platform="森空岛", + game="角色列表", + ) + _raise_business_error(payload, platform="森空岛", game="角色列表") + return normalize_skland_roles(payload) + + async def _discover_miyoushe_roles( + self, + *, + account_uid: str, + account_name: str, + ) -> CommunityActivityRoleDiscovery: + from .miyoushe import ( + BASE_HEADERS, + ROLES_URL, + _ensure_auth_aliases, + _generate_device_id, + _parse_cookie, + validate_miyoushe_cookie, + ) + + target = CommunityActivityTarget( + account_uid=account_uid, + account_name=account_name, + platform="米游社", + game="角色绑定", + role_uid="", + ) + request = CommunityActivityRequest( + target=target, + source=ROLES_URL, + method="GET", + auth_scope="miyoushe", + signature_profile="miyoushe_params", + ) + try: + async with httpx.AsyncClient( + proxy=self.proxy, trust_env=False + ) as client: + cookies, device_id, _ = await self._prepare_miyoushe( + request, + client, + ensure_auth_aliases=_ensure_auth_aliases, + generate_device_id=_generate_device_id, + parse_cookie=_parse_cookie, + validate_cookie=validate_miyoushe_cookie, + require_stoken_v2=False, + ) + await self._wait_miyoushe_request() + headers = BASE_HEADERS.copy() + headers.update(self._miyoushe_request_headers( + request, + device_id=device_id, + device_fp="", + )) + response = await client.get( + ROLES_URL, + headers=headers, + cookies=cookies, + timeout=_ACTIVITY_REQUEST_TIMEOUT, + ) + except httpx.TimeoutException as exc: + raise CommunityActivityTransportError( + "米游社角色列表请求超时", status="failed" + ) from exc + except httpx.HTTPError as exc: + raise CommunityActivityTransportError( + "米游社角色列表网络请求失败", status="failed" + ) from exc + + payload = _response_json( + response, + platform="米游社", + game="角色列表", + ) + _raise_business_error(payload, platform="米游社", game="角色列表") + return normalize_miyoushe_roles(payload) + + async def _request_skland( + self, request: CommunityActivityRequest + ) -> Mapping[str, Any]: + from .skland import ( + _get_cred_by_code, + _get_grant_code, + get_cached_device_id, + parse_skland_credential, + serialize_skland_credential, + validate_skland_credential, + ) + + try: + async with httpx.AsyncClient( + proxy=self.proxy, trust_env=False + ) as client: + credential, device_id = await self._prepare_skland( + request.target, + client, + get_cached_device_id=get_cached_device_id, + parse_credential=parse_skland_credential, + validate_credential=validate_skland_credential, + get_grant_code=_get_grant_code, + get_cred_by_code=_get_cred_by_code, + serialize_credential=serialize_skland_credential, + ) + headers = self._skland_request_headers( + request, + credential=credential, + device_id=device_id, + ) + response = await client.get( + request.source, + params=request.query, + headers=headers, + timeout=request.timeout, + ) + except httpx.TimeoutException as exc: + raise CommunityActivityTransportError( + f"森空岛{request.target.game}接口请求超时", status="failed" + ) from exc + except httpx.HTTPError as exc: + raise CommunityActivityTransportError( + f"森空岛{request.target.game}接口网络请求失败", status="failed" + ) from exc + + payload = _response_json( + response, + platform="森空岛", + game=request.target.game, + ) + _raise_business_error( + payload, + platform="森空岛", + game=request.target.game, + ) + return payload + + async def _prepare_skland( + self, + target: CommunityActivityTarget | None, + client: httpx.AsyncClient, + *, + get_cached_device_id: Callable[..., Awaitable[str]], + parse_credential: Callable[[str | dict[str, Any]], dict[str, str]], + validate_credential: Callable[ + [str | dict[str, Any]], dict[str, str] + ], + get_grant_code: Callable[..., Awaitable[str]], + get_cred_by_code: Callable[..., Awaitable[dict[str, Any]]], + serialize_credential: Callable[[dict[str, Any]], str], + ) -> tuple[dict[str, str], str]: + async with self._state_lock: + if self._credential is None: + self._credential = validate_credential(self.raw_credential) + if not self._device_id: + cached_device_id = target.device_id if target else "" + self._device_id = cached_device_id or await get_cached_device_id( + self.proxy, client=client + ) + if not self._credential_ready: + credential = self._credential + if not credential["cred"] or not credential["token"]: + if not credential["oauthToken"]: + raise ValueError("森空岛凭据缺少可用认证字段") + grant_code = await get_grant_code( + client, credential["oauthToken"], self._device_id + ) + cred_data = await get_cred_by_code( + client, grant_code, self._device_id + ) + self._credential = parse_credential( + { + "oauthToken": credential["oauthToken"], + "token": cred_data.get("token"), + "cred": cred_data.get("cred"), + "userId": cred_data.get("userId"), + } + ) + else: + try: + self._credential = await self._refresh_skland( + client, + self._credential, + self._device_id, + ) + except ( + CommunityActivityTransportError, + httpx.HTTPError, + OSError, + ValueError, + ): + # 刷新失败不覆盖原凭据;本次查询继续用旧值,让活动接口 + # 自己返回可见的失效或风控状态。 + pass + self._credential_ready = True + if self.on_credential_update is not None: + serialized = serialize_credential(self._credential) + if serialized != self.raw_credential: + await self._publish_credential_update(serialized) + return dict(self._credential), self._device_id + + async def _refresh_skland( + self, + client: httpx.AsyncClient, + credential: dict[str, str], + device_id: str, + ) -> dict[str, str]: + from .skland import SKLAND_REFRESH_URL, parse_skland_credential + + path = "/web/v1/auth/refresh" + headers = _skland_signature( + credential["token"], + path=path, + query="", + device_id=device_id, + platform="1", + version="1.21.0", + ) + headers["cred"] = credential["cred"] + response = await client.get( + SKLAND_REFRESH_URL, + headers=headers, + timeout=_ACTIVITY_REQUEST_TIMEOUT, + ) + payload = _response_json(response, platform="森空岛", game="凭据刷新") + _raise_business_error(payload, platform="森空岛", game="凭据刷新") + data = payload.get("data") + if not isinstance(data, Mapping) or not data.get("token"): + raise ValueError("森空岛凭据刷新响应缺少 token") + return parse_skland_credential( + {**credential, "token": str(data["token"])} + ) + + async def _publish_credential_update(self, serialized: str) -> None: + if self._credential_update_sent or self.on_credential_update is None: + return + self._credential_update_sent = True + try: + await self.on_credential_update(serialized) + except Exception: + # 回写是调用方的可选持久化动作,不能改变只读查询结果。 + return + + @staticmethod + def _skland_request_headers( + request: CommunityActivityRequest, + *, + credential: Mapping[str, str], + device_id: str, + ) -> dict[str, str]: + profile = request.signature_profile + platform = "3" if profile == "skland_endfield_web" else "1" + version = "1.0.0" if profile == "skland_endfield_web" else "1.21.0" + parsed = urlparse(request.source) + signed = _skland_signature( + credential["token"], + path=parsed.path, + query=request.query_string, + device_id=device_id, + platform=platform, + version=version, + ) + headers = request.header_map + headers.update(signed) + headers["cred"] = credential["cred"] + return headers + + async def _request_miyoushe( + self, request: CommunityActivityRequest + ) -> Mapping[str, Any]: + from .miyoushe import ( + _ensure_auth_aliases, + _generate_device_id, + _parse_cookie, + validate_miyoushe_cookie, + ) + + try: + async with httpx.AsyncClient( + proxy=self.proxy, trust_env=False + ) as client: + cookies, device_id, device_fp = await self._prepare_miyoushe( + request, + client, + ensure_auth_aliases=_ensure_auth_aliases, + generate_device_id=_generate_device_id, + parse_cookie=_parse_cookie, + validate_cookie=validate_miyoushe_cookie, + ) + await self._wait_miyoushe_request() + headers = self._miyoushe_request_headers( + request, + device_id=device_id, + device_fp=device_fp, + ) + response = await client.get( + request.source, + params=request.query, + headers=headers, + cookies=cookies, + timeout=request.timeout, + ) + except httpx.TimeoutException as exc: + raise CommunityActivityTransportError( + f"米游社{request.target.game}接口请求超时", status="failed" + ) from exc + except httpx.HTTPError as exc: + raise CommunityActivityTransportError( + f"米游社{request.target.game}接口网络请求失败", status="failed" + ) from exc + + payload = _response_json( + response, + platform="米游社", + game=request.target.game, + ) + _raise_business_error( + payload, + platform="米游社", + game=request.target.game, + ) + return payload + + async def _prepare_miyoushe( + self, + request: CommunityActivityRequest, + client: httpx.AsyncClient, + *, + ensure_auth_aliases: Callable[[dict[str, str]], None], + generate_device_id: Callable[[str], str], + parse_cookie: Callable[[str], dict[str, str]], + validate_cookie: Callable[[str], None], + require_stoken_v2: bool = True, + ) -> tuple[dict[str, str], str, str]: + async with self._state_lock: + if self._miyoushe_cookies is None: + validate_cookie(self.raw_credential) + self._miyoushe_cookies = parse_cookie(self.raw_credential) + ensure_auth_aliases(self._miyoushe_cookies) + cookies = dict(self._miyoushe_cookies) + if not self._device_id: + self._device_id = request.target.device_id or generate_device_id( + self.raw_credential + ) + if request.target.device_fp and not self._device_fp: + self._device_fp = request.target.device_fp + if request.requires_device_fingerprint and not self._device_fp: + self._device_fp = await self._acquire_device_fp( + client, self._device_id + ) + await self._register_device( + client, + device_id=self._device_id, + device_fp=self._device_fp, + cookies=cookies, + ) + if require_stoken_v2 and request.signature_profile in { + "miyoushe_ios", + "miyoushe_data", + } and not cookies.get("stoken_v2"): + raise CommunityActivityTransportError( + "米游社小组件需要 stoken_v2,当前凭据不完整", + status="limited", + ) + return cookies, self._device_id, self._device_fp + + async def _acquire_device_fp( + self, client: httpx.AsyncClient, device_id: str + ) -> str: + response = await client.post( + _MIYOUSHE_DEVICE_FP_URL, + json=_device_fp_body(device_id), + headers={ + "Content-Type": "application/json", + "User-Agent": _MIYOUSHE_DEVICE_USER_AGENT, + }, + timeout=_ACTIVITY_REQUEST_TIMEOUT, + ) + payload = _response_json(response, platform="米游社", game="设备指纹") + _raise_business_error(payload, platform="米游社", game="设备指纹") + data = payload.get("data") + device_fp = data.get("device_fp") if isinstance(data, Mapping) else None + if not isinstance(device_fp, str) or not device_fp.strip(): + raise CommunityActivityTransportError( + "米游社官方设备指纹响应无效,无法确认设备状态", + status="limited", + ) + return device_fp.strip() + + async def _register_device( + self, + client: httpx.AsyncClient, + *, + device_id: str, + device_fp: str, + cookies: Mapping[str, str], + ) -> None: + data = { + "app_version": "2.99.1", + "device_id": device_id, + "device_name": _MIYOUSHE_DEVICE_NAME, + "os_version": "30", + "platform": "Android", + "registration_id": "1a0018970a5c00e814d", + } + body = json.dumps(data, separators=(",", ":")) + headers = { + "DS": _device_ds(body), + "x-rpc-client_type": "2", + "x-rpc-app_version": "2.99.1", + "x-rpc-sys_version": "12", + "x-rpc-channel": "miyousheluodi", + "x-rpc-device_id": device_id, + "x-rpc-device_name": _MIYOUSHE_DEVICE_NAME, + "x-rpc-device_model": _MIYOUSHE_DEVICE_MODEL, + "x-rpc-device_fp": device_fp, + "Referer": "https://app.mihoyo.com", + "Content-Type": "application/json; charset=UTF-8", + "User-Agent": "okhttp/4.9.3", + } + # 登记是降低风控概率的辅助步骤;失败时保留官方 getFp 值继续查询, + # 由真正的实时便笺响应决定最终是成功还是受限。 + for url in (_MIYOUSHE_DEVICE_LOGIN_URL, _MIYOUSHE_DEVICE_SAVE_URL): + try: + response = await client.post( + url, + headers=headers, + content=body, + cookies=cookies, + timeout=_ACTIVITY_REQUEST_TIMEOUT, + ) + payload = _response_json(response, platform="米游社", game="设备登记") + _raise_business_error( + payload, + platform="米游社", + game="设备登记", + ) + except ( + CommunityActivityTransportError, + httpx.HTTPError, + OSError, + ValueError, + ): + continue + + async def _wait_miyoushe_request(self) -> None: + if self.miyoushe_request_interval <= 0: + return + async with self._miyoushe_rate_lock: + now = time.monotonic() + if self._last_miyoushe_request is not None: + delay = self.miyoushe_request_interval - ( + now - self._last_miyoushe_request + ) + if delay > 0: + await asyncio.sleep(delay) + self._last_miyoushe_request = time.monotonic() + + @staticmethod + def _miyoushe_request_headers( + request: CommunityActivityRequest, + *, + device_id: str, + device_fp: str, + ) -> dict[str, str]: + profile = request.signature_profile + headers = request.header_map + if profile == "miyoushe_params": + headers["DS"] = _miyoushe_ds(query=request.query_string) + elif profile in {"miyoushe_ios", "miyoushe_data"}: + headers["DS"] = _miyoushe_ds(widget=True) + else: + raise CommunityActivityTransportError( + "米游社当前请求规格未确认,不发起请求", + status="limited", + ) + headers.setdefault("x-rpc-app_version", "2.99.1") + headers.setdefault("x-rpc-device_model", _MIYOUSHE_DEVICE_MODEL) + headers.setdefault("x-rpc-device_name", _MIYOUSHE_DEVICE_NAME) + headers.setdefault("User-Agent", _MIYOUSHE_DEVICE_USER_AGENT) + headers["x-rpc-device_id"] = device_id + if device_fp: + headers["x-rpc-device_fp"] = device_fp + return headers + + +def build_community_activity_requester( + platform: str, + raw_credential: str, + *, + proxy: str | httpx.Proxy | None = None, + on_credential_update: CredentialUpdate | None = None, + miyoushe_request_interval: float = 1.2, +) -> Callable[[CommunityActivityRequest], Awaitable[object]]: + """构造一个账号级 provider 请求函数,供活动 collector 注入。""" + + if platform not in {"森空岛", "米游社"}: + raise ValueError(f"{platform}活动 provider 尚未登记") + provider = CommunityActivityProvider( + platform=platform, + raw_credential=raw_credential, + proxy=proxy, + on_credential_update=on_credential_update, + miyoushe_request_interval=miyoushe_request_interval, + ) + return provider.request diff --git a/app/tools/community_activity_roles.py b/app/tools/community_activity_roles.py new file mode 100644 index 000000000..8334fe9b1 --- /dev/null +++ b/app/tools/community_activity_roles.py @@ -0,0 +1,278 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025-2026 AUTO-MAS Team + +# This file incorporates request knowledge from the following acknowledged +# community projects: +# +# nonebot-plugin-mystool Copyright © 2023-2025 Ljzd-PRO +# https://github.com/Ljzd-PRO/nonebot-plugin-mystool +# +# gxy12345/arknights-plugin +# https://github.com/gxy12345/arknights-plugin + +# 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 __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Literal + +from .community_activity_transport import CommunityActivityTarget + +__all__ = [ + "ACTIVITY_COMMUNITY_DEFINITIONS", + "CommunityActivityProviderDefinition", + "CommunityActivityRole", + "CommunityActivityRoleDiscovery", + "normalize_miyoushe_roles", + "normalize_skland_roles", +] + + +ActivityCommunity = Literal["森空岛", "米游社"] + + +@dataclass(frozen=True) +class CommunityActivityProviderDefinition: + """活动查询 provider 的目标游戏登记。""" + + platform: ActivityCommunity + games: tuple[str, ...] + + +ACTIVITY_COMMUNITY_DEFINITIONS = ( + CommunityActivityProviderDefinition( + platform="森空岛", + games=("明日方舟", "终末地"), + ), + CommunityActivityProviderDefinition( + platform="米游社", + games=("原神", "星穹铁道", "绝区零"), + ), +) + + +@dataclass(frozen=True) +class CommunityActivityRole: + """不含凭据和设备值的单个游戏角色。""" + + platform: ActivityCommunity + game: str + role_uid: str + server: str = "" + role_name: str = "" + user_id: str = "" + + def to_target( + self, + *, + account_uid: str, + account_name: str, + device_id: str = "", + device_fp: str = "", + ) -> CommunityActivityTarget: + """转换为活动 transport 使用的脱敏目标。""" + + return CommunityActivityTarget( + account_uid=account_uid, + account_name=account_name, + platform=self.platform, + game=self.game, + role_uid=self.role_uid, + server=self.server, + role_name=self.role_name, + user_id=self.user_id, + device_id=device_id, + device_fp=device_fp, + ) + + +@dataclass(frozen=True) +class CommunityActivityRoleDiscovery: + """一次账号组角色发现的脱敏结果。""" + + platform: ActivityCommunity + roles: tuple[CommunityActivityRole, ...] = () + + def roles_for_game(self, game: str) -> tuple[CommunityActivityRole, ...]: + """按游戏筛选角色并保持上游顺序。""" + + return tuple(role for role in self.roles if role.game == game) + + +def _text(item: Mapping[str, Any], *names: str) -> str: + for name in names: + value = item.get(name) + if value is not None and str(value).strip(): + return str(value).strip() + return "" + + +def _binding_entries( + payload: Mapping[str, Any], *, app_code: str +) -> tuple[Mapping[str, Any], ...]: + data = payload.get("data") + if isinstance(data, list): + groups: object = data + elif isinstance(data, Mapping): + groups = data.get("list") + if groups is None and data.get("appCode"): + groups = [data] + elif groups is None and ( + isinstance(data.get("bindingList"), list) + or isinstance(data.get("binding_list"), list) + ): + # 本次请求没有按 appCode 过滤,不能把无标签的同一列表同时 + # 解释为明日方舟和终末地,避免把角色映射到错误游戏。 + raise ValueError("森空岛角色列表缺少 appCode,无法安全识别游戏") + else: + groups = None + + if not isinstance(groups, list): + raise ValueError("森空岛角色列表响应缺少绑定列表") + + entries: list[Mapping[str, Any]] = [] + for group in groups: + if not isinstance(group, Mapping): + continue + group_app_code = _text(group, "appCode", "app_code") + if group_app_code != app_code: + continue + binding_list = group.get("bindingList") + if binding_list is None: + binding_list = group.get("binding_list") + if binding_list is None: + continue + if not isinstance(binding_list, list): + raise ValueError("森空岛角色绑定列表响应格式无效") + entries.extend( + entry for entry in binding_list if isinstance(entry, Mapping) + ) + return tuple(entries) + + +def _deduplicate_roles( + roles: list[CommunityActivityRole], +) -> tuple[CommunityActivityRole, ...]: + seen: set[tuple[str, str, str]] = set() + result: list[CommunityActivityRole] = [] + for role in roles: + key = (role.game, role.role_uid, role.server) + if key in seen: + continue + seen.add(key) + result.append(role) + return tuple(result) + + +def normalize_skland_roles( + payload: Mapping[str, Any], +) -> CommunityActivityRoleDiscovery: + """归一化森空岛明日方舟和终末地绑定响应。""" + + roles: list[CommunityActivityRole] = [] + for entry in _binding_entries(payload, app_code="arknights"): + role_uid = _text(entry, "uid") + if role_uid: + roles.append( + CommunityActivityRole( + platform="森空岛", + game="明日方舟", + role_uid=role_uid, + server=_text(entry, "channelName", "channel_name"), + role_name=_text(entry, "nickName", "nickname", "name"), + ) + ) + + for entry in _binding_entries(payload, app_code="endfield"): + nested_roles = entry.get("roles") + if not isinstance(nested_roles, list): + nested_roles = [entry] if _text(entry, "roleId", "role_id") else [] + entry_user_id = _text(entry, "userId", "user_id") + entry_server = _text(entry, "serverId", "server_id") + for role in nested_roles: + if not isinstance(role, Mapping): + continue + role_uid = _text(role, "roleId", "role_id") + if not role_uid: + continue + roles.append( + CommunityActivityRole( + platform="森空岛", + game="终末地", + role_uid=role_uid, + server=_text(role, "serverId", "server_id") or entry_server, + role_name=_text(role, "nickname", "nickName", "name"), + user_id=_text(role, "userId", "user_id") or entry_user_id, + ) + ) + + return CommunityActivityRoleDiscovery("森空岛", _deduplicate_roles(roles)) + + +_MIYOUSHE_GAME_NAMES = { + "hk4e_cn": "原神", + "hkrpg_cn": "星穹铁道", + "nap_cn": "绝区零", +} + + +def normalize_miyoushe_roles( + payload: Mapping[str, Any], +) -> CommunityActivityRoleDiscovery: + """归一化米游社角色接口的扁平和按游戏嵌套响应。""" + + data = payload.get("data") + data_list = data.get("list") if isinstance(data, Mapping) else None + if not isinstance(data_list, list): + return CommunityActivityRoleDiscovery("米游社") + + role_entries: list[Mapping[str, Any]] = [] + for item in data_list: + if not isinstance(item, Mapping): + continue + nested = item.get("list") + if isinstance(nested, list): + role_entries.extend( + role for role in nested if isinstance(role, Mapping) + ) + else: + role_entries.append(item) + + roles: list[CommunityActivityRole] = [] + for role in role_entries: + game = _MIYOUSHE_GAME_NAMES.get(_text(role, "game_biz")) + role_uid = _text(role, "game_uid", "gameUid") + if not game or not role_uid: + continue + roles.append( + CommunityActivityRole( + platform="米游社", + game=game, + role_uid=role_uid, + server=_text(role, "region", "server"), + role_name=_text(role, "nickname", "nickName", "name"), + ) + ) + + return CommunityActivityRoleDiscovery("米游社", _deduplicate_roles(roles)) diff --git a/app/tools/community_activity_transport.py b/app/tools/community_activity_transport.py new file mode 100644 index 000000000..c7ab7c48d --- /dev/null +++ b/app/tools/community_activity_transport.py @@ -0,0 +1,505 @@ +# 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 + + +"""社区日常查询的 provider 请求规格和独立执行边界。""" + +from __future__ import annotations + +import asyncio +import re +from collections.abc import Awaitable, Callable, Iterable +from dataclasses import dataclass, field, replace +from datetime import datetime +from typing import Literal +from urllib.parse import urlencode + +from app.utils.constants import UTC8 + +from .game_sign_activity import parse_activity_snapshot +from .game_sign_contract import ActivityState, CommunityActivitySnapshot + +__all__ = [ + "CommunityActivityRequest", + "CommunityActivityTarget", + "CommunityActivityTransportError", + "ActivitySignatureProfile", + "build_community_activity_requests", + "collect_community_activity", +] + + +ActivityRequester = Callable[["CommunityActivityRequest"], Awaitable[object]] +ActivityAuthScope = Literal["skland", "miyoushe"] +ActivitySignatureProfile = Literal[ + "skland_widget", + "skland_endfield_web", + "miyoushe_params", + "miyoushe_ios", + "miyoushe_data", +] + +SKLAND_BASE_URL = "https://zonai.skland.com" +MIYOUSHE_RECORD_BASE_URL = "https://api-takumi-record.mihoyo.com" + + +class CommunityActivityTransportError(RuntimeError): + """provider 请求失败时供适配器传递的安全原因。""" + + def __init__( + self, + reason: str, + *, + status: Literal["limited", "unavailable", "failed"] = "failed", + ) -> None: + safe_reason = _normalize_transport_reason(reason, status=status) + super().__init__(safe_reason) + self.reason = safe_reason + self.status = status + + +def _normalize_transport_reason( + reason: object, + *, + status: Literal["limited", "unavailable", "failed"], +) -> str: + """压缩适配器文案,拒绝明显的键值型敏感字段。""" + + text = " ".join(str(reason or "").split()) + if re.search( + r"(?i)(?:token|cookie|authorization|cred|device[_-]?(?:id|fp))\s*[:=]", + text, + ): + text = "社区活动接口受到上游限制" if status == "limited" else "社区活动接口请求失败" + if not text: + text = "社区活动接口受到上游限制" if status == "limited" else "社区活动接口请求失败" + return text[:240] + + +@dataclass(frozen=True) +class CommunityActivityTarget: + """一次社区活动查询所需的账号/角色脱敏上下文。""" + + account_uid: str + account_name: str + platform: str + game: str + role_uid: str + server: str = "" + role_name: str = "" + user_id: str = "" + device_id: str = field(default="", repr=False) + device_fp: str = field(default="", repr=False) + + def role_metadata(self) -> dict[str, str]: + """返回传给解析器的角色元数据,不包含凭据或设备字段。""" + + return { + "name": self.role_name, + "roleId": self.role_uid, + "serverName": self.server, + } + + +@dataclass(frozen=True) +class CommunityActivityRequest: + """不携带认证值的单次 provider 请求规格。""" + + target: CommunityActivityTarget + source: str + method: Literal["GET"] + params: tuple[tuple[str, str], ...] = () + headers: tuple[tuple[str, str], ...] = () + auth_scope: ActivityAuthScope = "miyoushe" + signature_profile: ActivitySignatureProfile = "miyoushe_data" + requires_ds: bool = False + requires_device_id: bool = False + requires_device_fingerprint: bool = False + timeout: float = 30.0 + + @property + def query(self) -> dict[str, str]: + """返回供 HTTP 客户端使用的查询参数副本。""" + + return dict(self.params) + + @property + def header_map(self) -> dict[str, str]: + """返回供 HTTP 客户端使用的公开请求头副本。""" + + return dict(self.headers) + + @property + def query_string(self) -> str: + """返回用于签名计算的 URL 编码查询串。""" + + return urlencode(self.params) + + +def _required(value: str, name: str) -> str: + normalized = str(value or "").strip() + if not normalized: + raise ValueError(f"社区活动请求缺少 {name}") + return normalized + + +def _pairs(*values: tuple[str, object]) -> tuple[tuple[str, str], ...]: + return tuple((name, str(value)) for name, value in values if str(value).strip()) + + +def _headers( + target: CommunityActivityTarget, + *values: tuple[str, str], + include_device: bool = True, +) -> tuple[tuple[str, str], ...]: + headers = {name: value for name, value in values if value} + if include_device and target.device_id: + headers["x-rpc-device_id"] = target.device_id + if include_device and target.device_fp: + headers["x-rpc-device_fp"] = target.device_fp + return tuple(headers.items()) + + +def _skland_headers(target: CommunityActivityTarget) -> tuple[tuple[str, str], ...]: + return _headers( + target, + ("os", "iOS"), + ("platform", "2"), + ("manufacturer", "Apple"), + ("nid", "1"), + ("vName", "0.1.1"), + include_device=False, + ) + + +def _miyoushe_bbs_headers( + target: CommunityActivityTarget, + *, + tool_version: str = "v4.2.2-ys", + page: str = "v4.2.2-ys_#/ys/daily", +) -> tuple[tuple[str, str], ...]: + return _headers( + target, + ("Accept", "application/json, text/plain, */*"), + ("Origin", "https://webstatic.mihoyo.com"), + ("Referer", "https://webstatic.mihoyo.com/"), + ("x-rpc-client_type", "5"), + ("x-rpc-tool_version", tool_version), + ("x-rpc-page", page), + ) + + +def _miyoushe_widget_headers( + target: CommunityActivityTarget, + *, + client_type: str, +) -> tuple[tuple[str, str], ...]: + return _headers( + target, + ("Accept", "*/*"), + ("Referer", "https://app.mihoyo.com"), + ("x-rpc-client_type", client_type), + ("x-rpc-channel", "appstore"), + ) + + +def build_community_activity_requests( + target: CommunityActivityTarget, + *, + timestamp: int | None = None, +) -> tuple[CommunityActivityRequest, ...]: + """按已确认的 provider 接口生成一款游戏的请求规格。 + + 规格不包含 Cookie、Token、Authorization 或请求体;认证签名由调用方的 + provider 适配器在执行请求时注入。原神保留记录卡失败后的 Widget 回退。 + + Args: + target: 当前账号组和游戏角色的脱敏上下文。 + timestamp: 兼容调用方的可选查询时间戳;当前已确认的森空岛请求不使用它。 + + Returns: + 按优先级排列的单次请求规格。 + + Raises: + ValueError: 目标缺少该 provider 必需的角色字段。 + """ + + role_uid = _required(target.role_uid, "角色 UID") + if target.platform == "森空岛" and target.game == "明日方舟": + return ( + CommunityActivityRequest( + target=target, + source=f"{SKLAND_BASE_URL}/api/v1/game/player/info", + method="GET", + params=_pairs(("uid", role_uid)), + headers=_skland_headers(target), + auth_scope="skland", + signature_profile="skland_widget", + requires_device_id=False, + ), + ) + + if target.platform == "森空岛" and target.game == "终末地": + server = _required(target.server or "1", "终末地 serverId") + return ( + CommunityActivityRequest( + target=target, + source=f"{SKLAND_BASE_URL}/api/v1/game/endfield/card/detail", + method="GET", + params=_pairs( + ("roleId", role_uid), + ("serverId", server), + # 已确认成功记录只携带 roleId/serverId;userId 仅在绑定 + # 响应明确提供时附加,不能作为终末地查询的强制字段。 + ("userId", target.user_id), + ), + headers=_headers( + target, + ("Accept", "*/*"), + ("Origin", "https://game.skland.com"), + ("Referer", "https://game.skland.com/"), + ("X-Requested-With", "com.hypergryph.skland"), + ("sk-game-role", f"3_{role_uid}_{server}"), + ), + auth_scope="skland", + signature_profile="skland_endfield_web", + requires_device_id=True, + ), + ) + + if target.platform != "米游社": + raise ValueError(f"{target.platform}{target.game}尚未登记活动请求") + + server = _required(target.server, "米游社角色区服") + role_params = _pairs(("role_id", role_uid), ("server", server)) + if target.game == "原神": + return ( + CommunityActivityRequest( + target=target, + source=( + f"{MIYOUSHE_RECORD_BASE_URL}" + "/game_record/app/genshin/api/dailyNote" + ), + method="GET", + params=role_params, + headers=_miyoushe_bbs_headers(target), + auth_scope="miyoushe", + signature_profile="miyoushe_params", + requires_ds=True, + requires_device_id=True, + requires_device_fingerprint=True, + ), + CommunityActivityRequest( + target=target, + source=( + f"{MIYOUSHE_RECORD_BASE_URL}" + "/game_record/genshin/aapi/widget/v2" + ), + method="GET", + headers=_miyoushe_widget_headers(target, client_type="1"), + auth_scope="miyoushe", + signature_profile="miyoushe_ios", + requires_ds=True, + requires_device_id=True, + requires_device_fingerprint=True, + ), + ) + + if target.game == "星穹铁道": + return ( + CommunityActivityRequest( + target=target, + source=( + f"{MIYOUSHE_RECORD_BASE_URL}" + "/game_record/app/hkrpg/api/note" + ), + method="GET", + params=role_params, + headers=_miyoushe_bbs_headers( + target, + tool_version="v4.51.1", + page="v4.51.1_#/rpg", + ), + auth_scope="miyoushe", + signature_profile="miyoushe_params", + requires_ds=True, + requires_device_id=True, + requires_device_fingerprint=True, + ), + CommunityActivityRequest( + target=target, + source=( + f"{MIYOUSHE_RECORD_BASE_URL}" + "/game_record/app/hkrpg/aapi/widget" + ), + method="GET", + headers=_miyoushe_widget_headers(target, client_type="2"), + auth_scope="miyoushe", + signature_profile="miyoushe_data", + requires_ds=True, + requires_device_id=True, + requires_device_fingerprint=True, + ), + ) + + if target.game == "绝区零": + raise CommunityActivityTransportError( + "参考项目未确认绝区零稳定实时便笺接口,当前不发起请求", + status="limited", + ) + + raise ValueError(f"米游社{target.game}尚未登记活动请求") + + +def _failed_snapshot( + target: CommunityActivityTarget, + *, + status: ActivityState, + reason: str, + source: str = "", +) -> CommunityActivitySnapshot: + return CommunityActivitySnapshot( + account=target.account_name, + account_uid=target.account_uid, + game=target.game, + platform=target.platform, + status=status, + reason=reason, + updated_at=datetime.now(tz=UTC8).isoformat(), + role_name=target.role_name, + role_uid=target.role_uid, + server=target.server, + source=source, + ) + + +def _transport_failure( + error: Exception, +) -> tuple[ActivityState, str]: + if isinstance(error, CommunityActivityTransportError): + return error.status, error.reason + if isinstance(error, asyncio.TimeoutError): + return "failed", "社区活动接口请求超时" + return "failed", "社区活动接口请求失败" + + +async def _collect_one( + target: CommunityActivityTarget, + requester: ActivityRequester, + *, + semaphore: asyncio.Semaphore, + timestamp: int | None, +) -> CommunityActivitySnapshot: + async with semaphore: + try: + requests = build_community_activity_requests(target, timestamp=timestamp) + except (ValueError, CommunityActivityTransportError) as exc: + if isinstance(exc, CommunityActivityTransportError): + status, reason = exc.status, exc.reason + else: + status, reason = "unavailable", str(exc) + return _failed_snapshot( + target, + status=status, + reason=reason, + ) + + last_snapshot: CommunityActivitySnapshot | None = None + for request in requests: + try: + payload = await asyncio.wait_for( + requester(request), + timeout=request.timeout, + ) + snapshot = parse_activity_snapshot( + payload, + account_uid=target.account_uid, + account_name=target.account_name, + platform=target.platform, + game=target.game, + role=target.role_metadata(), + ) + except Exception as exc: + status, reason = _transport_failure(exc) + snapshot = _failed_snapshot( + target, + status=status, + reason=reason, + source=request.source, + ) + + if snapshot.status == "success" and snapshot.source != request.source: + snapshot = replace(snapshot, source=request.source) + last_snapshot = snapshot + if snapshot.status == "success": + return snapshot + + return last_snapshot or _failed_snapshot( + target, + status="failed", + reason="社区活动接口未返回结果", + ) + + +async def collect_community_activity( + targets: Iterable[CommunityActivityTarget], + requester: ActivityRequester, + *, + max_concurrency: int = 4, + timestamp: int | None = None, +) -> tuple[CommunityActivitySnapshot, ...]: + """独立并发查询多款游戏,保证单款失败不取消其他游戏。 + + 该函数只使用本地活动查询 semaphore,不获取签到锁、不调用签到入口,也不 + 负责保存凭据。调用方若需要防止重复点击,应在 `community_activity_flow()` + 边界内调用本函数。 + + Args: + targets: 待查询的账号/角色集合。 + requester: provider 适配器提供的异步请求函数;认证值只能在其内部注入。 + max_concurrency: 活动查询自身的并发上限。 + timestamp: 传给森空岛 Widget 的可选固定时间戳。 + + Returns: + 与输入顺序一致的独立游戏快照。 + + Raises: + ValueError: 并发上限小于 1。 + """ + + if max_concurrency < 1: + raise ValueError("社区活动查询并发上限必须大于 0") + + target_list = tuple(targets) + semaphore = asyncio.Semaphore(max_concurrency) + return tuple( + await asyncio.gather( + *( + _collect_one( + target, + requester, + semaphore=semaphore, + timestamp=timestamp, + ) + for target in target_list + ) + ) + ) diff --git a/app/tools/community_notify.py b/app/tools/community_notify.py new file mode 100644 index 000000000..b7dfbb00b --- /dev/null +++ b/app/tools/community_notify.py @@ -0,0 +1,80 @@ +# 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 .game_sign_notify import ( + format_game_sign_notification as format_community_notification, + format_game_sign_task_summary as format_community_task_summary, + push_game_sign_notification as push_community_notification, +) + +__all__ = [ + "append_task_community_summary", + "format_community_notification", + "format_community_task_summary", + "get_task_community_summary", + "mark_task_community_summary_consumed", + "push_community_notification", +] + + +def get_task_community_summary(task_info: object) -> str: + """读取尚未发送的社区签到汇总,兼容旧任务字段。""" + + consumed = ( + getattr(task_info, "community_summary_consumed", False) + if hasattr(task_info, "community_summary_consumed") + else getattr(task_info, "game_sign_summary_consumed", False) + ) + if consumed: + return "" + + results = ( + getattr(task_info, "community_results", []) + if hasattr(task_info, "community_results") + else getattr(task_info, "game_sign_results", []) + ) + if not results: + return "" + return format_community_task_summary(list(results)) + + +def mark_task_community_summary_consumed(task_info: object) -> None: + """标记社区签到汇总已由任务报告消费。""" + + if hasattr(task_info, "community_summary_consumed"): + setattr(task_info, "community_summary_consumed", True) + else: + setattr(task_info, "game_sign_summary_consumed", True) + + +def append_task_community_summary(task_info: object, result: str) -> str: + """将尚未发送的社区签到汇总附加到任务报告。""" + + if not Config.ToolsConfig.get("GameSign", "NotifyEnabled"): + return result + + summary = get_task_community_summary(task_info) + return f"{result}\n\n{summary}" if summary else result diff --git a/app/tools/community_plugins.py b/app/tools/community_plugins.py new file mode 100644 index 000000000..2d75b823a --- /dev/null +++ b/app/tools/community_plugins.py @@ -0,0 +1,200 @@ +# 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 dataclasses import dataclass +from typing import Literal + +__all__ = [ + "COMMUNITY_PLUGIN_REFERENCES", + "CommunityCapability", + "CommunityPluginReference", + "get_community_plugin_references", + "get_confirmed_community_capabilities", + "supports_community_capability", +] + + +CommunityCapability = Literal[ + "credential", + "sign", + "community_task", + "daily_activity", + "role_profile", + "announcement", + "activity_calendar", + "gacha_record", + "game_data", + "notification", +] + + +@dataclass(frozen=True) +class CommunityPluginReference: + """记录外部社区插件已展示的能力,不能直接视为 AUTO-MAS 已接入。""" + + name: str + provider: str + games: tuple[str, ...] + capabilities: frozenset[CommunityCapability] + evidence: tuple[str, ...] + interface_confirmed: bool = True + + +# 只记录参考项目中已看到的能力;接口接入仍需单独完成协议和凭据审查。 +COMMUNITY_PLUGIN_REFERENCES = ( + CommunityPluginReference( + name="Yunzai-Kuro-Plugin", + provider="库街区", + games=("战双帕弥什", "鸣潮"), + capabilities=frozenset( + { + "credential", + "sign", + "community_task", + "daily_activity", + "role_profile", + "gacha_record", + "notification", + } + ), + evidence=( + "apps/gameEnergy.js", + "apps/bbsActivityTask.js", + "model/gameCard.js", + ), + ), + CommunityPluginReference( + name="ZZZ-Plugin", + provider="米游社", + games=("绝区零",), + capabilities=frozenset( + { + "credential", + "daily_activity", + "role_profile", + "gacha_record", + "game_data", + } + ), + evidence=("dist/apps/note.js", "dist/lib/mysapi/api.js"), + ), + CommunityPluginReference( + name="zmd-plugin", + provider="森空岛", + games=("明日方舟", "终末地"), + capabilities=frozenset( + { + "credential", + "sign", + "daily_activity", + "role_profile", + "announcement", + "activity_calendar", + "gacha_record", + } + ), + evidence=( + "model/skland/api.js", + "apps/status.js", + "resources/enduid/daily.html", + ), + ), + CommunityPluginReference( + name="bh3-plugin", + provider="米游社", + games=("崩坏3",), + capabilities=frozenset( + {"credential", "daily_activity", "role_profile", "game_data"} + ), + evidence=("lib/api.js", "apps/main.js"), + ), + CommunityPluginReference( + name="1999-plugin", + provider="未确认", + games=("重返未来:1999",), + capabilities=frozenset(), + evidence=("README.md",), + interface_confirmed=False, + ), + CommunityPluginReference( + name="skland-plugin", + provider="森空岛", + games=("明日方舟",), + capabilities=frozenset( + { + "credential", + "sign", + "daily_activity", + "role_profile", + "gacha_record", + "notification", + } + ), + evidence=("components/Code.js", "apps/Sanity.js", "apps/SignIn.js"), + ), +) + + +def get_community_plugin_references( + *, + provider: str | None = None, + game: str | None = None, + confirmed_only: bool = False, +) -> tuple[CommunityPluginReference, ...]: + """按社区或游戏筛选参考插件能力。""" + + provider_value = provider.strip() if provider else None + game_value = game.strip() if game else None + return tuple( + item + for item in COMMUNITY_PLUGIN_REFERENCES + if (not confirmed_only or item.interface_confirmed) + and (provider_value is None or item.provider == provider_value) + and (game_value is None or game_value in item.games) + ) + + +def get_confirmed_community_capabilities( + provider: str, game: str +) -> frozenset[CommunityCapability]: + """合并已确认参考插件对指定社区/游戏展示的能力。""" + + capabilities: set[CommunityCapability] = set() + for item in get_community_plugin_references( + provider=provider, + game=game, + confirmed_only=True, + ): + capabilities.update(item.capabilities) + return frozenset(capabilities) + + +def supports_community_capability( + provider: str, + game: str, + capability: CommunityCapability, +) -> bool: + """判断参考项目是否展示过指定社区能力。""" + + return capability in get_confirmed_community_capabilities(provider, game) diff --git a/app/tools/game_sign.py b/app/tools/game_sign.py index ce459ace9..e181c74e4 100644 --- a/app/tools/game_sign.py +++ b/app/tools/game_sign.py @@ -20,35 +20,32 @@ # Contact: DLmaster_361@163.com -import asyncio import time from contextlib import asynccontextmanager -from contextvars import ContextVar from dataclasses import dataclass, field -from datetime import datetime from typing import Awaitable, Callable import httpx from app.core import Config -from app.utils.constants import UTC8 from app.utils.logger import get_logger from app.utils.security import format_exception_reason +from .game_sign_credentials import ( + is_community_credential_configured, + parse_community_credential, + validate_community_credential, +) +from .game_sign_contract import CommunitySignInProgressError, CommunitySignResult from .game_sign_result import build_skland_sign_results logger = get_logger("游戏社区签到") -_game_sign_lock = asyncio.Lock() -_game_sign_flow_lock = asyncio.Lock() -_game_sign_lock_owner: ContextVar[asyncio.Task | None] = ContextVar( - "game_sign_lock_owner", default=None -) _system_time_checked_at = 0.0 _SYSTEM_TIME_CHECK_INTERVAL = 300.0 @dataclass -class _ProviderRun: +class _CommunityProviderRun: """单个社区适配器的结果和需要回写的凭据。""" results: list[dict] @@ -56,13 +53,17 @@ class _ProviderRun: credential_updates: dict[str, str] = field(default_factory=dict) -ProviderRunner = Callable[[str, str, str], Awaitable[_ProviderRun]] +CredentialUpdateCallback = Callable[[str, str], Awaitable[None]] +ProviderRunner = Callable[ + [str, str, str, CredentialUpdateCallback | None], + Awaitable[_CommunityProviderRun], +] PlatformResolver = Callable[[str], tuple[str, ...]] ErrorGameResolver = Callable[[str], str] @dataclass(frozen=True) -class _GameSignProvider: +class _CommunitySignProvider: """以配置字段驱动的社区签到适配器描述。""" token_field: str @@ -72,44 +73,33 @@ class _GameSignProvider: error_game: ErrorGameResolver -class GameSignInProgressError(RuntimeError): - """游戏社区签到已在执行。""" +GameSignInProgressError = CommunitySignInProgressError @asynccontextmanager async def game_sign_flow(): - """保护签到请求及结果落盘,通知由调用方在锁外发送。""" + """兼容旧调用方,转发到社区签到流程锁。""" - if _game_sign_flow_lock.locked(): - raise GameSignInProgressError("游戏社区签到正在执行,请稍后重试") + from app.core.community_sign import community_sign_flow - await _game_sign_flow_lock.acquire() - try: + async with community_sign_flow(): yield - finally: - _game_sign_flow_lock.release() async def _enter_game_sign_lock() -> bool: - """获取全局签到锁;同一任务嵌套调用时复用已持有的锁。""" + """兼容旧内部调用,获取社区签到执行锁。""" - current_task = asyncio.current_task() - if current_task is not None and _game_sign_lock_owner.get() is current_task: - return False - - if _game_sign_lock.locked(): - raise GameSignInProgressError("游戏社区签到正在执行,请稍后重试") + from app.core.community_sign import _enter_community_sign_lock - await _game_sign_lock.acquire() - _game_sign_lock_owner.set(current_task) - return True + return await _enter_community_sign_lock() def _exit_game_sign_lock(acquired: bool) -> None: - if not acquired: - return - _game_sign_lock_owner.set(None) - _game_sign_lock.release() + """兼容旧内部调用,释放社区签到执行锁。""" + + from app.core.community_sign import _exit_community_sign_lock + + _exit_community_sign_lock(acquired) def _all_enabled_platforms_signed( @@ -118,29 +108,18 @@ def _all_enabled_platforms_signed( account_uid: str, enabled_platforms: list[str], ) -> bool: - """判断账号的全部已配置平台结果是否均已完成。""" - - if not enabled_platforms: - return False + """兼容旧内部调用,判断账号的已配置平台是否全部完成。""" - for platform in enabled_platforms: - platform_results = [ - result - for result in results - if result.get("account_uid") == account_uid - and result.get("platform") == platform - ] - if not platform_results or any( - result.get("status") not in ("成功", "已签到") - and not result.get("_completed") - for result in platform_results - ): - return False + from app.core.community_sign import all_enabled_community_platforms_signed - return True + return all_enabled_community_platforms_signed( + results, + account_uid=account_uid, + enabled_platforms=enabled_platforms, + ) -async def _check_system_time() -> None: +async def check_community_system_time() -> None: """检查系统时间偏差并提示用户,不阻断签到流程。 时间源不可信或不可用时(服务退役、被劫持的网络等)仅记录日志; @@ -179,31 +158,23 @@ def _empty_platform_result( ) -> dict: """为没有返回可签到角色的平台保留通知占位,不写入前端结果列表。""" - return { - "account": account_name, - "account_uid": account_uid, - "game": "", - "platform": platform, - "status": "失败", - "reward": "", - "reason": "未获取到可签到角色", - "_notification_only": True, - } + return CommunitySignResult( + account=account_name, + account_uid=account_uid, + game="", + platform=platform, + status="失败", + reason="未获取到可签到角色", + notification_only=True, + ).to_legacy() async def run_all_sign_in(force: bool = False) -> list[dict]: - """协调执行游戏社区签到,避免重复签到和重复通知。""" - # 时间检查只提供告警,不应阻塞真实签到或占用签到锁。 - time_check_task = asyncio.create_task(_check_system_time()) - acquired = False - try: - acquired = await _enter_game_sign_lock() - return await _run_all_sign_in(force=force) - finally: - _exit_game_sign_lock(acquired) - if not time_check_task.done(): - time_check_task.cancel() - await asyncio.gather(time_check_task, return_exceptions=True) + """兼容旧调用方,转发到社区签到核心编排。""" + + from app.core.community_sign import run_community_sign_in + + return await run_community_sign_in(force=force) def _fixed_platforms(platform: str) -> PlatformResolver: @@ -215,30 +186,39 @@ def _default_error_game(platform: str) -> str: def _taygedo_error_game(platform: str) -> str: - return "幻塔社区" if platform == "塔吉多" else "云异环" + # 未完成凭据/上游校验时没有可靠的游戏 ID,不把结果猜成某一款游戏。 + return "塔吉多社区" if platform == "塔吉多" else "云异环" def _resolve_taygedo_platforms(raw_token: str) -> tuple[str, ...]: """根据塔吉多凭据字段确定实际启用的社区。""" try: - from .taygedo import parse_taygedo_credential - - credential = parse_taygedo_credential(raw_token) + credential = parse_community_credential("TaygedoToken", raw_token) except Exception: return ("塔吉多",) platforms = [] - if credential.get("refreshToken") or credential.get("accessToken"): + if credential.has_any( + "refreshToken", + "refresh_token", + "accessToken", + "access_token", + ): platforms.append("塔吉多") - if credential.get("cloudToken") and credential.get("cloudUserId"): + if credential.has_any("cloudToken", "cloud_token") and credential.has_any( + "cloudUserId", "cloud_user_id" + ): platforms.append("云异环") return tuple(platforms) or ("塔吉多",) async def _run_skland_provider( - token: str, account_name: str, account_uid: str -) -> _ProviderRun: + token: str, + account_name: str, + account_uid: str, + on_credential_update: CredentialUpdateCallback | None = None, +) -> _CommunityProviderRun: from .skland import skland_sign_in updated_token = "" @@ -246,6 +226,14 @@ async def _run_skland_provider( async def capture_credential(value: str) -> None: nonlocal updated_token updated_token = str(value or "").strip() + if updated_token and on_credential_update is not None: + try: + await on_credential_update("SklandToken", updated_token) + except Exception as error: + # 收尾 credential_updates 仍会重试,不能让回写故障掩盖签到结果。 + logger.warning( + f"[{account_name}] 森空岛凭据即时回写失败: {type(error).__name__}" + ) raw_result = await skland_sign_in( token, @@ -254,7 +242,7 @@ async def capture_credential(value: str) -> None: on_credential_update=capture_credential, ) updates = {"SklandToken": updated_token} if updated_token else {} - return _ProviderRun( + return _CommunityProviderRun( results=build_skland_sign_results( raw_result, account_name=account_name, @@ -266,22 +254,35 @@ async def capture_credential(value: str) -> None: async def _run_miyoushe_provider( - token: str, _account_name: str, _account_uid: str -) -> _ProviderRun: - from .miyoushe import miyoushe_sign_in + token: str, + _account_name: str, + _account_uid: str, + on_credential_update: CredentialUpdateCallback | None = None, +) -> _CommunityProviderRun: + from .miyoushe import merge_miyoushe_cookie_update, miyoushe_sign_in updated_token = "" async def capture_credential(value: str) -> None: nonlocal updated_token - updated_token = str(value or "").strip() + candidate = str(value or "") + if candidate: + updated_token = merge_miyoushe_cookie_update(token, candidate) + if updated_token and on_credential_update is not None: + try: + await on_credential_update("MiyousheToken", updated_token) + except Exception as error: + # 收尾 credential_updates 仍会重试,不能让回写故障掩盖签到结果。 + logger.warning( + f"米游社凭据即时回写失败: {type(error).__name__}" + ) results = await miyoushe_sign_in( token, on_credential_update=capture_credential, ) updates = {"MiyousheToken": updated_token} if updated_token else {} - return _ProviderRun( + return _CommunityProviderRun( results=results, platforms=("米游社",), credential_updates=updates, @@ -289,81 +290,94 @@ async def capture_credential(value: str) -> None: async def _run_kuro_provider( - token: str, _account_name: str, _account_uid: str -) -> _ProviderRun: + token: str, + _account_name: str, + _account_uid: str, + _on_credential_update: CredentialUpdateCallback | None = None, +) -> _CommunityProviderRun: from .kuro import kuro_sign_in - return _ProviderRun( + return _CommunityProviderRun( results=await kuro_sign_in(token), platforms=("库街区",), ) async def _run_taygedo_provider( - token: str, _account_name: str, _account_uid: str -) -> _ProviderRun: + token: str, + _account_name: str, + _account_uid: str, + on_credential_update: CredentialUpdateCallback | None = None, +) -> _CommunityProviderRun: from .taygedo import ( - parse_taygedo_credential, - serialize_taygedo_credential, sign_taygedo, + validate_taygedo_credential, ) - credential = parse_taygedo_credential(token) - has_community = bool( - credential.get("refreshToken") or credential.get("accessToken") - ) - has_cloud = bool(credential.get("cloudToken") and credential.get("cloudUserId")) - if not has_community and not has_cloud: - raise ValueError( - "塔吉多凭据缺少 refreshToken/accessToken 或 cloudToken/cloudUserId" - ) + validate_taygedo_credential(token) + + # 刷新结果由 provider 先聚合,再交给社区签到编排统一回写;这样既能 + # 接住上游轮换后的 refreshToken,也能让下一次调用优先复用当前 accessToken。 + updated_token = "" + + async def capture_credential(value: str) -> None: + nonlocal updated_token + updated_token = str(value or "").strip() + if updated_token and on_credential_update is not None: + # 塔吉多内部会在刷新返回后立即调用此回调;不要在这里吞掉异常, + # 让 sign_taygedo 保留收尾兜底机会。 + await on_credential_update("TaygedoToken", updated_token) - sign_results, refreshed_credential = await sign_taygedo( + community_results, _runtime_credential = await sign_taygedo( token, proxy=Config.proxy, + on_credential_update=capture_credential, ) - refreshed_token = serialize_taygedo_credential(refreshed_credential) - updates = ( - {"TaygedoToken": refreshed_token} - if refreshed_token and refreshed_token != str(token).strip() - else {} - ) - return _ProviderRun( - results=sign_results, + return _CommunityProviderRun( + results=community_results, platforms=(), - credential_updates=updates, + credential_updates={"TaygedoToken": updated_token} if updated_token else {}, ) -def _game_sign_providers() -> tuple[_GameSignProvider, ...]: +def get_community_sign_providers() -> tuple[_CommunitySignProvider, ...]: """返回按通知顺序排列的签到适配器注册表。""" - return _GAME_SIGN_PROVIDERS + return _COMMUNITY_SIGN_PROVIDERS + +def get_community_token_field(platform: str) -> str: + """从兼容签到注册表读取社区凭据字段。""" -_GAME_SIGN_PROVIDERS = ( - _GameSignProvider( + for provider in get_community_sign_providers(): + if provider.log_name == platform: + return provider.token_field + raise ValueError(f"{platform}社区凭据字段尚未登记") + + +_COMMUNITY_SIGN_PROVIDERS = ( + _CommunitySignProvider( token_field="SklandToken", log_name="森空岛", runner=_run_skland_provider, resolve_platforms=_fixed_platforms("森空岛"), error_game=_default_error_game, ), - _GameSignProvider( + _CommunitySignProvider( token_field="MiyousheToken", log_name="米游社", runner=_run_miyoushe_provider, resolve_platforms=_fixed_platforms("米游社"), error_game=_default_error_game, ), - _GameSignProvider( + _CommunitySignProvider( token_field="KuroToken", log_name="库街区", runner=_run_kuro_provider, resolve_platforms=_fixed_platforms("库街区"), error_game=_default_error_game, ), - _GameSignProvider( + _CommunitySignProvider( token_field="TaygedoToken", log_name="塔吉多", runner=_run_taygedo_provider, @@ -371,12 +385,17 @@ def _game_sign_providers() -> tuple[_GameSignProvider, ...]: error_game=_taygedo_error_game, ), ) -GAME_SIGN_TOKEN_FIELDS = tuple( - provider.token_field for provider in _GAME_SIGN_PROVIDERS +COMMUNITY_TOKEN_FIELDS = tuple( + provider.token_field for provider in _COMMUNITY_SIGN_PROVIDERS ) +# 历史名称只保留为兼容引用,注册表和字段清单仍各有唯一实例。 +_ProviderRun = _CommunityProviderRun +_GameSignProvider = _CommunitySignProvider +_GAME_SIGN_PROVIDERS = _COMMUNITY_SIGN_PROVIDERS +GAME_SIGN_TOKEN_FIELDS = COMMUNITY_TOKEN_FIELDS -def _read_game_sign_token(account: object, field: str) -> str: +def read_community_token(account: object, field: str) -> str: """读取凭据字段,兼容旧版本尚未包含新增字段的账号对象。""" try: @@ -386,16 +405,20 @@ def _read_game_sign_token(account: object, field: str) -> str: return value.strip() if isinstance(value, str) else str(value or "").strip() -def has_game_sign_credentials(account: object) -> bool: +def has_community_credentials(account: object) -> bool: """判断账号是否至少配置一个已注册社区凭据。""" return any( - _read_game_sign_token(account, field) for field in GAME_SIGN_TOKEN_FIELDS + is_community_credential_configured( + field, + read_community_token(account, field), + ) + for field in COMMUNITY_TOKEN_FIELDS ) def _provider_error_results( - provider: _GameSignProvider, + provider: _CommunitySignProvider, *, platforms: tuple[str, ...], account_name: str, @@ -403,21 +426,20 @@ def _provider_error_results( reason: str, ) -> list[dict]: return [ - { - "account": f"{account_name}/{platform}", - "account_uid": account_uid, - "game": provider.error_game(platform), - "platform": platform, - "status": "失败", - "reward": "", - "reason": reason, - } + CommunitySignResult( + account=f"{account_name}/{platform}", + account_uid=account_uid, + game=provider.error_game(platform), + platform=platform, + status="失败", + reason=reason, + ).to_legacy() for platform in platforms ] def _resolved_provider_platforms( - provider: _GameSignProvider, token: str + provider: _CommunitySignProvider, token: str ) -> tuple[str, ...]: try: platforms = provider.resolve_platforms(token) @@ -428,22 +450,24 @@ def _resolved_provider_platforms( def _decorate_provider_run( - run: _ProviderRun, + run: _CommunityProviderRun, *, fallback_platforms: tuple[str, ...], account_name: str, account_uid: str, -) -> _ProviderRun: +) -> _CommunityProviderRun: platforms = run.platforms or fallback_platforms normalized = [] for raw_item in run.results: if not isinstance(raw_item, dict): continue - item = dict(raw_item) - if not item.get("account") or item.get("account") == "未知用户": - item["account"] = account_name - item["account_uid"] = account_uid - normalized.append(item) + normalized.append( + CommunitySignResult.from_legacy( + raw_item, + fallback_account=account_name, + fallback_uid=account_uid, + ).to_legacy() + ) for platform in platforms: if not any(item.get("platform") == platform for item in normalized): @@ -455,7 +479,7 @@ def _decorate_provider_run( ) ) - return _ProviderRun( + return _CommunityProviderRun( results=normalized, platforms=platforms, credential_updates=dict(run.credential_updates), @@ -492,29 +516,57 @@ def _is_expected_provider_exception(error: Exception) -> bool: ) -async def _run_provider( - provider: _GameSignProvider, +async def run_community_provider( + provider: _CommunitySignProvider, token: str, *, account_name: str, account_uid: str, -) -> _ProviderRun: + on_credential_update: CredentialUpdateCallback | None = None, +) -> _CommunityProviderRun: fallback_platforms = _resolved_provider_platforms(provider, token) - logger.info(f"[{account_name}] 开始{provider.log_name}签到") + logger.info(f"[{account_name}] 开始{provider.log_name}社区签到") + credential_status = validate_community_credential( + provider.token_field, + token, + ) + if not credential_status.locally_valid: + reason = credential_status.reason or f"{provider.log_name}凭据格式无效" + logger.warning(f"[{account_name}] {provider.log_name}凭据校验失败: {reason}") + return _CommunityProviderRun( + results=_provider_error_results( + provider, + platforms=fallback_platforms, + account_name=account_name, + account_uid=account_uid, + reason=reason, + ), + platforms=fallback_platforms, + ) try: - run = await provider.runner(token, account_name, account_uid) + if on_credential_update is None: + # 保留第三方/历史适配器的三参数 runner 兼容性;内置社区 runner + # 均支持第四个回写回调。 + run = await provider.runner(token, account_name, account_uid) # type: ignore[call-arg] + else: + run = await provider.runner( + token, + account_name, + account_uid, + on_credential_update, + ) except Exception as e: expected = _is_expected_provider_exception(e) reason = format_exception_reason( e, - stage=f"{provider.log_name}签到失败", + stage=f"{provider.log_name}社区签到失败", include_message=expected, ) if expected: logger.warning(f"[{account_name}] {reason}") else: - logger.exception(f"{provider.log_name}签到程序异常") - return _ProviderRun( + logger.exception(f"{provider.log_name}社区签到程序异常") + return _CommunityProviderRun( results=_provider_error_results( provider, platforms=fallback_platforms, @@ -533,98 +585,19 @@ async def _run_provider( async def _run_all_sign_in(force: bool = False) -> list[dict]: - """执行所有已配置平台的签到。 + """兼容旧内部调用,转发到社区签到核心编排。""" - 平台由凭据字段注册表驱动。同一账号的独立社区并发执行,适配器内部 - 仍自行控制请求间隔和风控策略;凭据刷新结果在并发任务完成后统一落盘。 - """ - results = [] - today = datetime.now(tz=UTC8).strftime("%Y-%m-%d") - - providers = _game_sign_providers() - 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) - - # 跳过已禁用的用户 - if not account_enabled: - continue + return await run_all_sign_in(force=force) - # 非强制模式:跳过今日已签到的用户 - if not force: - user_last_sign = account.get("GameSignAccount", "LastSignDate") - if user_last_sign == today: - logger.debug(f"[{account_name}] 今日已签到,跳过") - continue - - tokens = { - provider.token_field: _read_game_sign_token(account, provider.token_field) - for provider in providers - } - configured = [ - provider for provider in providers if tokens.get(provider.token_field) - ] - if not configured: - continue - # 不同社区互不依赖,按注册顺序并发执行,完成后仍按固定顺序合并结果。 - provider_runs = await asyncio.gather( - *( - _run_provider( - provider, - tokens[provider.token_field], - account_name=account_name, - account_uid=account_uid, - ) - for provider in configured - ) - ) - enabled_platforms = [] - for provider, run in zip(configured, provider_runs): - for platform in run.platforms: - if platform not in enabled_platforms: - enabled_platforms.append(platform) - results.extend(run.results) - for field, updated_token in run.credential_updates.items(): - if not updated_token or updated_token == tokens.get(field, ""): - continue - try: - await account.set("GameSignAccount", field, updated_token) - except Exception as e: - logger.warning(f"[{account_name}] 保存{field}失败: {e}") - - # 自动签到每天只尝试一次。失败也要记住当天的尝试,避免后续 MAS 任务反复请求; - # 手动签到使用 force=True,仍只在所有已配置平台完成后更新日期。 - all_platforms_signed = _all_enabled_platforms_signed( - results, - account_uid=account_uid, - enabled_platforms=enabled_platforms, - ) - should_mark_signed = bool(enabled_platforms) and ( - not force or all_platforms_signed - ) - if should_mark_signed: - try: - # 多账号串行签到可能跨越 0 点,写入时重新取当前日期, - # 避免把新一天的签到记成旧日期导致次日误判。 - sign_date = datetime.now(tz=UTC8).strftime("%Y-%m-%d") - await account.set("GameSignAccount", "LastSignDate", sign_date) - except Exception as e: - logger.warning(f"[{account_name}] 保存签到完成日期失败: {e}") - - if not results: - logger.info("没有配置任何签到平台") - - return results - - -def merge_sign_results(existing: dict, formatted: dict, replace: bool = False) -> dict: +def merge_community_sign_results( + existing: dict, formatted: dict, replace: bool = False +) -> dict: """将新签到结果合并到已有结果中 Args: existing: 已有的 _game_sign_result_data - formatted: 本次 format_sign_results 的新结果 + formatted: 本次 format_community_sign_results 的新结果 replace: 保留该参数以兼容现有调用;受影响账号均按 account_uid 替换旧数据。 Returns: @@ -650,7 +623,7 @@ def merge_sign_results(existing: dict, formatted: dict, replace: bool = False) - return existing -def format_sign_results(results: list[dict]) -> dict: +def format_community_sign_results(results: list[dict]) -> dict: """将签到结果格式化为前端可展示的结构 按平台分组,平台内按账号 UID 聚合 @@ -696,3 +669,30 @@ def format_sign_results(results: list[dict]) -> dict: result[platform] = list(accounts.values()) return result + + +# 旧模块继续暴露历史符号,实际签到调度由 app.core.community_sign 承载。 +_check_system_time = check_community_system_time +_read_game_sign_token = read_community_token +has_game_sign_credentials = has_community_credentials +_run_provider = run_community_provider + + +def _game_sign_providers() -> tuple[_CommunitySignProvider, ...]: + """兼容旧内部调用,返回历史注册表别名。""" + + return _GAME_SIGN_PROVIDERS + + +def merge_sign_results( + existing: dict, formatted: dict, replace: bool = False +) -> dict: + """兼容旧调用方,合并社区签到结果。""" + + return merge_community_sign_results(existing, formatted, replace=replace) + + +def format_sign_results(results: list[dict]) -> dict: + """兼容旧调用方,格式化社区签到结果。""" + + return format_community_sign_results(results) diff --git a/app/tools/game_sign_activity.py b/app/tools/game_sign_activity.py new file mode 100644 index 000000000..f59c79288 --- /dev/null +++ b/app/tools/game_sign_activity.py @@ -0,0 +1,720 @@ +# 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 __future__ import annotations + +import json +import math +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from app.utils.constants import UTC8 + +from .game_sign_contract import ActivityState, CommunityActivitySnapshot + +__all__ = ["ACTIVITY_GAME_DEFINITIONS", "parse_activity_snapshot"] + + +class ActivityResponseLimitedError(ValueError): + """上游响应被风控、拦截或返回了不可解析内容。""" + + +class ActivityResponseUnavailableError(ValueError): + """上游响应可达但没有当前版本可识别的数据。""" + + +ActivityRole = Mapping[str, Any] | None +ActivityParser = Callable[ + [Mapping[str, Any], str, str, ActivityRole], CommunityActivitySnapshot +] + +_RISK_CODES = frozenset({1034, 5003, 10035, 10041}) + + +def _as_int(value: object) -> int | None: + if isinstance(value, bool) or value is None: + return None + if isinstance(value, int): + return max(value, 0) + if isinstance(value, float): + return max(int(value), 0) if math.isfinite(value) else None + if isinstance(value, str): + text = value.strip() + if not text: + return None + try: + return max(int(float(text)), 0) + except ValueError: + return None + return None + + +def _as_code(value: object) -> int | None: + if isinstance(value, bool) or value is None: + return None + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) if math.isfinite(value) else None + if isinstance(value, str): + try: + return int(value.strip()) + except ValueError: + return None + return None + + +def _first_int(item: Mapping[str, Any], names: tuple[str, ...]) -> int | None: + for name in names: + value = _as_int(item.get(name)) + if value is not None: + return value + return None + + +def _progress_pair(value: object) -> tuple[int, int] | None: + if isinstance(value, str): + current_text, separator, target_text = value.partition("/") + if separator: + current = _as_int(current_text) + target = _as_int(target_text) + if current is not None and target is not None: + return current, target + return None + + if not isinstance(value, Mapping): + return None + + current = _first_int( + value, + ( + "current", + "cur", + "completed", + "finished", + "finish", + "done", + "progress", + "dailyActivation", + "daily_activation", + "curStamina", + "currentStamina", + "finished_task_num", + "current_train_score", + "current_training_score", + "current_rogue_score", + "accepted_expedition_num", + "curLevel", + "currentValue", + "current_value", + "count", + "num", + "score", + ), + ) + target = _first_int( + value, + ( + "total", + "target", + "max", + "maxProgress", + "maxStamina", + "max_stamina", + "totalCount", + "total_task_num", + "max_train_score", + "max_training_score", + "max_rogue_score", + "total_expedition_num", + "maxDailyActivation", + "max_daily_activation", + "maxLevel", + "limit", + "maxValue", + "max_value", + ), + ) + if current is not None and target is not None: + return current, target + + for name in ("progress", "data", "detail", "stat", "value", "daily"): + nested = value.get(name) + if nested is not value: + pair = _progress_pair(nested) + if pair is not None: + return pair + return None + + +def _activity_item(name: str, value: object) -> dict[str, Any] | None: + pair = _progress_pair(value) + if pair is None: + return None + completed, target = pair + return { + "name": name, + "completed": completed, + "target": target, + "status": "已完成" if target > 0 and completed >= target else "进行中", + } + + +def _task_items(value: object, *, default_name: str) -> list[dict[str, Any]]: + item = _activity_item(default_name, value) + if item is not None: + return [item] + if not isinstance(value, Mapping): + return [] + + for key in ("items", "tasks", "list"): + entries = value.get(key) + if not isinstance(entries, list): + continue + items: list[dict[str, Any]] = [] + for index, entry in enumerate(entries): + if not isinstance(entry, Mapping): + continue + name = str( + entry.get("name") + or entry.get("title") + or entry.get("desc") + or f"{default_name}{index + 1}" + ).strip() + item = _activity_item(name, entry) + if item is not None: + items.append(item) + if items: + return items + return [] + + +def _resource_item(name: str, value: object) -> dict[str, Any] | None: + pair = _progress_pair(value) + if pair is None: + return None + current, target = pair + return { + "name": name, + "current": current, + "target": target, + "status": "已满" if target > 0 and current >= target else "可用", + } + + +def _mark_period( + items: list[dict[str, Any]], period: str +) -> list[dict[str, Any]]: + return [dict(item, period=period) for item in items] + + +def _unwrap_data(payload: Mapping[str, Any]) -> Mapping[str, Any]: + data = payload.get("data") + return data if isinstance(data, Mapping) else payload + + +def _role_value(role: ActivityRole, names: tuple[str, ...]) -> str: + if not isinstance(role, Mapping): + return "" + for name in names: + value = role.get(name) + if value is not None and str(value).strip(): + return str(value).strip() + return "" + + +def _build_snapshot( + *, + account_uid: str, + account_name: str, + platform: str, + game: str, + items: list[dict[str, Any]], + resources: list[dict[str, Any]], + role: ActivityRole, + source: str, +) -> CommunityActivitySnapshot: + daily_items = [item for item in items if item.get("period", "daily") == "daily"] + if not daily_items: + raise ActivityResponseUnavailableError( + f"{platform}{game}未返回可识别的每日任务进度" + ) + + completed = sum(_as_int(item.get("completed")) or 0 for item in daily_items) + target = sum(_as_int(item.get("target")) or 0 for item in daily_items) + return CommunityActivitySnapshot( + account=account_name, + account_uid=account_uid, + game=game, + platform=platform, + status="success", + completed=completed, + target=target, + tasks=tuple(items), + resources=tuple(resources), + updated_at=datetime.now(tz=UTC8).isoformat(), + role_name=_role_value(role, ("name", "nickname", "nickName")), + role_uid=_role_value( + role, + ("roleId", "role_id", "uid", "gameUid", "game_uid"), + ), + server=_role_value( + role, + ("serverName", "server", "serverId", "server_id", "channelName"), + ), + source=source, + ) + + +def _failed_snapshot( + *, + account_uid: str, + account_name: str, + platform: str, + game: str, + status: ActivityState, + reason: str, + role: ActivityRole, + source: str = "", +) -> CommunityActivitySnapshot: + return CommunityActivitySnapshot( + account=account_name, + account_uid=account_uid, + game=game, + platform=platform, + status=status, + reason=reason, + updated_at=datetime.now(tz=UTC8).isoformat(), + role_name=_role_value(role, ("name", "nickname", "nickName")), + role_uid=_role_value( + role, + ("roleId", "role_id", "uid", "gameUid", "game_uid"), + ), + server=_role_value( + role, + ("serverName", "server", "serverId", "server_id", "channelName"), + ), + source=source, + ) + + +def _coerce_payload( + payload: object, *, platform: str, game: str +) -> Mapping[str, Any]: + if isinstance(payload, Mapping): + root = payload + else: + if isinstance(payload, (bytes, bytearray)): + text = bytes(payload).decode("utf-8", errors="replace").strip() + elif isinstance(payload, str): + text = payload.strip() + else: + text = "" + if not text: + raise ActivityResponseLimitedError( + f"{platform}{game}接口返回空响应,可能触发风控或维护" + ) + try: + decoded = json.loads(text) + except json.JSONDecodeError as exc: + raise ActivityResponseLimitedError( + f"{platform}{game}接口返回非 JSON,可能触发风控" + ) from exc + if not isinstance(decoded, Mapping): + raise ActivityResponseUnavailableError( + f"{platform}{game}接口返回的数据结构无法识别" + ) + root = decoded + + for key in ("retcode", "code"): + code = _as_code(root.get(key)) + if code is None or code == 0: + continue + if abs(code) in _RISK_CODES: + raise ActivityResponseLimitedError( + f"{platform}{game}接口受到上游风控(业务码 {code})" + ) + raise ActivityResponseUnavailableError( + f"{platform}{game}接口返回业务失败(业务码 {code})" + ) + return root + + +def _parse_skland_arknights( + payload: Mapping[str, Any], + account_uid: str, + account_name: str, + role: ActivityRole, +) -> CommunityActivitySnapshot: + root = _unwrap_data(payload) + routine = root.get("routine") + routine = routine if isinstance(routine, Mapping) else {} + daily_value = routine.get("daily") or root.get("daily") + weekly_value = routine.get("weekly") or root.get("weekly") + items = _task_items(daily_value, default_name="日常活跃度") + items.extend(_mark_period( + _task_items(weekly_value, default_name="每周活跃度"), "weekly" + )) + + status = root.get("status") + status = status if isinstance(status, Mapping) else {} + ap_value = status.get("ap") or root.get("ap") + if isinstance(ap_value, Mapping): + current = _first_int(ap_value, ("current", "cur")) + maximum = _first_int(ap_value, ("max", "total")) + current_ts = _as_int(root.get("currentTs")) + last_add_ts = _as_int(ap_value.get("lastApAddTime")) + if ( + current is not None + and maximum is not None + and current_ts is not None + and last_add_ts is not None + ): + # Widget 返回的是上次恢复时的基础值,按每 6 分钟恢复 1 点补齐当前值。 + current = min(maximum, current + max(0, (current_ts - last_add_ts) // 360)) + ap_value = {"current": current, "total": maximum} + resource_values = ( + ("理智", ap_value), + ("公开招募", status.get("recruit") or root.get("recruit")), + ("无人机", status.get("drone") or root.get("drone")), + ("经验", status.get("exp") or root.get("exp")), + ("干员疲劳", status.get("fatigue") or root.get("fatigue")), + ) + resources = [] + for name, value in resource_values: + item = _resource_item(name, value) + if item is not None: + resources.append(item) + return _build_snapshot( + account_uid=account_uid, + account_name=account_name, + platform="森空岛", + game="明日方舟", + items=items, + resources=resources, + role=role, + source="/api/v1/game/player/info", + ) + + +def _parse_skland_endfield( + payload: Mapping[str, Any], + account_uid: str, + account_name: str, + role: ActivityRole, +) -> CommunityActivitySnapshot: + data_root = _unwrap_data(payload) + sources: list[Mapping[str, Any]] = [data_root] + pending = [data_root] + seen = {id(data_root)} + while pending: + current = pending.pop() + for key in ("detail", "card", "data", "activity", "daily"): + nested = current.get(key) + if isinstance(nested, Mapping) and id(nested) not in seen: + seen.add(id(nested)) + sources.append(nested) + pending.append(nested) + + def first_value(*names: str) -> object: + for source in sources: + for name in names: + if source.get(name) is not None: + return source[name] + return None + + daily_value = first_value( + "dailyMission", + "daily_mission", + "dailyTask", + "dailyActivity", + ) + weekly_value = first_value( + "weeklyMission", + "weekly_mission", + "weeklyTask", + "weeklyActivity", + ) + items = _task_items(daily_value, default_name="日常活跃度") + items.extend( + _mark_period( + _task_items(weekly_value, default_name="每周任务"), "weekly" + ) + ) + for value, name in ( + (first_value("bpSystem", "bp_system", "battlePass"), "通行证"), + ( + first_value("seekSuspicion", "seek_suspicion", "roguelike"), + "蚀像寻遗", + ), + ): + item = _activity_item(name, value) + if item is not None: + items.append(dict(item, period="weekly")) + + resource_values = ( + ("体力", first_value("stamina", "ap", "sanity", "dungeon")), + ) + resources = [] + for name, value in resource_values: + item = _resource_item(name, value) + if item is not None: + resources.append(item) + return _build_snapshot( + account_uid=account_uid, + account_name=account_name, + platform="森空岛", + game="终末地", + items=items, + resources=resources, + role=role, + source="/api/v1/game/endfield/card/detail", + ) + + +def _parse_miyoushe_genshin( + payload: Mapping[str, Any], + account_uid: str, + account_name: str, + role: ActivityRole, +) -> CommunityActivitySnapshot: + root = _unwrap_data(payload) + daily_value = root.get("daily") or root.get("daily_task") + daily = _activity_item("每日委托", daily_value) + if daily is None: + daily = _activity_item("每日委托", root) + items = [daily] if daily is not None else [] + resources = [] + resource_values = ( + ("原粹树脂", {"current": root.get("current_resin"), "total": root.get("max_resin")}), + ("洞天宝钱", {"current": root.get("current_home_coin"), "total": root.get("max_home_coin")}), + ("探索派遣", {"current": root.get("current_expedition_num"), "total": root.get("total_expedition_num")}), + ) + for name, value in resource_values: + item = _resource_item(name, value) + if item is not None: + resources.append(item) + return _build_snapshot( + account_uid=account_uid, + account_name=account_name, + platform="米游社", + game="原神", + items=items, + resources=resources, + role=role, + source="/game_record/app/genshin/api/dailyNote", + ) + + +def _parse_miyoushe_hsr( + payload: Mapping[str, Any], + account_uid: str, + account_name: str, + role: ActivityRole, +) -> CommunityActivitySnapshot: + root = _unwrap_data(payload) + daily_value = root.get("daily") or root.get("daily_task") + daily = _activity_item("每日实训", daily_value) + if daily is None: + daily = _activity_item( + "每日实训", + { + "current": root.get("current_training_score"), + "total": root.get("max_training_score"), + }, + ) + items = [daily] if daily is not None else [] + resources = [] + resource_values = ( + ("开拓力", {"current": root.get("current_stamina"), "total": root.get("max_stamina")}), + ("储备开拓力", {"current": root.get("current_reserve_stamina"), "total": root.get("max_reserve_stamina")}), + ("探索派遣", {"current": root.get("current_expedition_num"), "total": root.get("total_expedition_num")}), + ) + for name, value in resource_values: + item = _resource_item(name, value) + if item is not None: + resources.append(item) + return _build_snapshot( + account_uid=account_uid, + account_name=account_name, + platform="米游社", + game="星穹铁道", + items=items, + resources=resources, + role=role, + source="/game_record/app/hkrpg/api/note", + ) + + +@dataclass(frozen=True) +class ActivityGameDefinition: + platform: str + game: str + parser: ActivityParser | None + source: str = "" + limited_reason: str = "" + + +ACTIVITY_GAME_DEFINITIONS = ( + ActivityGameDefinition( + platform="森空岛", + game="明日方舟", + parser=_parse_skland_arknights, + source="/api/v1/game/player/info", + ), + ActivityGameDefinition( + platform="森空岛", + game="终末地", + parser=_parse_skland_endfield, + source="/api/v1/game/endfield/card/detail", + ), + ActivityGameDefinition( + platform="米游社", + game="原神", + parser=_parse_miyoushe_genshin, + source="/game_record/app/genshin/api/dailyNote", + ), + ActivityGameDefinition( + platform="米游社", + game="星穹铁道", + parser=_parse_miyoushe_hsr, + source="/game_record/app/hkrpg/api/note", + ), + ActivityGameDefinition( + platform="米游社", + game="绝区零", + parser=None, + source="", + limited_reason=( + "参考项目未确认绝区零稳定实时便笺接口,当前仅展示受限状态,不请求未经确认的字段" + ), + ), +) + + +def _definition_for(platform: str, game: str) -> ActivityGameDefinition | None: + return next( + ( + definition + for definition in ACTIVITY_GAME_DEFINITIONS + if definition.platform == platform and definition.game == game + ), + None, + ) + + +def parse_activity_snapshot( + payload: object, + *, + account_uid: str, + account_name: str, + platform: str, + game: str, + role: ActivityRole = None, +) -> CommunityActivitySnapshot: + """将单个游戏的脱敏响应转换为稳定的活跃度快照。 + + Args: + payload: 已由调用方读取的 JSON 对象、JSON 字符串或响应正文。 + account_uid: 游戏社区账号组 UUID。 + account_name: 游戏社区账号组名称。 + platform: 社区平台名称。 + game: 游戏名称。 + role: 当前查询角色的脱敏字段。 + + Returns: + 单个游戏的活跃度快照;不支持或受限状态也以快照返回。 + """ + definition = _definition_for(platform, game) + if definition is None: + return _failed_snapshot( + account_uid=account_uid, + account_name=account_name, + platform=platform, + game=game, + status="unavailable", + reason=f"{platform}{game}尚未登记活跃度解析器", + role=role, + ) + if definition.parser is None: + return _failed_snapshot( + account_uid=account_uid, + account_name=account_name, + platform=platform, + game=game, + status="limited", + reason=definition.limited_reason, + role=role, + source=definition.source, + ) + + try: + root = _coerce_payload(payload, platform=platform, game=game) + return definition.parser(root, account_uid, account_name, role) + except ActivityResponseLimitedError as exc: + return _failed_snapshot( + account_uid=account_uid, + account_name=account_name, + platform=platform, + game=game, + status="limited", + reason=str(exc), + role=role, + source=definition.source, + ) + except ActivityResponseUnavailableError as exc: + return _failed_snapshot( + account_uid=account_uid, + account_name=account_name, + platform=platform, + game=game, + status="unavailable", + reason=str(exc), + role=role, + source=definition.source, + ) + except ValueError: + return _failed_snapshot( + account_uid=account_uid, + account_name=account_name, + platform=platform, + game=game, + status="failed", + reason=f"{platform}{game}活跃度响应解析失败", + role=role, + source=definition.source, + ) + except Exception: + return _failed_snapshot( + account_uid=account_uid, + account_name=account_name, + platform=platform, + game=game, + status="failed", + reason=f"{platform}{game}活跃度处理失败", + role=role, + source=definition.source, + ) diff --git a/app/tools/game_sign_contract.py b/app/tools/game_sign_contract.py new file mode 100644 index 000000000..9b14feb8f --- /dev/null +++ b/app/tools/game_sign_contract.py @@ -0,0 +1,164 @@ +# 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、前端或通知渠道。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal, Mapping + + +CredentialState = Literal["empty", "valid", "incomplete", "invalid"] +ActivityState = Literal[ + "success", + "empty", + "limited", + "unavailable", + "failed", +] + + +class CommunitySignInProgressError(RuntimeError): + """游戏社区签到已在执行。""" + + +@dataclass(frozen=True) +class CredentialStatus: + """凭据本地校验摘要,不包含凭据值。""" + + token_field: str + state: CredentialState + fields: tuple[str, ...] = () + reason: str = "" + + @property + def configured(self) -> bool: + """返回是否存在非空凭据。""" + + return self.state != "empty" + + @property + def locally_valid(self) -> bool: + """返回是否通过本地格式校验,不表示上游认证成功。""" + + return self.state == "valid" + + +@dataclass(frozen=True) +class CommunitySignResult: + """签到结果的领域表示,同时兼容旧版字典字段。""" + + account: str + account_uid: str + game: str + platform: str + status: str + reward: str = "" + reason: str = "" + completed: bool = False + notification_only: bool = False + + @classmethod + def from_legacy( + cls, + item: Mapping[str, Any], + *, + fallback_account: str = "未知用户", + fallback_uid: str = "", + ) -> "CommunitySignResult": + """从既有结果字典读取领域结果,不改变外部字段语义。""" + + account = str(item.get("account") or fallback_account) + if account == "未知用户": + account = fallback_account + return cls( + account=account, + account_uid=str(item.get("account_uid") or fallback_uid), + game=str(item.get("game") or ""), + platform=str(item.get("platform") or "未知"), + status=str(item.get("status") or "失败"), + reward=str(item.get("reward") or ""), + reason=str(item.get("reason") or ""), + completed=bool(item.get("_completed")), + notification_only=bool(item.get("_notification_only")), + ) + + def to_legacy(self) -> dict[str, Any]: + """转换回旧版签到结果字典,供现有 API 与通知继续消费。""" + + result: dict[str, Any] = { + "account": self.account, + "account_uid": self.account_uid, + "game": self.game, + "platform": self.platform, + "status": self.status, + "reward": self.reward, + "reason": self.reason, + } + if self.completed: + result["_completed"] = True + if self.notification_only: + result["_notification_only"] = True + return result + + +@dataclass(frozen=True) +class CommunityActivitySnapshot: + """日常活跃度领域结果,供后续只读查询模块复用。""" + + account: str + account_uid: str + game: str + platform: str + status: ActivityState + completed: int | None = None + target: int | None = None + tasks: tuple[Mapping[str, Any], ...] = () + resources: tuple[Mapping[str, Any], ...] = () + reason: str = "" + updated_at: str = "" + role_name: str = "" + role_uid: str = "" + server: str = "" + source: str = "" + + def to_dict(self) -> dict[str, Any]: + """转换为与 API 无关的普通字典。""" + + return { + "account": self.account, + "account_uid": self.account_uid, + "game": self.game, + "platform": self.platform, + "status": self.status, + "completed": self.completed, + "target": self.target, + "tasks": [dict(task) for task in self.tasks], + "resources": [dict(resource) for resource in self.resources], + "reason": self.reason, + "updated_at": self.updated_at, + "role_name": self.role_name, + "role_uid": self.role_uid, + "server": self.server, + "source": self.source, + } diff --git a/app/tools/game_sign_credentials.py b/app/tools/game_sign_credentials.py new file mode 100644 index 000000000..098d599cd --- /dev/null +++ b/app/tools/game_sign_credentials.py @@ -0,0 +1,186 @@ +# 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 + + +"""游戏社区凭据解析和本地校验边界。 + +该模块只保存格式、字段名和本地校验状态,不保存、截断、脱敏或改写原始 Token。 +平台适配器仍负责真实上游认证、刷新和网络请求。 +""" + +import json +from collections.abc import Callable +from dataclasses import dataclass +from typing import Literal + +from .game_sign_contract import CredentialStatus + + +CredentialFormat = Literal["empty", "opaque", "cookie", "json"] + + +@dataclass(frozen=True) +class GameSignCredentialInfo: + """不包含凭据值的解析摘要,供编排层判断凭据形态。""" + + token_field: str + format: CredentialFormat + fields: tuple[str, ...] = () + configured: bool = False + + def has_any(self, *field_names: str) -> bool: + """判断 JSON/Cookie 是否包含任一指定字段名。""" + + return any(field_name in self.fields for field_name in field_names) + + +def _cookie_field_names(raw_token: str) -> tuple[str, ...]: + fields = set() + for item in raw_token.split(";"): + key, separator, _ = item.strip().partition("=") + if separator and key: + fields.add(key.strip()) + return tuple(sorted(fields)) + + +def _json_field_names(raw_token: str) -> tuple[str, ...] | None: + if not raw_token.startswith("{"): + return None + try: + payload = json.loads(raw_token) + except json.JSONDecodeError: + return None + if not isinstance(payload, dict): + return None + return tuple(sorted(str(key) for key in payload if isinstance(key, str))) + + +def parse_game_sign_credential( + token_field: str, raw_token: object +) -> GameSignCredentialInfo: + """解析凭据外形,原始值始终由调用方持有且不会被本模块写回。""" + + text = raw_token if isinstance(raw_token, str) else str(raw_token or "") + text = text.strip() + if not text: + return GameSignCredentialInfo(token_field, "empty") + + if token_field == "MiyousheToken": + fields = _cookie_field_names(text) + if fields: + return GameSignCredentialInfo( + token_field, "cookie", fields, configured=True + ) + + fields = _json_field_names(text) + if fields is not None: + return GameSignCredentialInfo(token_field, "json", fields, configured=True) + + # 保留旧版纯 Token、损坏 JSON 和未知平台 Token 的“已配置”语义。 + return GameSignCredentialInfo(token_field, "opaque", configured=True) + + +def is_game_sign_credential_configured(token_field: str, raw_token: object) -> bool: + """判断凭据是否非空,不对平台有效性作结论。""" + + return parse_game_sign_credential(token_field, raw_token).configured + + +CredentialValidator = Callable[[str], object] + + +def _validate_skland(raw_token: str) -> object: + from .skland import validate_skland_credential + + return validate_skland_credential(raw_token) + + +def _validate_miyoushe(raw_token: str) -> object: + from .miyoushe import validate_miyoushe_cookie + + return validate_miyoushe_cookie(raw_token) + + +def _validate_kuro(raw_token: str) -> object: + from .kuro import validate_kuro_credential + + return validate_kuro_credential(raw_token) + + +def _validate_taygedo(raw_token: str) -> object: + from .taygedo import validate_taygedo_credential + + return validate_taygedo_credential(raw_token) + + +_CREDENTIAL_VALIDATORS: dict[str, CredentialValidator] = { + "SklandToken": _validate_skland, + "MiyousheToken": _validate_miyoushe, + "KuroToken": _validate_kuro, + "TaygedoToken": _validate_taygedo, +} + + +def _credential_error_state(reason: str) -> Literal["incomplete", "invalid"]: + if any(marker in reason for marker in ("缺少", "不能为空", "必须", "不应")): + return "incomplete" + return "invalid" + + +def validate_game_sign_credential( + token_field: str, raw_token: object +) -> CredentialStatus: + """执行提供方本地凭据校验,不代表上游 Token 仍然有效。""" + + info = parse_game_sign_credential(token_field, raw_token) + if not info.configured: + return CredentialStatus(token_field, "empty", info.fields) + + validator = _CREDENTIAL_VALIDATORS.get(token_field) + if validator is None: + return CredentialStatus(token_field, "valid", info.fields) + + text = raw_token if isinstance(raw_token, str) else str(raw_token or "") + try: + validator(text) + except ValueError as exc: + reason = str(exc).strip() or "凭据格式无效" + return CredentialStatus( + token_field, + _credential_error_state(reason), + info.fields, + reason, + ) + except Exception: + return CredentialStatus( + token_field, + "invalid", + info.fields, + "凭据本地校验失败", + ) + return CredentialStatus(token_field, "valid", info.fields) + + +# 新代码使用社区凭据语义;旧名称继续作为历史调用兼容入口。 +CommunityCredentialInfo = GameSignCredentialInfo +parse_community_credential = parse_game_sign_credential +is_community_credential_configured = is_game_sign_credential_configured +validate_community_credential = validate_game_sign_credential diff --git a/app/tools/game_sign_notify.py b/app/tools/game_sign_notify.py index 44841acc5..122bfc73b 100644 --- a/app/tools/game_sign_notify.py +++ b/app/tools/game_sign_notify.py @@ -28,7 +28,7 @@ from app.services import Notify from app.utils.logger import get_logger -logger = get_logger("游戏签到通知") +logger = get_logger("游戏社区通知") NOTIFICATION_SEND_ATTEMPTS = 2 NOTIFICATION_RETRY_DELAY_SECONDS = 1 @@ -221,7 +221,7 @@ async def _send_notification_channel( async def push_game_sign_notification(results: list[dict]) -> list[str]: - """推送手动或启动时触发的游戏签到结果通知。""" + """推送手动或启动时触发的游戏社区结果通知。""" results = _notification_results(results) if not results: return [] diff --git a/app/tools/kuro.py b/app/tools/kuro.py index dec59fec2..45f0bfd7e 100644 --- a/app/tools/kuro.py +++ b/app/tools/kuro.py @@ -33,6 +33,7 @@ import asyncio import httpx from datetime import datetime +from typing import Any from app.core import Config @@ -40,7 +41,7 @@ from app.utils.logger import get_logger from app.utils.security import format_exception_reason -logger = get_logger("库街区签到任务") +logger = get_logger("库街区社区") def _log_kuro_exception(stage: str, error: Exception) -> str: @@ -68,6 +69,17 @@ def _safe_json(response: httpx.Response) -> dict: try: data = response.json() except ValueError as exc: + try: + response_preview = response.text[:512].lower() + except Exception: + response_preview = "" + if any( + marker in response_preview + for marker in ("captcha", "geetest", "安全验证", "风控", "challenge") + ): + raise _KuroRiskError( + "库街区请求触发风控或安全验证,请稍后重试" + ) from exc raise ValueError( f"库街区返回了非 JSON 响应(HTTP {response.status_code}),疑似风控或服务维护" ) from exc @@ -76,6 +88,229 @@ def _safe_json(response: httpx.Response) -> dict: return data +def _is_kuro_code(value: Any, expected: int) -> bool: + """兼容库街区接口以数字或字符串返回业务码。""" + + return str(value).strip() == str(expected) + + +class _KuroAuthError(ValueError): + """库街区凭据或用户会话已不能继续使用。""" + + +class _KuroRiskError(ValueError): + """库街区请求被风控或安全校验拦截。""" + + +_KURO_RISK_MARKERS = ( + "风控", + "安全验证", + "短信验证", + "需要验证", + "频繁", + "captcha", + "security", + "risk", +) + + +def _kuro_device_identifiers(token: str) -> tuple[str, str]: + """从同一 Token 派生稳定的设备码和请求标识,不改变配置存储格式。""" + + return ( + str(uuid.uuid3(uuid.NAMESPACE_URL, f"auto-mas:kuro:devcode:{token}")), + str(uuid.uuid3(uuid.NAMESPACE_URL, f"auto-mas:kuro:distinct:{token}")), + ) + + +def _kuro_request_headers( + template: dict[str, str], + token: str, + dev_code: str, + distinct_id: str = "", +) -> dict[str, str]: + """为一次请求注入同一组运行期认证和设备字段。""" + + headers = template.copy() + headers.update({"token": token, "devcode": dev_code}) + if distinct_id: + headers["distinct_id"] = distinct_id + return headers + + +def _raise_kuro_response_error( + response: httpx.Response, + data: dict, + stage: str, + *, + allow_already_signed: bool = False, +) -> None: + """将库街区业务码转换为可区分的领域异常。""" + + code = data.get("code") + message = _kuro_response_message(data, f"HTTP {response.status_code}") + lowered_message = message.lower() + if response.status_code in (403, 429) and ( + response.status_code == 429 + or any(marker in lowered_message for marker in _KURO_RISK_MARKERS) + ): + raise _KuroRiskError( + f"库街区请求触发频率限制或风控(HTTP {response.status_code}),请稍后重试" + ) + if response.status_code in (401, 403): + raise _KuroAuthError("库街区 Token 已失效,请重新获取 Token") + if not response.is_success: + raise ValueError( + f"{stage}失败(HTTP {response.status_code},code={code}):{message}" + ) + if _is_kuro_code(code, 200) or ( + allow_already_signed and _is_kuro_code(code, 1511) + ): + return + + if _is_kuro_code(code, 220): + if any(marker in lowered_message for marker in _KURO_RISK_MARKERS): + raise _KuroRiskError( + "库街区请求触发风控或安全验证(code=220),请稍后重试" + ) + raise _KuroAuthError( + "库街区 Token 已失效(code=220);库街区 APP 端再次登录可能使旧 Token 失效,请重新获取 Token" + ) + if _is_kuro_code(code, 1513): + raise _KuroAuthError( + "库街区用户信息异常(code=1513),请重新获取 Token" + ) + raise ValueError( + f"{stage}失败(HTTP {response.status_code},code={code}):{message}" + ) + + +def _kuro_result_status(error: Exception) -> str: + """将库街区领域异常映射为现有通知结果状态。""" + + return "风控" if isinstance(error, _KuroRiskError) else "失败" + + +def _kuro_response_message(data: dict, fallback: str) -> str: + """读取接口给出的短错误信息,不回显完整响应正文。""" + + message = data.get("msg", data.get("message", fallback)) + return str(message or "").strip() or fallback + + +def _extract_kuro_role_records(value: Any) -> list[dict]: + """兼容角色列表的数组及已确认的常见包装字段。""" + + if value is None: + return [] + if isinstance(value, list): + records: list[dict] = [] + for item in value: + if isinstance(item, (dict, list)): + records.extend(_extract_kuro_role_records(item)) + return records + if not isinstance(value, dict): + raise ValueError("库街区角色列表响应格式无效") + + if any( + value.get(key) not in (None, "") + for key in ("roleId", "role_id", "roleID") + ): + return [value] + + for key in ("roles", "roleList", "role_list", "list", "data", "roleInfo", "role"): + if key in value: + return _extract_kuro_role_records(value[key]) + return [] + + +def _normalise_kuro_role( + raw_role: dict, + requested_game_id: str, +) -> dict | None: + """按本次请求的游戏 ID 归一化角色,拒绝跨游戏猜测。""" + + reported_game_id = str( + raw_role.get("gameId") + or raw_role.get("game_id") + or raw_role.get("gameID") + or "" + ).strip() + if reported_game_id and reported_game_id != requested_game_id: + return None + + game_id = reported_game_id or requested_game_id + if game_id not in GAME_CONFIG: + return None + + role_id = str( + raw_role.get("roleId") + or raw_role.get("role_id") + or raw_role.get("roleID") + or "" + ).strip() + if not role_id: + return None + + role = dict(raw_role) + role["gameId"] = game_id + role["gameName"] = GAME_CONFIG[game_id]["name"] + role["roleId"] = role_id + for source, target in ( + ("server_id", "serverId"), + ("server_name", "serverName"), + ("role_name", "roleName"), + ): + if target not in role and role.get(source) not in (None, ""): + role[target] = role[source] + for key in ("serverId", "serverName", "roleName"): + if role.get(key) not in (None, ""): + role[key] = str(role[key]).strip() + return role + + +def _kuro_game_failure_results( + account: str, + reason: str, + game_ids: tuple[str, ...] | None = None, + status: str = "失败", +) -> list[dict]: + """为有明确游戏范围的失败生成逐游戏结果。""" + + selected_game_ids = game_ids or tuple(GAME_CONFIG) + return [ + { + "account": f"{account}/{GAME_CONFIG[game_id]['name']}", + "game": GAME_CONFIG[game_id]["name"], + "platform": "库街区", + "status": status, + "reward": "", + "reason": reason, + } + for game_id in selected_game_ids + if game_id in GAME_CONFIG + ] + + +def _kuro_role_failure_result( + account: str, + game_name: str, + reason: str, + *, + status: str = "失败", +) -> dict: + """构造单个库街区游戏的失败结果。""" + + return { + "account": account, + "game": game_name, + "platform": "库街区", + "status": status, + "reward": "", + "reason": reason, + } + + # ==================== 常量 ==================== # API 端点 @@ -91,28 +326,53 @@ def _safe_json(response: httpx.Response) -> dict: # 请求头模板 BBS_HEADERS = { - "User-Agent": "Kuro/2.2.0 (Android;13) okhttp/4.11.0", + "Accept": "*/*", + "Accept-Language": "zh-CN,zh-Hans;q=0.9", + "User-Agent": "okhttp/3.11.0", "Host": "api.kurobbs.com", "Connection": "keep-alive", - "Accept-Encoding": "gzip", + "Accept-Encoding": "gzip, deflate, br", "Content-Type": "application/x-www-form-urlencoded", + "source": "android", + "osversion": "Android", + "model": "2211133C", + "version": "2.2.0", + "versioncode": "2200", + "channelid": "1", + "lang": "zh-Hans", + "countrycode": "CN", } 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": "", + **BBS_HEADERS, + "Accept": "application/json, text/plain, */*", + "Origin": "https://web-static.kurobbs.com", + "X-Requested-With": "com.kurogame.kjq", + "Sec-Fetch-Site": "same-site", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Dest": "empty", + "User-Agent": ( + "Mozilla/5.0 (Linux; Android 13; 2211133C Build/TKQ1.220905.001; wv) " + "AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/114.0.5735.131 " + "Mobile Safari/537.36 Kuro/1.0.9 KuroGameBox/1.0.9" + ), } +def validate_kuro_credential(token: str) -> str: + """校验库街区 Token 的本地非空条件,不探测上游有效期。""" + + value = str(token or "").strip() + if not value: + raise ValueError("库街区 Token 为空") + return value + + # ==================== 签到主流程 ==================== async def kuro_sign_in(token: str, proxy: str | None = None) -> list[dict]: - """库街区游戏签到 + """库街区社区签到 Args: token: 库街区 JWT Token 字符串 @@ -125,20 +385,10 @@ async def kuro_sign_in(token: str, proxy: str | None = None) -> list[dict]: if not token or not token.strip(): logger.warning("库街区 Token 为空") - return [ - { - "account": "未知/库街区", - "game": "库街区", - "platform": "库街区", - "status": "失败", - "reward": "", - "reason": "Token 为空", - } - ] + return _kuro_game_failure_results("未知/库街区", "Token 为空") token = token.strip() - dev_code = str(uuid.uuid4()) - distinct_id = str(uuid.uuid4()) + dev_code, distinct_id = _kuro_device_identifiers(token) resolved_proxy = proxy if proxy is not None else Config.proxy async with httpx.AsyncClient(proxy=resolved_proxy, trust_env=False) as client: @@ -147,16 +397,11 @@ async def kuro_sign_in(token: str, proxy: str | None = None) -> list[dict]: user_info = await _get_user_info(token, dev_code, distinct_id, client) except Exception as e: reason = _log_kuro_exception("获取库街区用户信息失败", e) - return [ - { - "account": "未知/库街区", - "game": "库街区", - "platform": "库街区", - "status": "失败", - "reward": "", - "reason": reason, - } - ] + return _kuro_game_failure_results( + "未知/库街区", + reason, + status=_kuro_result_status(e), + ) user_id = user_info.get("userId", "") nick_name = user_info.get("nickName", user_id) @@ -166,25 +411,40 @@ async def kuro_sign_in(token: str, proxy: str | None = None) -> list[dict]: roles = await _get_role_list(token, dev_code, distinct_id, user_id, client) except Exception as e: reason = _log_kuro_exception("获取库街区游戏角色失败", e) - return [ - { - "account": f"{nick_name}/库街区", - "game": "库街区", - "platform": "库街区", - "status": "失败", - "reward": "", - "reason": reason, - } - ] + return _kuro_game_failure_results( + f"{nick_name}/库街区", + reason, + status=_kuro_result_status(e), + ) + + query_failures = [ + role + for role in roles + if role.get("_queryError") and str(role.get("gameId", "")) in GAME_CONFIG + ] + for failure in query_failures: + game_id = str(failure["gameId"]) + game_name = GAME_CONFIG[game_id]["name"] + results.append({ + "account": f"{nick_name}/{game_name}", + "game": game_name, + "platform": "库街区", + "status": "失败", + "reward": "", + "reason": str(failure["_queryError"]), + }) signable_roles = [ - role for role in roles if str(role.get("gameId", "")) in GAME_CONFIG + role + for role in roles + if not role.get("_queryError") + and str(role.get("gameId", "")) in GAME_CONFIG ] if not signable_roles: logger.warning("未找到库街区绑定的游戏角色") return results - # 逐游戏签到 + # 逐游戏执行社区签到 for index, role in enumerate(signable_roles): game_id = str(role.get("gameId", "")) server_id = role.get("serverId", "") @@ -211,22 +471,51 @@ async def kuro_sign_in(token: str, proxy: str | None = None) -> list[dict]: account, game_cfg, client, + distinct_id=distinct_id, ) results.append(sign_result) + except (_KuroAuthError, _KuroRiskError) as e: + reason = _log_kuro_exception( + f"{game_cfg['name']}签到失败", + e, + ) + results.append( + _kuro_role_failure_result( + account, + game_cfg["name"], + reason, + status=_kuro_result_status(e), + ) + ) + # 220/1513/风控已经说明当前会话不可继续,停止撞击剩余角色。 + for remaining_role in signable_roles[index + 1 :]: + remaining_game_id = str(remaining_role.get("gameId", "")) + remaining_cfg = GAME_CONFIG.get(remaining_game_id) + if remaining_cfg is None: + continue + remaining_role_id = remaining_role.get("roleId", "") + remaining_account = ( + f"{nick_name}/{remaining_role.get('roleName', '')}" + f"({remaining_role_id})" + if remaining_role_id + else f"{nick_name}/库街区" + ) + results.append( + _kuro_role_failure_result( + remaining_account, + remaining_cfg["name"], + reason, + status=_kuro_result_status(e), + ) + ) + break except Exception as e: reason = _log_kuro_exception( f"{game_cfg['name']}签到失败", e, ) results.append( - { - "account": account, - "game": game_cfg["name"], - "platform": "库街区", - "status": "失败", - "reward": "", - "reason": reason, - } + _kuro_role_failure_result(account, game_cfg["name"], reason) ) if index < len(signable_roles) - 1: @@ -242,10 +531,7 @@ async def _get_user_info( client: httpx.AsyncClient, ) -> dict: """获取用户信息""" - headers = BBS_HEADERS.copy() - headers["token"] = token - headers["devcode"] = dev_code - headers["distinct_id"] = distinct_id + headers = _kuro_request_headers(BBS_HEADERS, token, dev_code, distinct_id) response = await client.post( USER_INFO_URL, @@ -255,13 +541,25 @@ async def _get_user_info( ) rsp = _safe_json(response) - if rsp.get("code") != 200: - raise ValueError(f"获取用户信息失败: {rsp.get('msg', rsp.get('message', ''))}") + _raise_kuro_response_error(response, rsp, "获取用户信息") data = rsp.get("data", {}) if not isinstance(data, dict): raise ValueError("库街区用户信息响应格式无效") - return data + profile = data.get("mine") + if not isinstance(profile, dict): + profile = data + user_id = profile.get("userId") or data.get("userId") + if user_id in (None, ""): + raise ValueError("库街区用户信息缺少 userId") + nick_name = ( + profile.get("nickName") + or profile.get("userName") + or data.get("nickName") + or data.get("userName") + or user_id + ) + return {"userId": str(user_id).strip(), "nickName": str(nick_name).strip()} async def _get_role_list( @@ -272,34 +570,33 @@ async def _get_role_list( client: httpx.AsyncClient, ) -> list[dict]: """获取游戏角色列表""" - headers = BBS_HEADERS.copy() - headers["token"] = token - headers["devcode"] = dev_code - headers["distinct_id"] = distinct_id + headers = _kuro_request_headers(BBS_HEADERS, token, dev_code, distinct_id) all_roles = [] for game_id in GAME_CONFIG: - response = await client.post( - ROLE_LIST_URL, - headers=headers, - data=f"gameId={game_id}&userId={user_id}", - timeout=30.0, - ) - rsp = _safe_json(response) + game_name = GAME_CONFIG[game_id]["name"] + try: + response = await client.post( + ROLE_LIST_URL, + headers=headers, + data=f"gameId={game_id}&userId={user_id}", + timeout=30.0, + ) + rsp = _safe_json(response) - if rsp.get("code") != 200: - logger.warning(f"获取 gameId={game_id} 角色失败: {rsp.get('msg', '')}") - continue + _raise_kuro_response_error(response, rsp, f"获取{game_name}角色") - raw_roles = rsp.get("data", []) - if not isinstance(raw_roles, list): - raise ValueError(f"gameId={game_id} 角色列表响应格式无效") - for role in raw_roles: - if not isinstance(role, dict): - continue - role["gameId"] = game_id - all_roles.append(role) + raw_roles = _extract_kuro_role_records(rsp.get("data", [])) + for raw_role in raw_roles: + role = _normalise_kuro_role(raw_role, game_id) + if role is not None: + all_roles.append(role) + except (_KuroAuthError, _KuroRiskError): + raise + except Exception as e: + reason = _log_kuro_exception(f"获取{game_name}角色失败", e) + all_roles.append({"gameId": game_id, "_queryError": reason}) return all_roles @@ -314,14 +611,12 @@ async def _do_sign( account: str, game_cfg: dict, client: httpx.AsyncClient, + *, + distinct_id: str = "", ) -> 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) " - ) + headers = _kuro_request_headers(GAME_HEADERS, token, dev_code, distinct_id) # 库街区服务端按北京时间计月,本地时区可能不同,统一使用 UTC+8 req_month = datetime.now(tz=UTC8).strftime("%m") @@ -337,7 +632,14 @@ async def _do_sign( code = rsp.get("code", -1) - if code == 200: + _raise_kuro_response_error( + response, + rsp, + f"{game_cfg['name']}签到", + allow_already_signed=True, + ) + + if _is_kuro_code(code, 200): # 尝试获取奖励 reward = "" data = rsp.get("data", {}) @@ -355,7 +657,7 @@ async def _do_sign( "reward": reward, "reason": "", } - elif code == 1511: + elif _is_kuro_code(code, 1511): # 今日已签到 return { "account": account, @@ -366,13 +668,6 @@ async def _do_sign( "reason": "", } else: - message = rsp.get("msg", rsp.get("message", f"错误码 {code}")) + message = _kuro_response_message(rsp, f"错误码 {code}") logger.warning(f"{account} {game_cfg['name']} 签到失败: {message}") - return { - "account": account, - "game": game_cfg["name"], - "platform": "库街区", - "status": "失败", - "reward": "", - "reason": message, - } + return _kuro_role_failure_result(account, game_cfg["name"], message) diff --git a/app/tools/miyoushe.py b/app/tools/miyoushe.py index 21bf05b39..9aedd8d48 100644 --- a/app/tools/miyoushe.py +++ b/app/tools/miyoushe.py @@ -65,7 +65,7 @@ ) # DS 签名 Salt(对齐 MihoyoBBSTools) -SALT_WEB = "DlOUwIupfU6YespEUWDJmXtutuXV6owG" # web 端 salt(游戏签到 GET 请求) +SALT_WEB = "DlOUwIupfU6YespEUWDJmXtutuXV6owG" # web 端 salt(游戏社区 GET 请求) SALT_DATA = "t0qEgfub6cvueAPgR5m9aQWWVciEer7v" # 有 body/query 的请求(x6) # 验证码重试配置 @@ -160,7 +160,7 @@ def get(self, key, default=None): }, } -# 通用请求头(对齐 MihoyoBBSTools 游戏签到 headers) +# 通用请求头(对齐 MihoyoBBSTools 游戏社区 headers) BASE_HEADERS = { "Accept": "application/json, text/plain, */*", "Origin": "https://webstatic.mihoyo.com", @@ -258,10 +258,26 @@ def _build_cookie_str(cookies: Dict[str, str]) -> str: return "; ".join(f"{k}={v}" for k, v in cookies.items()) +def merge_miyoushe_cookie_update(cookie: str, updated_cookie: str) -> str: + """合并刷新结果,避免新增 Cookie 字段时丢失原始认证字段。""" + current = _parse_cookie(cookie) + refreshed = _parse_cookie(updated_cookie) + if not refreshed: + return cookie + if not current: + return updated_cookie + + merged = current.copy() + for key, value in refreshed.items(): + if value: + merged[key] = value + return _build_cookie_str(merged) + + def _generate_ds(body: str = "", query: str = "") -> str: """生成 DS (Dynamic Secret) 签名 - 对齐 MihoyoBBSTools:游戏签到 GET 请求使用 SALT_WEB, + 对齐 MihoyoBBSTools:游戏社区 GET 请求使用 SALT_WEB, POST 请求(有 body)使用 SALT_DATA。 Args: @@ -434,7 +450,7 @@ async def miyoushe_sign_in( *, on_credential_update: Callable[[str], Awaitable[None]] | None = None, ) -> list[dict]: - """米游社游戏签到 + """米游社社区签到 支持多种 cookie 认证策略: 1. cookie_token + UID → 直接使用 @@ -583,7 +599,7 @@ async def report_credential_update() -> None: await report_credential_update() return results - # 逐游戏签到 + # 逐游戏执行社区签到 for index, role in enumerate(roles): game_biz = role.get("game_biz", "") region = role.get("region", "") diff --git a/app/tools/miyoushe_qr.py b/app/tools/miyoushe_qr.py index 3d4cd3932..f7b9963c2 100644 --- a/app/tools/miyoushe_qr.py +++ b/app/tools/miyoushe_qr.py @@ -1,6 +1,10 @@ # AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software # Copyright © 2024-2025 DLmaster361 # Copyright © 2025-2026 AUTO-MAS Team +# +# GameToken API compatibility knowledge: +# nonebot-plugin-mystool Copyright (c) 2022 Ljzd-PRO (MIT License) +# https://github.com/Ljzd-PRO/nonebot-plugin-mystool # This file is part of AUTO-MAS. @@ -26,17 +30,18 @@ 可安全删除本文件及 app/api/qr_login.py、前端扫码按钮, 不会影响任何已有功能。 -扫码流程(Passport 模式,参考 thesadru/genshin.py): - 1. createQRLogin → POST 获取二维码 URL + ticket - 2. queryQRLoginStatus → POST 轮询状态,确认后从响应头或响应体获取 cookies - 3. cookies 中通常包含 cookie_token_v2、ltuid_v2 等 Passport 字段; - 保存时同时补充签到模块兼容的 cookie_token、stuid 等别名 +扫码流程(GameToken 优先,Passport 回退): + 1. GameToken/Passport 创建二维码,返回二维码 URL + ticket + 2. 轮询状态,GameToken 确认后兑换完整凭据,Passport 从响应头或响应体获取 cookies + 3. 保存时补充签到模块兼容的认证与 UID 别名 参考项目: - https://github.com/thesadru/genshin.py (2026-06 最新) """ +import json from http.cookies import CookieError, SimpleCookie +from urllib.parse import parse_qs, urlparse import httpx @@ -48,11 +53,23 @@ # ---- Passport QR 登录 API(对齐 genshin.py) ---- -CREATE_QRCODE_URL = ( - "https://passport-api.miyoushe.com/account/ma-cn-passport/web/createQRLogin" +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" +# ---- GameToken QR 登录 API(参考项目已确认的请求/响应形状) ---- +GAME_TOKEN_CREATE_URL = "https://hk4e-sdk.mihoyo.com/hk4e_cn/combo/panda/qrcode/fetch" +GAME_TOKEN_CHECK_URL = "https://hk4e-sdk.mihoyo.com/hk4e_cn/combo/panda/qrcode/query" +GAME_TOKEN_STOKEN_URL = ( + "https://api-takumi.mihoyo.com/account/ma-cn-session/app/getTokenByGameToken" ) -CHECK_QRCODE_URL = ( - "https://passport-api.miyoushe.com/account/ma-cn-passport/web/queryQRLoginStatus" +GAME_TOKEN_COOKIE_URL = ( + "https://api-takumi.mihoyo.com/auth/api/getCookieAccountInfoByGameToken" +) +GAME_TOKEN_APP_ID = "2" +_GAME_TOKEN_TICKET_PREFIX = "game-token:" +# Passport 扫码不保证下发 stoken;缺少时用 login_ticket 换取多类型 Token 补全, +# 只有拿到 stoken 才能走不受验证码限制的小组件接口查询活跃度。 +MULTI_TOKEN_URL = ( + "https://api-takumi.mihoyo.com/auth/api/getMultiTokenByLoginTicket" ) # ---- 请求头(对齐 genshin.py QRCODE_HEADERS) ---- @@ -98,6 +115,14 @@ "account_id", "account_id_v2", "login_uid", + # login_ticket 仅用于缺少 stoken 时换取 stoken,不参与签到请求。 + "login_ticket", +) + +_QR_V2_COOKIE_PAIRS = ( + ("cookie_token", "cookie_token_v2"), + ("stoken", "stoken_v2"), + ("ltoken", "ltoken_v2"), ) @@ -121,11 +146,109 @@ def _is_expired_message(message: object) -> bool: def _add_qr_cookie_aliases(cookie_parts: dict[str, str]) -> None: """补全签到模块使用的旧 Cookie 字段名,不覆盖服务端原值。""" + # 部分扫码响应只使用旧字段名,但值已经是 v2 格式;补出 v2 字段, + # 使后续便笺查询可以识别完整认证形态。 + for source, target in _QR_V2_COOKIE_PAIRS: + value = cookie_parts.get(source) + if ( + not cookie_parts.get(target) + and value + and value.startswith("v2_") + ): + cookie_parts[target] = value for source, target in _QR_COOKIE_ALIASES.items(): if not cookie_parts.get(target) and cookie_parts.get(source): cookie_parts[target] = cookie_parts[source] +async def _supplement_stoken( + cookie_parts: dict[str, str], proxy: str | None = None +) -> None: + """用 login_ticket 补全缺失的 stoken,失败时保持原有字段不变。 + + Passport 扫码返回的 Cookie 字段并不稳定:同一条链路有时下发 stoken, + 有时只给 cookie_token/ltoken。缺少 stoken 时活跃度查询只能退回实时便笺 + 并必然触发验证码风控,因此在这里做一次补全。 + + Args: + cookie_parts: 已从扫码响应解析出的 Cookie 字段,就地补全。 + proxy: 代理地址,与扫码请求保持一致。 + """ + login_ticket = cookie_parts.get("login_ticket") + uid = next( + ( + cookie_parts[key] + for key in ( + "stuid", + "stuid_v2", + "ltuid", + "ltuid_v2", + "account_id", + "account_id_v2", + "login_uid", + ) + if cookie_parts.get(key) + ), + "", + ) + if not login_ticket or not uid: + return + + try: + async with httpx.AsyncClient( + proxy=proxy or Config.proxy, + trust_env=False, + ) as client: + resp = await client.get( + MULTI_TOKEN_URL, + params={ + "login_ticket": login_ticket, + "token_types": "3", + "uid": uid, + }, + timeout=10.0, + ) + payload = resp.json() + except (httpx.HTTPError, OSError, ValueError) as e: + logger.warning( + format_exception_reason(e, stage="补全 stoken 请求失败") + ) + return + + if not isinstance(payload, dict) or payload.get("retcode") != 0: + logger.warning("补全 stoken 失败:login_ticket 可能已过期") + return + + data = payload.get("data") + token_list = data.get("list") if isinstance(data, dict) else None + if not isinstance(token_list, list): + return + for token_info in token_list: + if not isinstance(token_info, dict): + continue + name = token_info.get("name") + token = token_info.get("token") + if not isinstance(token, str) or not token: + continue + if name in ("stoken", "stoken_v2"): + cookie_parts.setdefault("stoken", token) + if name == "stoken_v2" or token.startswith("v2_"): + # v2 stoken 必须配套 mid,否则保存校验会直接拒绝。 + cookie_parts.setdefault("stoken_v2", token) + mid = token_info.get("mid") + if isinstance(mid, str) and mid: + cookie_parts.setdefault("mid", mid) + elif name in ("ltoken", "ltoken_v2"): + cookie_parts.setdefault("ltoken", token) + if name == "ltoken_v2": + cookie_parts.setdefault("ltoken_v2", token) + # 上游可能返回空列表,只有确实补到 stoken 才记成功,避免误导排查。 + if cookie_parts.get("stoken"): + logger.info("已通过 login_ticket 补全 stoken") + else: + logger.warning("补全 stoken 未获得可用 Token") + + def _has_qr_auth_cookie(cookie_parts: dict[str, str]) -> bool: return any( cookie_parts.get(key) @@ -155,10 +278,56 @@ def _qr_headers(device: str) -> dict: return headers +def _game_token_qr_data(payload: object) -> tuple[str, str] | None: + """从 GameToken 二维码响应中提取二维码 URL 和 ticket。""" + + if not isinstance(payload, dict): + return None + qr_url = str(payload.get("url") or "").strip() + if not qr_url: + return None + parsed = urlparse(qr_url) + path = parsed.path.lower() + if ( + parsed.scheme.lower() != "https" + or parsed.netloc.lower() != "hk4e-sdk.mihoyo.com" + or not ("/qrcode/" in path or path.endswith("/qrcode.html")) + ): + return None + + ticket = str(payload.get("ticket") or "").strip() + if not ticket: + ticket_values = parse_qs(parsed.query).get("ticket", []) + ticket = str(ticket_values[0] if ticket_values else "").strip() + if not ticket: + return None + return qr_url, ticket + + +async def _create_game_token_qr( + device: str, proxy: str | None = None +) -> tuple[str, str] | None: + """尝试创建参考项目使用的 GameToken 二维码。""" + + async with httpx.AsyncClient( + proxy=proxy or Config.proxy, + trust_env=False, + ) as client: + response = await client.post( + GAME_TOKEN_CREATE_URL, + json={"app_id": GAME_TOKEN_APP_ID, "device": device}, + timeout=15.0, + ) + payload = response.json() + if not isinstance(payload, dict) or payload.get("retcode") != 0: + return None + return _game_token_qr_data(payload.get("data")) + + async def create_qr_login(proxy: str | None = None) -> dict: - """创建米游社扫码登录二维码(Passport 模式) + """创建米游社扫码登录二维码(GameToken 优先,Passport 回退) - POST /createQRLogin(无需 body) + 优先使用已确认的 GameToken 链路;其不可用时继续使用 Passport。 Returns: {ticket, qr_url, device} 或 {error} @@ -167,6 +336,24 @@ async def create_qr_login(proxy: str | None = None) -> dict: device = str(uuid4()) + try: + game_token_qr = await _create_game_token_qr(device, proxy) + except (httpx.HTTPError, OSError, ValueError) as error: + logger.debug(f"GameToken QR 不可用,回退 Passport: {type(error).__name__}") + game_token_qr = None + except Exception as error: + logger.debug(f"GameToken QR 回退 Passport: {type(error).__name__}") + game_token_qr = None + + if game_token_qr is not None: + qr_url, game_token_ticket = game_token_qr + logger.info("GameToken QR 创建成功") + return { + "ticket": f"{_GAME_TOKEN_TICKET_PREFIX}{game_token_ticket}", + "qr_url": qr_url, + "device": device, + } + try: headers = _qr_headers(device) async with httpx.AsyncClient(proxy=proxy or Config.proxy) as client: @@ -217,6 +404,134 @@ async def create_qr_login(proxy: str | None = None) -> dict: return {"error": "创建二维码失败,请稍后重试"} +async def _check_game_token_qr_status( + ticket: str, device: str, proxy: str | None = None, +) -> dict: + """查询 GameToken 二维码并兑换为可保存的完整 Cookie。""" + + try: + async with httpx.AsyncClient( + proxy=proxy or Config.proxy, + trust_env=False, + ) as client: + response = await client.post( + GAME_TOKEN_CHECK_URL, + json={ + "app_id": GAME_TOKEN_APP_ID, + "device": device, + "ticket": ticket, + }, + timeout=30.0, + ) + data = response.json() + except (httpx.HTTPError, OSError) as error: + logger.warning( + format_exception_reason( + error, + stage="查询 GameToken 扫码状态网络请求失败", + ) + ) + return { + "status": "Error", + "error": "查询二维码状态网络请求失败,请检查网络或代理设置", + } + except ValueError: + logger.warning("解析 GameToken 扫码状态响应失败") + return {"status": "Error", "error": "二维码状态响应解析失败"} + except Exception: + logger.exception("查询 GameToken 扫码状态程序异常") + return {"status": "Error", "error": "查询二维码状态失败,请稍后重试"} + + if not isinstance(data, dict): + return {"status": "Error", "error": "二维码状态响应格式无效"} + + retcode = data.get("retcode", 0) + response_message = data.get("message") + if not isinstance(response_message, str): + response_message = "" + if retcode not in (0, "0"): + if str(retcode) == "-106" or _is_expired_message(response_message): + return { + "status": "Expired", + "message": response_message or QR_EXPIRED_MESSAGE, + } + return {"status": "Error", "error": response_message or "查询失败"} + + qr_data = data.get("data") + if not isinstance(qr_data, dict): + return {"status": "Error", "error": "二维码状态响应格式无效"} + + status = qr_data.get("stat") + if status in ("Init", "Created"): + return {"status": "Init"} + if status == "Scanned": + return {"status": "Scanned"} + if status in ("Expired", "Canceled"): + return { + "status": status, + "message": ( + QR_EXPIRED_MESSAGE + if status == "Expired" + else "登录已取消" + ), + } + + payload = qr_data.get("payload") + raw_payload = payload.get("raw") if isinstance(payload, dict) else None + if not isinstance(raw_payload, str) or not raw_payload.strip(): + return { + "status": "Error", + "error": "二维码确认响应缺少登录信息", + } + try: + login_payload = json.loads(raw_payload) + except (TypeError, ValueError): + return {"status": "Error", "error": "二维码登录信息格式无效"} + if not isinstance(login_payload, dict): + return {"status": "Error", "error": "二维码登录信息格式无效"} + + uid = str(login_payload.get("uid") or "").strip() + game_token = str(login_payload.get("token") or "").strip() + if not uid or not game_token: + return {"status": "Error", "error": "二维码确认响应缺少登录信息"} + + try: + exchanged = await exchange_stoken(game_token, uid, proxy) + except (httpx.HTTPError, OSError, ValueError) as error: + logger.warning( + format_exception_reason( + error, + stage="GameToken 换取完整凭据失败", + ) + ) + return { + "status": "Error", + "error": "GameToken 换取完整凭据失败,请检查登录状态或网络", + } + except Exception: + logger.exception("GameToken 换取完整凭据程序异常") + return { + "status": "Error", + "error": "GameToken 换取完整凭据失败,请稍后重试", + } + finally: + # GameToken 只在本次兑换中使用,不返回、不落盘。 + game_token = "" + raw_payload = "" + login_payload.clear() + + cookies_str = exchanged.get("cookies_str") if isinstance(exchanged, dict) else "" + if not isinstance(cookies_str, str) or not cookies_str: + return {"status": "Error", "error": "未获取到完整登录凭据"} + cookie_parts = _parse_cookie_string(cookies_str) + if not _has_qr_auth_cookie(cookie_parts): + return {"status": "Error", "error": "完整登录凭据缺少认证字段"} + if not _has_qr_uid_cookie(cookie_parts): + return {"status": "Error", "error": "完整登录凭据缺少用户 UID"} + logger.info("GameToken QR 确认成功,已换取完整凭据") + return {"status": "Confirmed", "cookies_str": cookies_str} + + async def check_qr_status( ticket: str, device: str, @@ -232,6 +547,13 @@ async def check_qr_status( {status: "Init"|"Scanned"|"Confirmed"|"Expired"|"Error", cookies_str?, error?} """ + if isinstance(ticket, str) and ticket.startswith(_GAME_TOKEN_TICKET_PREFIX): + return await _check_game_token_qr_status( + ticket[len(_GAME_TOKEN_TICKET_PREFIX):], + device, + proxy, + ) + try: headers = _qr_headers(device) async with httpx.AsyncClient(proxy=proxy or Config.proxy) as client: @@ -281,6 +603,18 @@ async def check_qr_status( # Confirmed — 从响应头或确认响应体提取 cookies cookies_str = _extract_cookies_from_headers(resp, qr_data) cookie_parts = _parse_cookie_string(cookies_str) + # 缺少 stoken 时先补全,再重建 cookie 字符串,避免活跃度查询 + # 退回实时便笺而触发验证码风控。 + if not cookie_parts.get("stoken") and not cookie_parts.get( + "stoken_v2" + ): + await _supplement_stoken(cookie_parts, proxy) + _add_qr_cookie_aliases(cookie_parts) + # login_ticket 只用于换取 stoken,不参与签到请求,保存前移除。 + cookie_parts.pop("login_ticket", None) + cookies_str = "; ".join( + f"{key}={value}" for key, value in cookie_parts.items() if value + ) if not _has_qr_auth_cookie(cookie_parts): return { "status": "Error", @@ -319,7 +653,9 @@ async def check_qr_status( return {"status": "Error", "error": "查询二维码状态失败,请稍后重试"} -def _extract_cookie_payload(payload: object) -> dict[str, str]: +def _extract_cookie_payload( + payload: object, *, include_nested: bool = True, _depth: int = 0 +) -> dict[str, str]: """从确认响应体提取已知 Cookie 字段,不记录或信任其它业务字段。""" if not isinstance(payload, dict): return {} @@ -330,15 +666,56 @@ def _extract_cookie_payload(payload: object) -> dict[str, str]: if isinstance(value, str) and value: cookie_parts[key] = value + user_info = payload.get("user_info") + if isinstance(user_info, dict): + aid = user_info.get("aid") or user_info.get("account_id") + mid = user_info.get("mid") + if isinstance(aid, str) and aid: + cookie_parts.setdefault("account_id_v2", aid) + cookie_parts.setdefault("ltuid_v2", aid) + if isinstance(mid, str) and mid: + cookie_parts.setdefault("mid", mid) + + tokens = payload.get("tokens") + if isinstance(tokens, dict): + for name in _QR_COOKIE_FIELDS: + token = tokens.get(name) + if isinstance(token, str) and token: + cookie_parts.setdefault(name, token) + elif isinstance(tokens, list): + for token_info in tokens: + if not isinstance(token_info, dict): + continue + name = token_info.get("name") + token = token_info.get("token") + if name in _QR_COOKIE_FIELDS and isinstance(token, str) and token: + cookie_parts.setdefault(name, token) + for key in ("cookie", "cookies", "cookie_str", "cookies_str"): raw_value = payload.get(key) if isinstance(raw_value, str): - cookie_parts.update(_parse_cookie_string(raw_value)) + for cookie_key, value in _parse_cookie_string(raw_value).items(): + cookie_parts.setdefault(cookie_key, value) elif isinstance(raw_value, dict): for cookie_key in _QR_COOKIE_FIELDS: value = raw_value.get(cookie_key) if isinstance(value, str) and value: cookie_parts.setdefault(cookie_key, value) + if include_nested and _depth < 2: + # Passport 的不同确认响应会把同一份 Cookie 放入已知业务封套中。 + # 只展开有限深度,兼容序列化 JSON 封套并避免扫描无关响应内容。 + for key in ("data", "result", "payload", "login_data"): + nested = payload.get(key) + if isinstance(nested, str): + try: + nested = json.loads(nested) + except (TypeError, ValueError): + nested = None + if isinstance(nested, dict): + for nested_key, nested_value in _extract_cookie_payload( + nested, include_nested=True, _depth=_depth + 1 + ).items(): + cookie_parts.setdefault(nested_key, nested_value) return cookie_parts @@ -406,8 +783,128 @@ async def exchange_stoken( uid: str, proxy: str | None = None, ) -> dict: - """兼容接口:Passport 模式下此函数不被调用,cookies 直接从响应头获取。 + """通过 GameToken 获取 stoken_v2/mid 和可选 cookie_token。""" - 保留此函数以兼容 API 路由层的调用。 - """ - return {"error": "Passport 模式不需要 exchange_stoken,请直接使用 cookies_str"} + token_value = str(game_token or "").strip() + uid_value = str(uid or "").strip() + if not token_value or not uid_value: + raise ValueError("GameToken 登录信息为空") + try: + account_id = int(uid_value) + except (TypeError, ValueError) as error: + raise ValueError("GameToken 登录返回的 UID 无效") from error + + request_body = { + "account_id": account_id, + "game_token": token_value, + } + headers = {"x-rpc-app_id": "bll8iq97cem8"} + + try: + async with httpx.AsyncClient( + proxy=proxy or Config.proxy, + trust_env=False, + ) as client: + response = await client.post( + GAME_TOKEN_STOKEN_URL, + headers=headers, + json=request_body, + timeout=30.0, + ) + try: + data = response.json() + except ValueError as error: + raise ValueError("GameToken 换取 stoken 接口返回无效 JSON") from error + + if ( + not isinstance(data, dict) + or data.get("retcode") not in (0, "0") + ): + raise ValueError("GameToken 换取 stoken 失败") + token_data = data.get("data") + token_info = ( + token_data.get("token") + if isinstance(token_data, dict) + else None + ) + user_info = ( + token_data.get("user_info") + if isinstance(token_data, dict) + else None + ) + stoken_v2 = ( + token_info.get("token") + if isinstance(token_info, dict) + else None + ) + mid = user_info.get("mid") if isinstance(user_info, dict) else None + if not isinstance(stoken_v2, str) or not stoken_v2.strip(): + raise ValueError("GameToken 换取 stoken 响应缺少 stoken") + if not isinstance(mid, str) or not mid.strip(): + raise ValueError("GameToken 换取 stoken 响应缺少 mid") + + stoken_v2 = stoken_v2.strip() + mid = mid.strip() + cookie_parts = { + "stoken_v2": stoken_v2, + "stoken": stoken_v2, + "mid": mid, + "mid_v2": mid, + "ltmid_v2": mid, + "account_mid_v2": mid, + "stuid": uid_value, + "stuid_v2": uid_value, + "ltuid": uid_value, + "ltuid_v2": uid_value, + "account_id": uid_value, + "account_id_v2": uid_value, + "login_uid": uid_value, + } + + # CookieToken 不是兑换 stoken 的必要条件;上游暂时不可用时, + # 仍保留 stoken_v2 + mid 供签到模块派生。 + try: + cookie_response = await client.post( + GAME_TOKEN_COOKIE_URL, + headers=headers, + json=request_body, + timeout=30.0, + ) + cookie_data = cookie_response.json() + except (httpx.HTTPError, OSError, ValueError): + logger.debug("GameToken 获取 cookie_token 跳过") + else: + cookie_payload = cookie_data.get("data") if isinstance( + cookie_data, dict + ) else None + cookie_info = ( + cookie_payload.get("token") + if isinstance(cookie_payload, dict) + else None + ) + cookie_token = ( + cookie_info.get("token") + if isinstance(cookie_info, dict) + else None + ) + if ( + isinstance(cookie_data, dict) + and cookie_data.get("retcode") in (0, "0") + and isinstance(cookie_token, str) + and cookie_token.strip() + ): + cookie_token = cookie_token.strip() + cookie_parts["cookie_token"] = cookie_token + if cookie_token.startswith("v2_"): + cookie_parts["cookie_token_v2"] = cookie_token + + return { + "cookies_str": "; ".join( + f"{key}={value}" + for key, value in cookie_parts.items() + if value + ) + } + finally: + request_body["game_token"] = "" + token_value = "" diff --git a/app/tools/taygedo.py b/app/tools/taygedo.py index ec45c5388..8dace7f05 100644 --- a/app/tools/taygedo.py +++ b/app/tools/taygedo.py @@ -41,7 +41,8 @@ import secrets import string import time -from collections.abc import Mapping +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass, field from typing import Any import httpx @@ -51,7 +52,7 @@ from app.utils.logger import get_logger from app.utils.security import format_exception_reason -logger = get_logger("塔吉多签到") +logger = get_logger("塔吉多社区") TAYGEDO_BASE_URL = "https://bbs-api.tajiduo.com" LAOHU_BASE_URL = "https://user.laohu.com" @@ -68,11 +69,11 @@ TAYGEDO_GAME_IDS = ("1256", "1257", "1289") TAYGEDO_GAME_NAMES = { "1256": "幻塔", - "1257": "异环", "1289": "异环", } +# 1257 的中文名称未在已核对的角色卡响应中确认;未知名称必须显式保留 ID。 APP_VERSION = "1.1.0" -# 用户中心刷新接口仍使用 1.1.0;角色卡、角色列表和社区接口使用当前原生协议版本。 +# 用户中心刷新、角色列表和社区接口使用 1.1.0;角色卡接口保留 1.2.5。 TAYGEDO_NATIVE_APP_VERSION = "1.2.5" TAYGEDO_COMMUNITY_IDS = ("1", "2") TAYGEDO_COMMUNITY_NAMES = { @@ -104,6 +105,44 @@ CLOUD_APP_VERSION = "1.1.0" +@dataclass +class _TaygedoRuntimeCredential: + """跟踪一次调用内产生的凭据对象,确保运行期临时数据可清理。""" + + values: list[dict[str, Any]] = field(default_factory=list) + refresh_attempted: bool = False + refresh_succeeded: bool = False + credential_update_delivered: bool = False + + def track(self, credential: dict[str, Any]) -> dict[str, Any]: + if not any(item is credential for item in self.values): + self.values.append(credential) + return credential + + def persistable( + self, + credential: dict[str, Any], + *, + drop_access_token: bool = True, + ) -> dict[str, Any]: + """复制可持久化快照,按调用方需要保留或移除 accessToken。""" + + self.track(credential) + persisted = dict(credential) + if drop_access_token: + persisted.pop("accessToken", None) + persisted.pop("_gameRoles", None) + persisted.pop("_gameRolesComplete", None) + return persisted + + def clear(self) -> None: + """清理本次调用持有的可变凭据对象。""" + + for credential in self.values: + credential.clear() + self.values.clear() + + def _is_expected_taygedo_exception(error: Exception) -> bool: """判断可预期的凭据、上游或网络失败。""" @@ -192,6 +231,27 @@ def parse_taygedo_credential(raw: str) -> dict[str, Any]: return credential +def validate_taygedo_credential(raw: str) -> dict[str, Any]: + """校验塔吉多及云异环凭据的本地字段完整性。""" + + credential = parse_taygedo_credential(raw) + if not credential: + raise ValueError("塔吉多凭据不能为空") + + has_community = bool( + credential.get("refreshToken") or credential.get("accessToken") + ) + has_cloud_token = bool(credential.get("cloudToken")) + has_cloud_user = bool(credential.get("cloudUserId")) + if has_cloud_token != has_cloud_user: + raise ValueError("云异环凭据必须同时包含 cloudToken 和 cloudUserId") + if not has_community and not has_cloud_token: + raise ValueError( + "塔吉多凭据缺少 refreshToken/accessToken 或 cloudToken/cloudUserId" + ) + return credential + + def serialize_taygedo_credential(credential: Mapping[str, Any]) -> str: """以稳定、可再次导入的 JSON 保存凭据,不写入日志。""" @@ -497,15 +557,18 @@ async def refresh_taygedo_credential( refresh_token = str(credential.get("refreshToken") or "").strip() if not refresh_token: raise ValueError("塔吉多凭据缺少 refreshToken") + request_device_id = str(credential.get("deviceId") or "").strip() + if not request_device_id: + # 参考客户端会复用设备标识;旧的纯 refreshToken 凭据没有该字段时, + # 用 refreshToken 派生稳定值,避免每天以不同设备发起刷新。 + request_device_id = _stable_device_id(refresh_token) async with httpx.AsyncClient(proxy=proxy, trust_env=False) as client: response = await client.post( REFRESH_TOKEN_URL, headers={ "authorization": refresh_token, - "deviceid": str( - credential.get("deviceId") or _stable_device_id(refresh_token) - ), + "deviceid": request_device_id, "appversion": APP_VERSION, "content-type": "application/x-www-form-urlencoded", "user-agent": APP_USER_AGENT, @@ -517,35 +580,113 @@ async def refresh_taygedo_credential( raise _api_error("塔吉多刷新 Token", response, data) refreshed = data["data"] - if not refreshed.get("accessToken") or not refreshed.get("refreshToken"): - raise ValueError("塔吉多刷新接口未返回完整 token") - credential["accessToken"] = str(refreshed["accessToken"]) - credential["refreshToken"] = str(refreshed["refreshToken"]) + access_token = str(refreshed.get("accessToken") or "").strip() + if not access_token: + raise ValueError("塔吉多刷新接口未返回 accessToken") + credential["accessToken"] = access_token + # 已核对的刷新协议会成对返回 accessToken/refreshToken;缺少轮换后的 + # refreshToken 时不能继续保存旧值,否则下一次续期可能再次使用已消费令牌。 + refresh_token_value = str( + refreshed.get("refreshToken") or refreshed.get("refresh_token") or "" + ).strip() + if not refresh_token_value: + raise ValueError("塔吉多刷新接口未返回 refreshToken") + credential["refreshToken"] = refresh_token_value if refreshed.get("uid") is not None: credential["uid"] = str(refreshed["uid"]) credential.setdefault("gameId", DEFAULT_GAME_ID) - credential.setdefault("deviceId", _stable_device_id(credential["refreshToken"])) + if not str(credential.get("deviceId") or "").strip(): + credential["deviceId"] = request_device_id + # 刷新只负责认证字段;角色发现由后续社区游戏任务链路统一完成, + # 避免刷新阶段重复请求角色接口并污染持久化元数据。 + return credential + + +async def sign_taygedo( + raw: str, + *, + proxy: str | None = None, + on_credential_update: Callable[[str], Awaitable[None]] | None = None, +) -> tuple[list[dict[str, str]], dict[str, Any]]: + """执行一次塔吉多社区调用,并按刷新结果处理运行期访问 Token。""" + runtime = _TaygedoRuntimeCredential() + credential = runtime.track(parse_taygedo_credential(raw)) try: - return await _attach_role_name(credential, proxy=proxy) - except Exception as exc: - # 角色名只用于通知展示,不能阻断有效 refreshToken 的社区签到。 - logger.debug(f"塔吉多角色信息获取跳过: {exc}") - return credential + results, effective_credential = await _run_taygedo( + raw, + credential, + runtime=runtime, + proxy=proxy, + on_credential_update=on_credential_update, + ) + persisted = runtime.persistable( + effective_credential, + # 刷新后的 accessToken 与轮换后的 refreshToken 成对保存;后续调用 + # 先复用当前会话,只有明确鉴权失败才再次触碰 refreshToken。 + drop_access_token=False, + ) + if ( + runtime.refresh_succeeded + and on_credential_update is not None + and not runtime.credential_update_delivered + ): + try: + await on_credential_update(serialize_taygedo_credential(persisted)) + runtime.credential_update_delivered = True + except Exception as exc: + _log_taygedo_exception("塔吉多凭据回写失败", exc) + return results, persisted + finally: + runtime.clear() -async def sign_taygedo( +async def _run_taygedo( raw: str, + credential: dict[str, Any], *, + runtime: _TaygedoRuntimeCredential, proxy: str | None = None, + on_credential_update: Callable[[str], Awaitable[None]] | None = None, ) -> tuple[list[dict[str, str]], dict[str, Any]]: - """执行塔吉多社区、应用内游戏签到和云异环时长查询。""" + """执行塔吉多社区签到、应用内游戏日常任务和云异环时长查询。""" - credential = parse_taygedo_credential(raw) refresh_error_reason: str | None = None - if credential.get("refreshToken"): + + async def publish_refreshed_credential( + refreshed_credential: dict[str, Any], + ) -> None: + """在刷新返回后立即写穿认证快照,避免取消窗口丢失轮换 Token。""" + + if on_credential_update is None: + return + serialized = serialize_taygedo_credential( + runtime.persistable( + refreshed_credential, + drop_access_token=False, + ) + ) + update_task = asyncio.ensure_future(on_credential_update(serialized)) + try: + # 刷新 Token 的回写必须完成后才能允许外层取消;否则一次性 + # refreshToken 可能已被消费但新值尚未落盘。 + await asyncio.shield(update_task) + except asyncio.CancelledError: + await asyncio.gather(update_task, return_exceptions=True) + raise + except Exception as exc: + _log_taygedo_exception("塔吉多凭据即时回写失败", exc) + return + runtime.credential_update_delivered = True + + if not _has_usable_taygedo_session(credential) and credential.get("refreshToken"): + runtime.refresh_attempted = True try: - credential = await refresh_taygedo_credential(raw, proxy=proxy) + credential = runtime.track( + await refresh_taygedo_credential(raw, proxy=proxy) + ) + runtime.refresh_succeeded = True + await publish_refreshed_credential(credential) except Exception as exc: # 塔吉多 refreshToken 失效时仍继续查询同一凭据中的云异环时长。 refresh_error_reason = _log_taygedo_exception( @@ -557,26 +698,25 @@ async def sign_taygedo( access_token = str(credential.get("accessToken") or "").strip() uid = str(credential.get("uid") or "").strip() device_id = str(credential.get("deviceId") or "").strip() + if not device_id and uid: + # 旧凭据可能只含 accessToken/uid;在本次运行内保持设备值稳定, + # 后续有 refreshToken 时由 refresh_taygedo_credential 继续复用它。 + device_id = _stable_device_id( + str(credential.get("refreshToken") or uid or access_token) + ) + credential["deviceId"] = device_id account = str(credential.get("roleName") or uid or "未知用户") - community_task: asyncio.Task[list[tuple[str, str, str, str]]] | None = None - game_task: asyncio.Task[list[dict[str, str]]] | None = None cloud_task: asyncio.Task[dict[str, int | None]] | None = None cloud_token = str(credential.get("cloudToken") or "").strip() cloud_user_id = str(credential.get("cloudUserId") or "").strip() cloud_account = account if account != "未知用户" else cloud_user_id cloud_before = _to_optional_int(credential.get("cloudRemainingDuration")) - async def cancel_pending_tasks() -> None: - pending_tasks = [ - task - for task in (community_task, game_task, cloud_task) - if task is not None and not task.done() - ] - for task in pending_tasks: - task.cancel() - if pending_tasks: - await asyncio.gather(*pending_tasks, return_exceptions=True) + async def cancel_cloud_task() -> None: + if cloud_task is not None and not cloud_task.done(): + cloud_task.cancel() + await asyncio.gather(cloud_task, return_exceptions=True) # 云异环每日首次登录时长与两个签到接口互不依赖,并发发起以缩短等待时间。 if cloud_token and cloud_user_id: @@ -594,26 +734,26 @@ async def cancel_pending_tasks() -> None: ) ) - if access_token and uid: - request_device_id = device_id or _stable_device_id(access_token) - cached_roles = credential.pop("_gameRoles", None) - cached_lookup_complete = credential.pop("_gameRolesComplete", None) - if not isinstance(cached_roles, list): - cached_roles = None - # 社区签到和应用内游戏签到互不依赖,同时发起以减少整体等待时间。 + async def run_taygedo_actions( + action_access_token: str, + action_uid: str, + action_device_id: str, + ) -> tuple[list[tuple[str, str, str, str]], list[dict[str, str]]]: + """并发执行一次社区和游戏日常动作,供鉴权恢复复用。""" + community_task = asyncio.create_task( _community_sign( - access_token, - uid, - request_device_id, + action_access_token, + action_uid, + action_device_id, proxy=proxy, ) ) game_task = asyncio.create_task( _sign_taygedo_games( - access_token, - uid, - request_device_id, + action_access_token, + action_uid, + action_device_id, account, proxy=proxy, roles=cached_roles, @@ -623,10 +763,21 @@ async def cancel_pending_tasks() -> None: ) ) + async def cancel_action_tasks() -> None: + pending_tasks = [ + task + for task in (community_task, game_task) + if not task.done() + ] + for task in pending_tasks: + task.cancel() + if pending_tasks: + await asyncio.gather(*pending_tasks, return_exceptions=True) + try: community_results = await community_task except asyncio.CancelledError: - await cancel_pending_tasks() + await cancel_action_tasks() raise except Exception as exc: failure_reason = _log_taygedo_exception("塔吉多社区签到异常", exc) @@ -641,26 +792,15 @@ async def cancel_pending_tasks() -> None: ) for community_id in TAYGEDO_COMMUNITY_IDS ] - for community_name, status, reason, reward in community_results: - results.append( - { - "account": account, - "game": community_name, - "platform": "塔吉多", - "status": status, - "reward": reward, - "reason": reason, - } - ) try: - results.extend(await game_task) + game_results = await game_task except asyncio.CancelledError: - await cancel_pending_tasks() + await cancel_action_tasks() raise except Exception as exc: - reason = _log_taygedo_exception("塔吉多应用内游戏签到异常", exc) - results.append( + reason = _log_taygedo_exception("塔吉多应用内游戏日常任务异常", exc) + game_results = [ { "account": account, "game": "应用内游戏", @@ -669,7 +809,68 @@ async def cancel_pending_tasks() -> None: "reward": "", "reason": reason, } + ] + return community_results, game_results + + if _has_usable_taygedo_session(credential): + request_device_id = device_id or _stable_device_id(access_token) + cached_roles = credential.pop("_gameRoles", None) + cached_lookup_complete = credential.pop("_gameRolesComplete", None) + if not isinstance(cached_roles, list): + cached_roles = None + + # 社区签到和应用内游戏日常任务互不依赖,同时发起以减少整体等待时间。 + community_results, game_results = await run_taygedo_actions( + access_token, + uid, + request_device_id, + ) + + # accessToken 过期时只恢复一次;只有明确鉴权失败才触碰 refreshToken。 + if ( + credential.get("refreshToken") + and not runtime.refresh_attempted + and _taygedo_actions_need_refresh( + community_results, + game_results, + ) + ): + runtime.refresh_attempted = True + try: + refreshed_credential = await refresh_taygedo_credential( + raw, + proxy=proxy, + ) + credential = runtime.track(refreshed_credential) + runtime.refresh_succeeded = True + await publish_refreshed_credential(credential) + access_token = str(credential.get("accessToken") or "").strip() + uid = str(credential.get("uid") or "").strip() + request_device_id = str(credential.get("deviceId") or "").strip() + if _has_usable_taygedo_session(credential): + community_results, game_results = await run_taygedo_actions( + access_token, + uid, + request_device_id or _stable_device_id(access_token), + ) + except Exception as exc: + refresh_error_reason = _log_taygedo_exception( + "塔吉多鉴权恢复失败", + exc, + ) + + for community_name, status, reason, reward in community_results: + results.append( + { + "account": account, + "game": community_name, + "platform": "塔吉多", + "status": status, + "reward": reward, + "reason": reason, + } ) + results.extend(game_results) elif refresh_error_reason is not None: results.extend( { @@ -730,7 +931,7 @@ async def cancel_pending_tasks() -> None: } ) except asyncio.CancelledError: - await cancel_pending_tasks() + await cancel_cloud_task() raise except Exception as exc: reason = _log_taygedo_exception("云异环时长查询异常", exc) @@ -748,6 +949,68 @@ async def cancel_pending_tasks() -> None: return results, credential +def _has_usable_taygedo_session(credential: Mapping[str, Any]) -> bool: + """判断本次调用是否已有可直接使用的访问会话。""" + + return bool( + str(credential.get("accessToken") or "").strip() + and str(credential.get("uid") or "").strip() + ) + + +def _taygedo_reason_is_auth_failure(reason: str) -> bool: + """只识别明确的会话失效提示,避免普通业务失败触发刷新。""" + + text = str(reason or "").lower() + return any( + marker in text + for marker in ( + "http 401", + "http 402", + "http 403", + "code=401", + "code=402", + "code=4011", + "code=403", + "auth_expired", + "access token", + "access_token", + "业务码 4011", + "token失效", + "token 已失效", + "令牌失效", + "授权失效", + "登录失效", + "登录已过期", + "未登录", + "未授权", + "invalid token", + "unauthorized", + "invalid_token", + ) + ) + + +def _taygedo_actions_need_refresh( + community_results: list[tuple[str, str, str, str]], + game_results: list[dict[str, str]], +) -> bool: + """判断动作结果是否需要一次受控的 refreshToken 恢复。""" + + entries = [ + (status, reason) + for _name, status, reason, _reward in community_results + ] + entries.extend( + (str(result.get("status", "")), str(result.get("reason", ""))) + for result in game_results + ) + if not entries: + return False + # 社区与游戏请求并发执行;一项成功不能掩盖另一项已经明确失效的会话。 + return any(_taygedo_reason_is_auth_failure(reason) for _status, reason in entries) + + async def _sign_taygedo_games( access_token: str, uid: str, @@ -758,7 +1021,7 @@ async def _sign_taygedo_games( roles: list[dict[str, str]] | None = None, lookup_complete: bool | None = None, ) -> list[dict[str, str]]: - """遍历塔吉多绑定游戏并执行每日游戏签到。""" + """遍历塔吉多绑定游戏并执行每日游戏日常任务。""" if roles is None: roles, lookup_complete = await _get_taygedo_game_roles_with_status( @@ -772,9 +1035,11 @@ async def _sign_taygedo_games( assert lookup_complete is not None if not roles: if lookup_complete: - logger.info("塔吉多未绑定应用内游戏,跳过游戏签到") + logger.info("塔吉多未绑定应用内游戏,跳过游戏日常任务") return [] - logger.warning("塔吉多游戏角色接口未完成,应用内游戏签到跳过") + logger.warning( + "塔吉多游戏角色接口未完成,应用内游戏日常任务跳过" + ) return [ { "account": f"{account}/应用内游戏", @@ -782,7 +1047,7 @@ async def _sign_taygedo_games( "platform": "塔吉多", "status": "失败", "reward": "", - "reason": "游戏角色接口获取失败,无法确认应用内游戏签到", + "reason": "游戏角色接口获取失败,无法确认应用内游戏日常任务", } ] @@ -792,7 +1057,7 @@ async def sign_role( game_id = role["gameId"] role_id = role["roleId"] role_name = role.get("roleName") or role_id - game_name = role.get("gameName") or TAYGEDO_GAME_NAMES.get(game_id, game_id) + game_name = _taygedo_game_name(game_id, role.get("gameName")) # 角色卡是本次结果的权威名称,不能继续沿用旧凭据中的异环别名。 role_account = f"{role_name}/{role_name}({role_id})" try: @@ -821,7 +1086,7 @@ async def sign_role( "reason": "", } except Exception as exc: - reason = _log_taygedo_exception("塔吉多应用内游戏签到异常", exc) + reason = _log_taygedo_exception("塔吉多应用内游戏日常任务异常", exc) return { "account": role_account, "game": game_name, @@ -844,7 +1109,7 @@ async def sign_role( "platform": "塔吉多", "status": "失败", "reward": "", - "reason": "部分游戏角色接口获取失败,无法确认全部应用内游戏签到", + "reason": "部分游戏角色接口获取失败,无法确认全部应用内游戏日常任务", } ) return results @@ -883,14 +1148,21 @@ async def _get_taygedo_game_roles_with_status( response = await client.get( GAME_RECORD_CARDS_URL, params={"uid": uid}, - headers=_native_headers(access_token, uid, device_id), + headers=_native_headers( + access_token, + uid, + device_id, + ), timeout=30.0, ) data = _read_json(response, "塔吉多游戏角色卡") raw_cards = data.get("data") - if not _is_code(data.get("code"), 0) or not _is_game_record_cards_payload( - raw_cards - ): + if not _is_code(data.get("code"), 0): + error = _api_error("塔吉多游戏角色卡", response, data) + if _taygedo_reason_is_auth_failure(str(error)): + raise error + raise ValueError("角色卡响应格式无效") + if not _is_game_record_cards_payload(raw_cards): raise ValueError("角色卡响应格式无效") roles = [ role @@ -901,6 +1173,9 @@ async def _get_taygedo_game_roles_with_status( return _deduplicate_game_roles(roles), True # 角色卡可能只返回未绑定的空卡,继续查询角色列表获取已绑定角色。 except Exception as exc: + # 角色卡的明确鉴权失败不能被静默降级,否则社区签到成功时不会触发恢复。 + if _taygedo_reason_is_auth_failure(str(exc)): + raise logger.debug(f"获取塔吉多游戏角色卡跳过: {type(exc).__name__}") roles: list[dict[str, str]] = [] @@ -913,11 +1188,19 @@ async def load_roles( response = await client.get( GAME_ROLES_URL, params={"gameId": game_id}, - headers=_native_headers(access_token, uid, device_id), + headers=_native_headers( + access_token, + uid, + device_id, + app_version=APP_VERSION, + ), timeout=30.0, ) data = _read_json(response, f"塔吉多角色({game_id})") if not _is_code(data.get("code"), 0): + error = _api_error(f"塔吉多角色({game_id})", response, data) + if _taygedo_reason_is_auth_failure(str(error)): + raise error return [], False # getGameRoles 的响应只返回 roleId/roleName,游戏归属由本次查询参数决定。 roles = [ @@ -932,10 +1215,13 @@ async def load_roles( ] return roles, True except Exception as exc: + # 保留明确的鉴权失败给上层的一次受控 refresh;普通单游戏失败继续部分回退。 + if _taygedo_reason_is_auth_failure(str(exc)): + raise logger.debug(f"获取塔吉多游戏角色 {game_id} 跳过: {type(exc).__name__}") return [], False - # 旧接口按请求参数返回角色;最终按角色 ID 去重,避免重复映射到多个游戏。 + # 旧接口按请求参数返回角色;最终按游戏 ID 和角色 ID 去重,避免重复映射。 role_batches = await asyncio.gather( *(load_roles(game_id) for game_id in TAYGEDO_GAME_IDS) ) @@ -1063,26 +1349,34 @@ def _normalise_game_role( ) return { "gameId": game_id, - # 服务端偶尔会沿用上一个子社区的 gameName,gameId 才是角色归属依据。 - "gameName": TAYGEDO_GAME_NAMES.get( - game_id, - str(raw_role.get("gameName") or game_id).strip(), - ), + # 角色卡的 gameName 是已确认响应中的展示来源;缺失时才使用安全回退。 + "gameName": _taygedo_game_name(game_id, raw_role.get("gameName")), "roleId": role_id, "roleName": role_name, } +def _taygedo_game_name(game_id: str, reported_name: Any = "") -> str: + """按已确认游戏 ID 返回稳定名称,未知 ID 才采用服务端名称。""" + + known_name = TAYGEDO_GAME_NAMES.get(game_id) + if known_name: + return known_name + name = str(reported_name or "").strip() + return name or f"游戏({game_id})" + + def _deduplicate_game_roles(roles: list[dict[str, str]]) -> list[dict[str, str]]: - # 角色 ID 在塔吉多账号下是全局唯一的,避免旧接口在多个 gameId 查询中 - # 重复返回同一角色时,把一个角色错误展示为多个游戏角色。 - seen: set[str] = set() + # 只在同一 gameId 内去重;不同游戏即使角色 ID 相同也必须分别展示。 + seen: set[tuple[str, str]] = set() unique: list[dict[str, str]] = [] for role in roles: + game_id = role.get("gameId", "") role_id = role.get("roleId", "") - if not role_id or role_id in seen: + key = (game_id, role_id) + if not role_id or key in seen: continue - seen.add(role_id) + seen.add(key) unique.append(role) return unique @@ -1109,9 +1403,9 @@ async def _get_game_sign_state( headers=_h5_headers(access_token), timeout=30.0, ) - data = _read_json(response, f"塔吉多游戏签到状态({game_id})") + data = _read_json(response, f"塔吉多游戏日常任务状态({game_id})") if not _is_code(data.get("code"), 0) or not isinstance(data.get("data"), dict): - raise _api_error(f"塔吉多游戏签到状态({game_id})", response, data) + raise _api_error(f"塔吉多游戏日常任务状态({game_id})", response, data) return data["data"] @@ -1169,26 +1463,35 @@ async def _submit_game_sign( data={"gameId": game_id, "roleId": role_id}, timeout=30.0, ) - data = _read_json(response, f"塔吉多游戏签到({game_id})") + data = _read_json(response, f"塔吉多游戏日常任务({game_id})") message = str(data.get("msg") or data.get("message") or "").strip() if _is_code(data.get("code"), 0): return "成功" if str(data.get("code")) == "5052" or _is_already_signed(message): return "已签到" - raise _api_error(f"塔吉多游戏签到({game_id})", response, data) + raise _api_error(f"塔吉多游戏日常任务({game_id})", response, data) -def _native_headers(access_token: str, uid: str, device_id: str) -> dict[str, str]: - return { +def _native_headers( + access_token: str, + uid: str, + device_id: str, + *, + app_version: str = TAYGEDO_NATIVE_APP_VERSION, +) -> dict[str, str]: + headers = { "accept": "application/json, text/plain, */*", "authorization": access_token, "uid": uid, "deviceid": device_id, - "appversion": TAYGEDO_NATIVE_APP_VERSION, + "appversion": app_version, "platform": "android", - "ds": _make_login_ds(), "user-agent": APP_USER_AGENT, } + # 已核对的旧版角色列表/社区接口不携带 DS;角色卡的 1.2.5 原生协议才需要它。 + if app_version == TAYGEDO_NATIVE_APP_VERSION: + headers["ds"] = _make_login_ds() + return headers def _h5_headers(access_token: str) -> dict[str, str]: @@ -1293,10 +1596,6 @@ async def _attach_role_name( if not access_token or not uid: return credential - # 角色查询失败或返回空列表时不能继续使用旧凭据中的游戏名。 - credential.pop("gameId", None) - credential.pop("roleName", None) - credential.pop("roleIds", None) device_id = str(credential.get("deviceId") or _stable_device_id(access_token)) roles, lookup_complete = await _get_taygedo_game_roles_with_status( access_token, @@ -1306,6 +1605,10 @@ async def _attach_role_name( ) credential["_gameRoles"] = roles credential["_gameRolesComplete"] = lookup_complete + # 角色查询失败或只返回部分结果时保留旧元数据,避免一次异常请求 + # 破坏下次调用仍可用的游戏映射。 + if not lookup_complete: + return credential if not roles: if lookup_complete: credential.pop("gameId", None) @@ -1313,6 +1616,9 @@ async def _attach_role_name( credential.pop("roleIds", None) return credential first = next((role for role in roles if role.get("roleName")), roles[0]) + credential.pop("gameId", None) + credential.pop("roleName", None) + credential.pop("roleIds", None) credential["gameId"] = first["gameId"] if first.get("roleName"): credential["roleName"] = first["roleName"] @@ -1338,7 +1644,12 @@ async def sign_community(community_id: str) -> tuple[str, str, str, str]: response = await client.post( APP_SIGNIN_URL, headers={ - **_native_headers(access_token, uid, device_id), + **_native_headers( + access_token, + uid, + device_id, + app_version=APP_VERSION, + ), "content-type": "application/x-www-form-urlencoded", }, data={"communityId": community_id}, @@ -1390,7 +1701,8 @@ def _api_error( endpoint: str, response: httpx.Response, data: Mapping[str, Any] ) -> ValueError: message = str(data.get("msg") or data.get("message") or "请求失败").strip() - return ValueError(f"{endpoint}失败(HTTP {response.status_code}):{message}") + code = data.get("code", "unknown") + return ValueError(f"{endpoint}失败(HTTP {response.status_code},code={code}):{message}") def _is_already_signed(message: str) -> bool: diff --git a/frontend/src/components/AppLayout.vue b/frontend/src/components/AppLayout.vue index fc0955c05..beaf8fe95 100644 --- a/frontend/src/components/AppLayout.vue +++ b/frontend/src/components/AppLayout.vue @@ -28,6 +28,7 @@ /> [ ]) const bottomMenuItems = computed(() => [ - { key: '/gamesign', label: t('comp.checkIns'), icon: icon(CarryOutOutlined) }, + { + key: '/gamesign', + label: t('comp.checkIns'), + icon: icon(CarryOutOutlined), + children: [ + { + key: '/gamesign/sign', + label: t('gamesign.nav.sign'), + icon: icon(CheckCircleOutlined), + }, + { + key: '/gamesign/activity', + label: t('gamesign.nav.activity'), + icon: icon(ProfileOutlined), + }, + ], + }, { key: '/history', label: t('comp.history'), icon: icon(HistoryOutlined) }, { key: '/tools', label: t('comp.tools'), icon: icon(ToolOutlined) }, { key: '/settings', label: t('comp.settings'), icon: icon(SettingOutlined) }, ]) +type MenuEntry = NonNullable[number] +type KeyedMenuEntry = Exclude & { key: string | number } + +const hasMenuKey = (item: MenuEntry): item is KeyedMenuEntry => + item !== null && 'key' in item && item.key !== undefined + +const flattenMenuItems = (items: readonly MenuEntry[]): MenuEntry[] => { + const flattened: MenuEntry[] = [] + for (const item of items) { + if (!item) continue + flattened.push(item) + if ('children' in item && item.children) { + flattened.push(...flattenMenuItems(item.children)) + } + } + return flattened +} + const allItems = computed(() => [ ...mainMenuItems.value, ...(isDevelopment.value ? devMenuItems.value : []), ...bottomMenuItems.value, ]) -// 选中项:根据当前路径前缀匹配 +const flatItems = computed(() => flattenMenuItems(allItems.value)) + +// 选中项:优先精确匹配子菜单,再按路径边界匹配父菜单。 const selectedKeys = computed(() => { const path = route.path - const matched = allItems.value.find(i => path.startsWith(String(i.key))) + const matched = flatItems.value + .filter(hasMenuKey) + .filter(item => { + const key = String(item.key) + return path === key || path.startsWith(`${key}/`) + }) + .sort((left, right) => String(right.key).length - String(left.key).length)[0] return [matched?.key || '/home'] }) +const bottomOpenKeys = ref([]) +watch( + () => route.path, + path => { + if ( + (path === '/gamesign' || path.startsWith('/gamesign/')) && + !bottomOpenKeys.value.includes('/gamesign') + ) { + bottomOpenKeys.value = [...bottomOpenKeys.value, '/gamesign'] + } + }, + { immediate: true } +) + const onMenuClick: MenuProps['onClick'] = info => { const target = String(info.key) @@ -156,7 +215,8 @@ const onMenuClick: MenuProps['onClick'] = info => { }) } - if (route.path !== target) router.push(target) + const destination = target === '/gamesign' ? '/gamesign/sign' : target + if (route.path !== destination) router.push(destination) } @@ -196,6 +256,49 @@ const onMenuClick: MenuProps['onClick'] = info => { text-align: left; } +.sider-content :deep(.ant-menu .ant-menu-submenu) { + width: calc(100% - 16px); + margin: 2px auto; + border-radius: 6px; +} + +.sider-content :deep(.ant-menu .ant-menu-submenu-title) { + height: 40px; + margin: 0; + padding: 5px 16px !important; + line-height: 30px; + border-radius: 6px; + display: flex; + align-items: center; + gap: 6px; + color: var(--ant-color-text); + transition: + background 0.16s ease, + color 0.16s ease; +} + +.sider-content :deep(.ant-menu .ant-menu-submenu-title .anticon) { + color: var(--ant-color-text-secondary); + font-size: 18px; +} + +.sider-content :deep(.ant-menu .ant-menu-submenu-title:hover) { + background: var(--ant-color-primary-bg); + color: var(--ant-color-text); +} + +.sider-content :deep(.ant-menu .ant-menu-submenu > .ant-menu) { + background: transparent !important; +} + +.sider-content :deep(.ant-menu .ant-menu-submenu .ant-menu-item) { + width: calc(100% - 8px); + margin: 2px 4px; + padding-left: 40px !important; + height: 36px; + line-height: 32px; +} + .sider-content :deep(.ant-menu .ant-menu-item .anticon) { color: var(--ant-color-text-secondary); font-size: 18px; diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index e644b373b..94aec3505 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -69,7 +69,7 @@ export default { moduleName: 'Module name', emulators: 'Emulators', testRoute: 'Test route', - checkIns: 'Check-ins', + checkIns: 'Game community', port: 'Port', portNumber: 'Port number', warning: 'Warning', @@ -1412,7 +1412,11 @@ export default { }, }, gamesign: { - title: 'Check-ins', + title: 'Game Community', + nav: { + sign: 'Check-ins', + activity: 'Daily notes', + }, statusInvalid: 'The check-in status response was invalid', resultInvalid: 'The check-in result response was invalid', signStatus: { signed: 'Done', risk: 'Blocked', failed: 'Failed', unsigned: 'Pending' }, @@ -1420,16 +1424,16 @@ export default { defaultUserName: 'User', newUserName: 'User {n}', section: { - settings: 'Check-in settings', + settings: 'Community settings', doc: 'Open the Skland check-in docs', signAll: 'Check in everywhere', noticeTitle: 'What this does, and your privacy', toolDesc: - 'The check-in tool stores your community credentials and runs check-ins at startup, on schedule, or on demand.', + 'The game community tool stores your community credentials and runs check-ins at startup, on schedule, or on demand.', privacyNotice: 'Signing in with a username and password stores neither. The credentials live only in memory for that one login request and are cleared as soon as it finishes or fails — they never reach the config, the logs, or a notification.', - enable: 'Enable the check-in tool', - enableDesc: 'Runs check-ins on the MAS schedule.', + enable: 'Enable community tools', + enableDesc: 'Runs community check-ins on the MAS schedule; manual check-ins ignore the daily automatic limit.', notify: 'Notify on completion', notifyDesc: 'Push the result through your configured notification channels', runOnStartup: 'Check in at startup', @@ -1443,7 +1447,7 @@ export default { add: 'Add a user', colName: 'Name', colEnabled: 'Enabled', - colTags: 'Check-in status by community', + colTags: 'Community status', colActions: 'Actions', drag: 'Drag to reorder', tooltipTitle: '{platform} — check-in details', diff --git a/frontend/src/i18n/locales/ja-JP.ts b/frontend/src/i18n/locales/ja-JP.ts index 8bee0d440..28f82f760 100644 --- a/frontend/src/i18n/locales/ja-JP.ts +++ b/frontend/src/i18n/locales/ja-JP.ts @@ -71,7 +71,7 @@ export default { moduleName: 'モジュール名', emulators: 'エミュレータ', testRoute: 'テストルート', - checkIns: 'ログインボーナス', + checkIns: 'ゲームコミュニティ', port: 'ポート', portNumber: 'ポート番号', warning: '警告', @@ -1421,7 +1421,11 @@ export default { }, }, gamesign: { - title: 'ゲームログインボーナス', + title: 'ゲームコミュニティ', + nav: { + sign: 'チェックイン', + activity: 'デイリーノート', + }, statusInvalid: 'ログインボーナスの状態レスポンスが不正です', resultInvalid: 'ログインボーナスの結果レスポンスが不正です', signStatus: { @@ -1434,16 +1438,17 @@ export default { defaultUserName: 'ユーザー', newUserName: 'ユーザー {n}', section: { - settings: 'ログインボーナス設定', + settings: 'コミュニティ設定', doc: 'Skland のログインボーナス設定ドキュメントを開く', signAll: 'すべて受け取る', noticeTitle: '機能の説明とプライバシーについて', - toolDesc: 'このツールは各コミュニティの認証情報を管理し', + toolDesc: + 'ゲームコミュニティツールは各コミュニティの認証情報を管理し、起動時・スケジュール実行時・手動実行時にチェックインします。', privacyNotice: 'アカウントとパスワードによるトークン取得では、どちらも保存されません。入力した情報はそのログイン要求のあいだメモリ上にのみ存在し、完了または失敗した時点で破棄されます。設定・ログ・通知のいずれにも書き込まれません。', - enable: 'ログインボーナス機能を有効にする', + enable: 'コミュニティツールを有効にする', enableDesc: - 'MAS のスケジュールに従って受け取ります。手動実行は 1 日 1 回の制限を受けません。', + 'MAS のタスクスケジュールに従ってコミュニティのチェックインを実行します。手動実行は 1 日 1 回の制限を受けません。', notify: '結果を通知する', notifyDesc: '設定済みの通知チャンネルに結果を送信します', runOnStartup: '起動時に受け取る', @@ -1457,7 +1462,7 @@ export default { add: 'ユーザーを追加', colName: 'ユーザー名', colEnabled: '有効', - colTags: 'コミュニティ別の受取状況', + colTags: '各コミュニティの状態', colActions: '操作', drag: 'ドラッグして並び替え', tooltipTitle: '{platform} — 受取の詳細', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 34d11d3ea..521d91726 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -71,7 +71,7 @@ export default { moduleName: '模块名', emulators: '模拟器管理', testRoute: '测试路由', - checkIns: '游戏签到', + checkIns: '游戏社区', port: '端口', portNumber: '端口号', warning: '警告', @@ -1336,7 +1336,11 @@ export default { }, }, gamesign: { - title: '游戏签到', + title: '游戏社区', + nav: { + sign: '签到', + activity: '日常便笺', + }, statusInvalid: '签到状态响应无效', resultInvalid: '签到结果响应无效', signStatus: { signed: '已签', risk: '风控', failed: '失败', unsigned: '未签' }, @@ -1344,15 +1348,15 @@ export default { defaultUserName: '用户', newUserName: '用户 {n}', section: { - settings: '签到设置', + settings: '社区设置', doc: '查看森空岛签到配置文档', signAll: '全部签到', noticeTitle: '功能说明与隐私声明', - toolDesc: '游戏签到社区工具用于管理各社区凭据,并按配置执行启动时、任务调度和手动签到。', + toolDesc: '游戏社区工具用于管理各社区凭据,并按配置执行启动时、任务调度和手动签到。', privacyNotice: '账密获取 Token 不保存任何账号密码;本次登录使用的账号密码仅存在于当前登录请求的内存中,登录完成或失败后立即清理,不写入配置、日志或通知。', - enable: '启用签到工具', - enableDesc: '启用后按 MAS 任务调度执行签到', + enable: '启用社区工具', + enableDesc: '启用后按 MAS 任务调度执行社区签到,手动签到不受每日自动签到限制', notify: '结果通知', notifyDesc: '签到完成后通过已配置的通知渠道推送结果', runOnStartup: '启动时签到', @@ -1366,7 +1370,7 @@ export default { add: '添加用户', colName: '用户名', colEnabled: '启用', - colTags: '各社区签到情况', + colTags: '各社区状态', colActions: '操作', drag: '拖拽排序', tooltipTitle: '{platform} - 签到详情', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index dbe3bc6a6..1f1436350 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -264,7 +264,13 @@ const routes = [ path: '/gamesign', name: 'GameSign', component: () => import('../views/gamesign/index.vue'), - meta: { title: '游戏签到' }, + meta: { title: '游戏社区' }, + }, + { + path: '/gamesign/:section(sign|activity)', + name: 'CommunityToolSection', + component: () => import('../views/gamesign/index.vue'), + meta: { title: '游戏社区' }, }, { path: '/tools', diff --git a/frontend/src/views/gamesign/CommunityActivityView.vue b/frontend/src/views/gamesign/CommunityActivityView.vue new file mode 100644 index 000000000..38ba625bd --- /dev/null +++ b/frontend/src/views/gamesign/CommunityActivityView.vue @@ -0,0 +1,449 @@ + + + + + diff --git a/frontend/src/views/gamesign/TabGameSign.vue b/frontend/src/views/gamesign/TabGameSign.vue index 66c9def36..c5e5bf6fb 100644 --- a/frontend/src/views/gamesign/TabGameSign.vue +++ b/frontend/src/views/gamesign/TabGameSign.vue @@ -42,7 +42,7 @@ const { onRefreshConfig?: () => Promise }>() -const logger = window.electronAPI.getLogger('游戏签到') +const logger = window.electronAPI.getLogger('游戏社区') const signLoading = ref(false) const notifySaving = ref(false) const credentialToolDescription = computed(() => t('gamesign.section.toolDesc')) @@ -334,6 +334,10 @@ const { } await loadAccounts() if (!isStillCurrent()) return + const savedAccount = accounts.value.find(account => account.uid === accountId) + if (savedAccount && editingAccount.value?.uid === accountId) { + editingAccount.value = { ...savedAccount } + } if (onRefreshConfig) { await onRefreshConfig() } @@ -402,7 +406,7 @@ const handleManualSign = async () => { if (response.code !== 200 && response.code !== 0) { throw new Error(response.message || t('gamesign.toast.signFailed')) } - logger.info('游戏签到完成') + logger.info('游戏社区签到完成') if (response.status === 'warning') { message.warning(response.message || t('gamesign.toast.signPartialNotify')) } else { diff --git a/frontend/src/views/gamesign/gameSignDisplay.ts b/frontend/src/views/gamesign/gameSignDisplay.ts index c09f24a36..a1d94c441 100644 --- a/frontend/src/views/gamesign/gameSignDisplay.ts +++ b/frontend/src/views/gamesign/gameSignDisplay.ts @@ -1,5 +1,5 @@ /** - * 游戏签到标签云的纯展示逻辑。 + * 游戏社区标签云的纯展示逻辑。 * * 这里全部是纯函数,不依赖 Vue 响应式和后端接口,方便单测覆盖。 * TabGameSign 的标签云(各社区已签/失败/风控状态)完全由这些函数推导, diff --git a/frontend/src/views/gamesign/index.vue b/frontend/src/views/gamesign/index.vue index c643b1617..15a70abee 100644 --- a/frontend/src/views/gamesign/index.vue +++ b/frontend/src/views/gamesign/index.vue @@ -1,7 +1,8 @@ diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 1f1436350..943f8665b 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -267,9 +267,18 @@ const routes = [ meta: { title: '游戏社区' }, }, { - path: '/gamesign/:section(sign|activity)', - name: 'CommunityToolSection', - component: () => import('../views/gamesign/index.vue'), + path: '/gamesign/sign', + name: 'CommunityToolSign', + redirect: '/gamesign', + meta: { title: '游戏社区' }, + }, + { + path: '/gamesign/activity', + name: 'CommunityToolActivity', + redirect: { + path: '/gamesign', + query: { tab: 'activity' }, + }, meta: { title: '游戏社区' }, }, { diff --git a/frontend/src/views/gamesign/index.vue b/frontend/src/views/gamesign/index.vue index 15a70abee..8fb6778a2 100644 --- a/frontend/src/views/gamesign/index.vue +++ b/frontend/src/views/gamesign/index.vue @@ -1,8 +1,8 @@