逐行精读源码生成。行号基于当前工作区版本,仅用于快速定位。
文件职责:src-tauri/src/services/account_service.rs(约 3320 行)是账户与状态中枢,集中管理「Hypergryph 登录(密码/验证码/扫码)→ 森空岛 cred/token → 角色列表与角色详情」的完整链路,并承担凭据持久化、双版本账户缓存(完整版/精简版)、懒加载开关、按角色授权 u8token、自动签到定时任务与前端事件通知。
u8token 粒度与来源(2026-11 二次改版):u8token(抽卡令牌)完全不持久化,每次同步现换。授权只是
roles[].u8Authorized一个布尔开关,不含任何凭据。换取走鹰角通行证链路:
hytoken→(granttype:1, appCodebe36d44aa36bfb5b)→ L2 →(u8_token_by_uid {uid})→ u8token。官服与 B 服同一条链、同一个 hytoken,差异只在渠道绑定 uid,换出的两把 token 互不通用 —— 因此roles[]必须保存bindingUid,否则无法为角色换令牌。旧的两条路线都已删除:① 账号级
account_token_*.u8token(渠道写死官服);②role_u8token_{roleId}(按角色持久化令牌,但仍走channelMasterId=1的旧链)。启动时migrate_legacy_u8token会把它们的遗留字段与凭据库条目一并清掉。
状态注册方式:lib.rs:199 调用 AccountService::new(config_service, skland_service, avatar_cache_service, network_service, app.handle().clone());lib.rs:206 包装为 Arc::new(Mutex::new(account_service))(其中 Mutex 为 tokio::sync::Mutex,见 lib.rs:3),lib.rs:207 执行 app.manage(managed_service)。因此所有 command 以 State<'_, Arc<Mutex<AccountService>>> 注入并在 .lock().await 后调用;lib.rs:215 另把克隆传给 AccountService::start_auto_refresh 启动后台定时器。注意:服务内部持有的 config_service 是 std::sync::Mutex<ConfigService>(lib.rs:151),与外层 tokio Mutex 不同。
主要依赖:
- services:
ConfigService(配置读写,account_token_*等键)、SklandService(check_cred/refresh_cred_by_hytoken/get_u8_token_by_uid/get_role_detail/get_player_binding/extract_endfield_roles/get_user_info/get_game_list/call_skland_api)、AvatarCacheService(get_or_download_avatar下载头像转 base64)、NetworkService(char_detail_service().get_with_cache、set_current_role_id、retain_only_char_detail、preload_all_char_details、query_role_data、get_current_role_id)。 - models:
account::{AccountInfo, AccountLoginResult, AccountRefreshResult, AccountSummary, RoleU8AuthResult, SklandAccountInfo, SklandGameInfo, SklandUserInfo}、login::{CodeLoginRequest, LoginRequest, ScanLoginInfo, ScanStatus, SendCodeRequest}、role::RoleDisplayInfo。 - utils:
http_client::create_client、capture::send(HTTP 请求 + 抓包日志)、AppError;日志宏log_debug! / log_info! / log_warn! / log_error!与tracing。 - 外部 crate:
serde_json、chrono(刷新时间戳)、tauri::{AppHandle, Emitter, async_runtime}、tokio。
| 区间 | 内容 |
|---|---|
| 1-21 行 | use 导入(models / services / utils / 日志宏 / tauri) |
| 24-26 行 | 常量 SKLAND_GAMES_CACHE_KEY = "skland_games_cache"、SKLAND_GAMES_CACHE_TTL_SECS = 12*60*60 |
| 24-46 行 | 常量与 RoleOwner 结构体(user_id / binding_uid / channel_master_id / server_id) |
| 43-48 行 | 自由函数 now_unix_secs()(当前 Unix 秒) |
| 50-55 行 | struct AsyncAccountService:供 async spawn 使用的精简克隆(仅 2 个服务引用) |
| 57-297 行 | AsyncAccountService::check_and_refresh_user_cred:cred 校验、过期刷新(不再顺带取 u8token) |
| 310-323 行 | pub struct AccountService 结构体与 8 个字段 |
| 325-360 行 | new 构造函数(读取 lazy_load_enabled,默认 true;调用 migrate_legacy_u8token) |
| 338-351 行 | 3 个服务访问器:get_config_service / skland_service / avatar_cache_service |
| 353-481 行 | set_lazy_load_enabled:懒加载开关切换 + 双缓存互转 + 角色详情预载/裁剪 |
| 483-494 行 | is_lazy_load_enabled、set_current_role_id |
| 496-557 行 | 账户缓存读写:cache_account_list / cache_account_summary / get_cached_accounts / clear_account_cache |
| 559-600 行 | gather_preload_role_infos:遍历 account_token_* 收集预加载所需角色+凭据 |
| 602-617 行 | clone_for_async 与对外的 check_and_refresh_user_cred(转发) |
| 32-341 行 | 各服务访问器、懒加载开关与双缓存、gather_preload_role_infos、clone_for_async、check_and_refresh_user_cred |
| 342-630 行 | 凭据字段写入器:set_hytoken_for_user、set_phone_for_user、set_device_token_for_user、get_local_device_token、notify_cache_refreshed、cache_accounts_by_user |
| 632-940 行 | 抽卡授权:authorize_role_u8token、revoke_role_u8token、is_role_u8_authorized、fetch_role_u8token、set_role_u8_authorized、find_role_owner、migrate_legacy_u8token、backfill_binding_uid |
| 约 1663-1810 行 | 账号移除:logout_account(解绑单角色)、delete_skland_account(删整账号)、batch_logout |
| 747-781 行 | get_local_device_token(私有)、set_device_token_for_user |
| 783-817 行 | notify_cache_refreshed(emit accounts-refreshed)、cache_accounts_by_user |
| 819-973 行 | 自动签到三件套:get_auto_sign_roles / check_attendance_today / do_attendance_for_role |
| 975-1016 行 | start_auto_refresh:300 秒定时器(刷新账户 + 自动签到) |
| 1018-1399 行 | get_accounts:全量同步(约 380 行,含 cred 刷新、失败状态、自动刷新重试) |
| 1401-1455 行 | add_account:密码登录入口 |
| 1457-1538 行 | logout_account(按 roleId 解绑角色)、batch_logout |
| 1540-1840 行 | refresh_accounts:手动/定时刷新(走 get_role_detail,完成后 emit) |
| 1842-1890 行 | send_verification_code:发送短信验证码 |
| 1892-1994 行 | get_hypergryph_token_by_code(私有)、add_account_by_code:验证码登录 |
| 1996-2118 行 | gen_scan_login、scan_status:扫码二维码生成与轮询 |
| 2120-2247 行 | token_by_scan_code(私有)、add_account_by_scan:扫码登录 |
| 2249-2390 行 | complete_login_from_token:三种登录方式的公共后续流程 |
| 2392-2477 行 | fetch_endfield_roles、read_skland_credentials、cache_skland_user_info |
| 2479-2573 行 | get_skland_user_info、get_skland_games(+ 缓存读写)、get_skland_account_roles |
| 2575-2652 行 | get_skland_accounts、save_skland_account |
| 2654-2810 行 | save_selected_roles:保存勾选角色、迁移/保留凭据、构建双缓存 |
| 2812-2847 行 | migrate_roles_app_field:为旧 roles 补 app: "endfield" |
| 2849-2920 行 | get_hypergryph_token(密码换取 hytoken)、is_new_device_verification_error |
| 2922-3062 行 | get_skland_code(OAuth grant)、get_skland_cred(code 换 cred/token/userId) |
| 3064-3096 行 | query_role_data(统一数据查询入口) |
AccountService(295-309 行)
| 字段 | 类型 | 含义 |
|---|---|---|
config_service |
Arc<Mutex<ConfigService>>(std Mutex) |
全局配置服务,承载 account_token_*、account_list 等所有持久化键 |
skland_service |
Arc<SklandService> |
森空岛 API 封装(cred 校验/刷新、角色详情、用户资料、游戏列表、attendance 等) |
avatar_cache_service |
Arc<AvatarCacheService> |
头像下载与本地缓存,返回 base64 |
network_service |
Arc<NetworkService> |
角色详情缓存与统一数据查询(char_detail_service 等) |
account_list_cache |
Arc<Mutex<HashMap<String, Vec<AccountInfo>>>> |
完整版账户缓存,key 为 user_id;仅非懒加载时写入/读取 |
account_summary_cache |
Arc<Mutex<HashMap<String, Vec<AccountSummary>>>> |
精简版账户缓存(无 cred/token),仅懒加载时使用 |
lazy_load_enabled |
Arc<Mutex<bool>> |
懒加载开关(内存态,初始值来自配置 lazy_load_enabled) |
app_handle |
Option<AppHandle> |
Tauri AppHandle,用于 emit("accounts-refreshed", …) |
AsyncAccountService(37-41 行,私有)
| 字段 | 类型 | 含义 |
|---|---|---|
config_service |
Arc<Mutex<ConfigService>> |
与主服务共享的配置服务 |
skland_service |
Arc<SklandService> |
cred 校验/刷新所需 |
MissingDeviceTokenPolicy(286-293 行,私有枚举):已删除。绑定流程不再因为缺 device token 中断,只有「密码接口自身拒绝新设备」会返回 NEW_DEVICE_VERIFICATION_REQUIRED(此时还没拿到 hytoken,无法继续)。
共 57 个函数:37 个 pub、20 个私有(含 1 个自由函数、1 个静态关联函数、1 个 AsyncAccountService 方法)。私有项以「(私有)」标注。
| 方法 | 位置 | 说明(输入 → 做什么 → 输出;调用的外部 API/命令) |
|---|---|---|
now_unix_secs()(私有自由函数) |
29 | 无输入 → 取当前 Unix 秒(失败返回 0)→ i64 |
AsyncAccountService::check_and_refresh_user_cred(私有) |
45 | user_id → 读 account_token_{user_id} 取 cred/token/hytoken/device_token;SklandService::check_cred 有效 → Ok(None)(不再回填 u8token);失效或 check_cred 调用失败则调 refresh_cred_by_hytoken(返回三元组)换新 cred/token 写回配置 → Ok(Some((cred, token)));缺 hytoken → AppError::AuthError |
new |
312 | 4 个服务 + AppHandle → 读 lazy_load_enabled(默认 true)、初始化空缓存与开关 → AccountService |
get_config_service |
338 | 无 → 返回 &Arc<Mutex<ConfigService>>(供 command 直接读写配置,如 selected_account_id) |
skland_service |
343 | 无 → 返回 &Arc<SklandService>(commands/attendance.rs 借此调 call_skland_api) |
avatar_cache_service |
348 | 无 → 返回 &Arc<AvatarCacheService>(commands/attendance.rs 用) |
set_lazy_load_enabled |
353 | enabled: bool → 写配置 lazy_load_enabled;开启:完整缓存转精简后清空、network_service.retain_only_char_detail(当前角色);关闭:用 account_token_* 的 cred/token 把精简合成完整、清空精简缓存、gather_preload_role_infos() + network_service.preload_all_char_details() 预载全部角色详情 → Ok(()) |
is_lazy_load_enabled |
483 | 无 → 读内存开关 → bool |
set_current_role_id |
488 | role_id: Option<String> → 取当前懒加载状态并转发 network_service.set_current_role_id(role_id, lazy) → (async,无返回值) |
cache_account_list |
496 | user_id, Vec<AccountInfo> → 写入完整版缓存 → 无 |
cache_account_summary |
503 | user_id, Vec<AccountSummary> → 写入精简版缓存 → 无 |
get_cached_accounts |
510 | user_id → 懒加载时读精简缓存并转成 cred/token = None 的完整结构;否则读完整缓存 → Option<Vec<AccountInfo>>(commands/account.rs:40 命中则免 API) |
clear_account_cache |
551 | user_id → 同时移除完整版与精简版缓存 → 无 |
gather_preload_role_infos(私有) |
560 | 无 → 遍历所有 account_token_* 的 roles[],配对 cred/token → Vec<PreloadRoleInfo>(network_service 用) |
clone_for_async(私有) |
602 | 无 → 构造 AsyncAccountService(克隆 2 个 Arc)→ 供 spawn 使用 |
check_and_refresh_user_cred |
610 | user_id → clone_for_async() 后转发同名私有方法 → Result<Option<(String, String)>, AppError>;被 command check_and_refresh_cred、attendance/gacha command 调用 |
set_hytoken_for_user |
619 | user_id, hytoken → 在 account_token_{user_id} 对象写入 hytoken 字段(非对象则 ConfigError)→ Ok(()) |
set_u8token_for_user |
— | 已删除。u8token 不再持久化 |
refresh_u8token_for_user |
— | 已删除。改为抽卡同步前现换:fetch_role_u8token |
is_role_u8_authorized |
700 附近 | role_id → 遍历 account_token_* 的 roles[],看该角色的 u8Authorized 是否为 true → bool |
authorize_role_u8token |
640 | role_id → find_role_owner 取 user_id + binding_uid → fetch_role_u8token 现换一次令牌做校验(不保存)→ 成功则 set_role_u8_authorized(true) 返回 authorized,失败返回 failed + 原因 → RoleU8AuthResult;command authorize_role_u8token |
revoke_role_u8token |
672 | role_id → 找到所属账号,把该角色 roles[].u8Authorized 移除并落盘 → bool;command revoke_role_u8token |
fetch_role_u8token(pub) |
736 | user_id, binding_uid → 读 account_token_{user_id}.hytoken(缺失报 AuthError)→ SklandService::get_u8_token_by_uid → String(调用方不得持久化);抽卡同步前调用 |
set_role_u8_authorized(私有) |
770 | user_id, role_id, authorized → 改 roles[].u8Authorized 并落盘 |
find_role_owner(私有) |
800 | 遍历 account_token_* 的 roles[] 找 roleId → RoleOwner { user_id, binding_uid, channel_master_id, server_id };找不到 AuthError |
resolve_arknights_target(私有) |
931 | role_id → 遍历 account_token_*.roles[],校验该角色 app == "arknights"(否则 ConfigError 提示数据源只服务明日方舟角色)→ (user_id, bindingUid);bindingUid 为空时报「请重新绑定」 |
arknights_credentials(私有) |
979 | user_id → read_skland_credentials 取 cred/token,再尝试 check_and_refresh_user_cred(成功用新值;失败回退旧值只记 warn,不阻断数据查询)→ (cred, token) |
get_arknights_player_info |
998 | role_id, force → resolve_arknights_target + arknights_credentials → ArknightsService::refresh_player_info(force=false 时先读 arknights_service.get_cached_player_info)→ serde_json::Value;command get_arknights_player_info |
get_arknights_cultivate |
1035 | role_id → 同上解析凭据 → ArknightsService::fetch_cultivate → Value;command get_arknights_cultivate |
get_arknights_attendance |
1053 | role_id → ArknightsService::attendance_status → 包成 {code, message, data, timestamp} 外壳(arknights_attendance_envelope,与终末地响应同形,前端解析器依赖 json.data)→ Value;data 内含后端算好的 hasToday / todayIndex / tomorrowIndex;command get_arknights_attendance |
do_arknights_attendance |
1071 | role_id → ArknightsService::do_attendance → 同样包成 {code, message, data} 外壳(code 透传真实业务码 0 / 10001);data 为 ArknightsAttendanceResult(awards[] + tomorrowAwards[],已签到时 alreadySigned = true);command do_arknights_attendance |
migrate_legacy_u8token(私有静态) |
836 | 启动时清理遗留:删除所有 role_u8token_* 键(含凭据库条目)+ 删除 account_token_*.u8token 字段与条目 |
backfill_binding_uid(私有) |
890 | 拿到 binding 列表时,为老数据里缺 bindingUid 的角色按 roleId 回填 uid / channelMasterId / channelName |
get_local_device_token(私有) |
747 | user_id → 读 account_token_{user_id}.device_token(空串视为无)→ Option<String> |
set_device_token_for_user |
759 | user_id, device_token → 写入 device_token 字段 → Ok(()) |
set_phone_for_user |
652 | user_id, phone → 写入 account_token_{user_id}.phone(仅用于重新登录时短信/密码方式自动填充,不参与鉴权)→ Ok(()) |
notify_cache_refreshed(私有) |
789 | &AccountRefreshResult → 有 app_handle 时 emit("accounts-refreshed", result) → 无 |
cache_accounts_by_user(私有) |
796 | &[AccountInfo] → 按 user_id 分组,按懒加载状态分别写精简/完整缓存 → 无 |
get_auto_sign_roles |
819 | 无 → 读配置 card_auto_sign_users → Vec<String>(role_id 列表) |
check_attendance_today |
1291 | role_id → 先试 resolve_arknights_target:命中则走 ArknightsService::attendance_status(GET /api/v1/game/attendance?gameId=1&uid=)并取其 hasToday;否则遍历 account_token_* 找该角色的 serverId/cred/token → SklandService::call_skland_api("GET", "/web/v1/game/endfield/attendance")(头 sk-game-role: 3_{role}_{server}、token)→ 解析 data.hasToday → bool |
do_attendance_for_role |
1379 | role_id → 先试 resolve_arknights_target:命中则走 ArknightsService::do_attendance(POST /api/v1/game/attendance);否则同上定位凭据(额外取 user_id)→ check_and_refresh_user_cred 刷新 → call_skland_api("POST", "/web/v1/game/endfield/attendance") → Ok(());code!=0 但属于 is_benign_business_error(已签到)时同样视为成功 |
start_auto_refresh(静态) |
975 | Arc<tokio::sync::Mutex<AccountService>> → async_runtime::spawn 每 300 秒:refresh_accounts() → 对 card_auto_sign_users 中未签到角色执行 do_attendance_for_role → 无;由 lib.rs:215 启动 |
get_accounts |
1018 | 无 → 遍历 account_token_* 收集 roles → 逐角色 check_and_refresh_user_cred → network_service.char_detail_service().get_with_cache(...) → 成功取昵称/等级并 avatar_cache_service.get_or_download_avatar → 失败按错误串判定 HYTOKEN_EXPIRED/FAILED,并可用 hytoken refresh_cred_by_hytoken 后重试一次 → 按懒加载写缓存 → Vec<AccountInfo> |
add_account |
1401 | LoginRequest(含可选 expectedUserId)→ get_hypergryph_token(密码) →(新设备错误经 is_new_device_verification_error 判定)返回 error_message = "NEW_DEVICE_VERIFICATION_REQUIRED" → 否则 complete_login_from_token(RequireUserVerification, expected_user_id, Some(phone)) → AccountLoginResult;command add_account |
logout_account |
约 1663 | account_id(实为 roleId)+ keep_device_token → 从各 account_token_* 的 roles[] 移除该角色;roles 清空且 keep_device_token=false 时删除 device_token;清对应用户缓存;从 account_list 移除 → 恒 true;command logout_account。只解绑角色,保留父账户 |
delete_skland_account |
约 1737 | user_id → config.remove("account_token_{user_id}")(连带清凭据库)→ 从 account_list 移除该账号全部 roleId → 清缓存 → selected_account_id 指向被删账号时改选剩余第一个(无则置空)→ Result<bool>;command delete_skland_account |
batch_logout |
1523 | account_ids → 从 account_list 移除,并 config.remove("account_token_{id}") → 恒 true;command batch_logout |
refresh_accounts |
1540 | 无 → 同 get_accounts 的收集/刷新逻辑,但角色详情走 skland_service.get_role_detail(绕过 char_detail 缓存),含 hytoken 自动刷新重试;完成后 cache_accounts_by_user + notify_cache_refreshed(emit)→ AccountRefreshResult;command refresh_accounts 与定时器 |
send_verification_code |
1842 | SendCodeRequest → POST https://as.hypergryph.com/general/v1/send_phone_code(body: phone/type)→ status==0 → Ok(true);command send_verification_code |
get_hypergryph_token_by_code(私有) |
1892 | phone, code → POST https://as.hypergryph.com/user/auth/v2/token_by_phone_code → (token, deviceToken) |
add_account_by_code |
1961 | CodeLoginRequest(含可选 expectedUserId)→ 上一步换 hytoken(deviceToken 空则置 None)→ complete_login_from_token(ContinueWithoutDeviceToken, expected_user_id, Some(phone)) → AccountLoginResult;command add_account_by_code |
gen_scan_login |
1996 | 无 → POST https://as.hypergryph.com/general/v1/gen_scan/login(appCode dd7b852d5f1dd9da)→ ScanLoginInfo { scan_id, scan_url };command gen_scan_login |
scan_status |
2063 | scan_id → GET https://as.hypergryph.com/general/v1/scan_status?scanId=... → ScanStatus { status, scan_code, msg };command scan_status |
token_by_scan_code(私有) |
2120 | scan_code → POST https://as.hypergryph.com/user/auth/v1/token_by_scan_code(带 X-AppCode/X-DeviceId/X-DeviceId2/X-DeviceModel/X-DeviceType/X-OSVer 头)→ (token, deviceToken, Option<hgld>) |
add_account_by_scan |
2211 | scan_code, expected_user_id → 上一步换 hytoken(deviceToken 空转 None)→ complete_login_from_token(ContinueWithoutDeviceToken, expected_user_id, None) → AccountLoginResult;command add_account_by_scan(参数 scanCode + 可选 expectedUserId) |
complete_login_from_token(私有) |
2432 | hy_token, device_token, expected_user_id, phone → Step2 get_skland_code、Step3 get_skland_cred → Step3.1 若 expected_user_id 非空且与登录得到的 user_id 不同,返回 error_message = "ACCOUNT_MISMATCH:<实际userId>" 且不落盘任何凭证 → 无 deviceToken 时用 userId 走 get_local_device_token 本地匹配;仍无也不中断(只记 warn,u8token 留待用户按角色授权)→ set_hytoken_for_user / set_phone_for_user(有手机号时)/ set_device_token_for_user(有时)→ AccountLoginResult { success: true, cred, token, user_id }(不返回角色,也不取 u8token) |
fetch_game_roles(私有) |
2762 | cred, token, user_id → get_player_binding + SklandService::extract_game_roles → 逐角色组装 Vec<RoleDisplayInfo>(单角色失败仅记日志跳过);每个角色的 u8_authorized 由 is_role_u8_authorized 回填。终末地角色再调 get_role_detail 补头像与精确等级;明日方舟角色只用绑定接口给的昵称/等级/服务器(avatar_url 为空串) |
read_skland_credentials(私有) |
2434 | user_id → 读 account_token_{user_id} 的 cred/token → (String, String);缺失抛 AuthError("Skland account/cred/token was not found") |
cache_skland_user_info(私有) |
2459 | user_id, &SklandUserInfo → 序列化写入 account_token_{user_id}.user_info → Ok(()) |
get_skland_user_info |
2485 | user_id → check_and_refresh_user_cred → read_skland_credentials → SklandService::get_user_info → 缓存 user_info(失败仅告警)→ SklandUserInfo;command get_skland_user_info |
get_skland_user_info_by_cred |
2504 | cred, token → SklandService::get_user_info(不读本地配置,用于「确认绑定森空岛账户」页展示头像/昵称,此时账户尚未保存)→ SklandUserInfo;command get_skland_user_info_by_cred |
get_skland_games |
2508 | user_id, force → 先读 skland_games_cache(12 小时 TTL,非 force 且未过期直接返回)→ 否则刷新 cred + SklandService::get_game_list 写缓存;失败回退旧缓存 → Vec<SklandGameInfo>;command get_skland_games |
read_skland_games_cache(私有) |
2550 | 无 → 读 skland_games_cache 的 fetched_at/games → Option<(i64, Vec<SklandGameInfo>)>(空列表视为无缓存) |
cache_skland_games(私有) |
2563 | &[SklandGameInfo] → 写 skland_games_cache = { fetched_at, games } → Ok(()) |
get_skland_account_roles |
2575 | user_id → 刷新 cred → read_skland_credentials → fetch_endfield_roles → AccountLoginResult { available_roles: Some(..) };command get_skland_account_roles |
get_skland_accounts |
2601 | 无 → 扫描 account_token_*,skland_account_bound(缺省时以 roles 非空判定)为真才收录,附带 user_info 的昵称/头像与首次登录记录的 phone → Vec<SklandAccountInfo>;command get_skland_accounts |
save_skland_account |
2628 | cred, token, user_id → 写 cred/token/skland_account_bound=true,并保证 roles 存在(默认 [])→ Ok(());command save_skland_account |
save_selected_roles |
3006 | cred, token, user_id, selected_roles → 先 migrate_roles_app_field;读旧数据保留 hytoken/device_token/phone/user_info;写入 roles[{userId,serverId,roleId,app,nickname,level,bindingUid,channelMasterId,channelName}](app 取 RoleDisplayInfo.game,空则 endfield)、skland_account_bound=true;更新 account_list(移除旧 roleId、追加新 roleId);锁释放后逐角色下载头像 → 写完整+精简双缓存 → Vec<AccountInfo>;command save_selected_roles |
migrate_roles_app_field(私有静态) |
3248 | &mut ConfigService → 遍历 account_token_*,为缺少 app 的角色补 "endfield" 并写回(该字段在支持明日方舟之前不存在,那时的角色只可能来自终末地)→ Ok(()) |
get_hypergryph_token(私有) |
2849 | phone, password → POST https://as.hypergryph.com/user/auth/v1/token_by_phone_password → (token, Option<deviceToken>, Option<hgld>) |
is_new_device_verification_error(私有静态) |
2922 | &AppError → 关键词匹配("新设备"/"需要验证"/"设备验证"/"verify"/"verification")→ bool |
get_skland_code(私有) |
2932 | hy_token → POST https://as.hypergryph.com/user/oauth2/v2/grant(appCode 4ca99fa6b56cc2ba, type 0)→ data.code → String |
get_skland_cred(私有) |
2989 | sk_code → POST https://zonai.skland.com/api/v1/user/auth/generate_cred_by_code(kind 1)→ (cred, token, user_id) |
query_role_data |
3064 | role_id, api_name, paths → get_accounts() 找到该角色(需 cred/token/user_id/server_id 齐全)→ network_service.query_role_data(...) → HashMap<String, Value>(key 为请求路径);command query_role_data,另被 commands/gacha.rs:479 以 "wiki_catalog" 调用 |
add_account(LoginRequest) → get_hypergryph_token(POST /user/auth/v1/token_by_phone_password)得 (hytoken, deviceToken?, hgld?);若 API 报错且 is_new_device_verification_error 命中,直接返回 NEW_DEVICE_VERIFICATION_REQUIRED。随后进入 complete_login_from_token(RequireUserVerification)。注意:hgld 目前仅在日志中使用,本地 device token 匹配实际以 Step 3 返回的 user_id 为键。
send_verification_code(POST /general/v1/send_phone_code)→ add_account_by_code(CodeLoginRequest) → get_hypergryph_token_by_code(POST /user/auth/v2/token_by_phone_code)→ complete_login_from_token(ContinueWithoutDeviceToken)。
gen_scan_login(POST /general/v1/gen_scan/login)产出二维码 scanUrl → 前端轮询 scan_status(GET /general/v1/scan_status)取 scanCode → add_account_by_scan(scan_code) → token_by_scan_code(POST /user/auth/v1/token_by_scan_code,附固定 X-Device* 头)→ complete_login_from_token(ContinueWithoutDeviceToken)。
Step 2 get_skland_code(POST /user/oauth2/v2/grant)→ Step 3 get_skland_cred(POST zonai.skland.com/.../generate_cred_by_code)得 (cred, token, user_id) → Step 3.1 重新登录校验(expected_user_id 非空且与实际 user_id 不同 → 返回 ACCOUNT_MISMATCH:<实际userId>,不保存任何凭证,用于「扫码/短信授权到了别的账号」的拦截;前端据此清空本次登录结果并提示)→ Step 3.5 服务器未下发 deviceToken 时用 get_local_device_token(user_id) 本地匹配 → 仍缺失也继续绑定(只记 warn;该账号暂时无法获取 u8token)→ 依次落盘 hytoken、phone(本次登录带手机号时)、device_token(有时)→ 返回父级凭据,角色绑定推迟到 save_selected_roles。
改版说明:
device_token与抽卡令牌无关了(新链路不需要它)。缺它不再阻断登录,也不再触发强制新设备验证。
authorize_role_u8token(role_id):find_role_owner拿到binding_uid→ 现换一次令牌做校验(令牌不保存)→ 成功才把roles[].u8Authorized = true落盘。所以「授权」= 验证一次 + 记一个开关。revoke_role_u8token(role_id):只移除u8Authorized开关,没有凭据需要删除。- 抽卡同步前由
fetch_role_u8token(user_id, binding_uid)现换令牌;同步命令还会先检查u8Authorized,未授权的角色直接报错提示去账号页授权。 - 官服与渠道服没有分支:同一条
u8_token_by_uid链路,差异只在binding_uid。 - 解绑/登出:授权开关随
roles[]一起消失(令牌本就不落盘)。 backfill_binding_uid负责把老数据的渠道 uid 补上(否则旧账号无法换令牌)。
账户数据获取频繁失败时应用照常进入(api_health 的 app:api-health 只收起 Splash,非致命),账户页与侧边栏展示「账号不可用」并提供「重新登录」入口:
- 登录方式选择页不要求输入手机号,只需选择方式;选短信/密码后才使用/输入手机号(首次登录成功即把手机号写入
account_token_{user_id}.phone,get_skland_accounts一并返回,供自动填充)。 - 扫码方式在授权成功后用
expected_user_id校验是否为同一森空岛账户,不一致即拦截(上方 Step 3.1),凭证不会落盘。 - 数美设备指纹接口连续失败(
ApiFailureKind::DeviceFingerprint)时前端提示「重启电脑并同步时区」;修复后点击重试会调用reset_api_health解除熔断。
- 主入口
check_and_refresh_user_cred(610)转发到AsyncAccountService(57):check_cred有效 → 直接返回Ok(None)(不再回填 u8token);失效或check_cred报错 →refresh_cred_by_hytoken(hyt token, device_token)换新 cred/token 并写回account_token_{user_id};无 hytoken →AuthError(提示 re-login)。 refresh_cred_by_hytoken已删除 u8 分支(原 Step 1.5),返回(cred, token, user_id)三元组 —— 换 cred 不再有任何「顺手取令牌」的副作用。get_accounts/refresh_accounts内部:每次取角色详情前刷新;错误串含hytoken/OAuth/grant failed→ 标记HYTOKEN_EXPIRED;错误串含API error/Failed to parse JSON/HTTP request failed→ 视为同步失败,再用 hytoken 自动刷新并重试一次详情接口。- u8token 不再「续期」,而是每次同步前用
fetch_role_u8token现换(不缓存、不落盘)。
get_accounts(1018):走network_service.char_detail_service().get_with_cache(带角色详情缓存),结果按user_id分组后按懒加载状态写入完整/精简缓存。refresh_accounts(1540):走skland_service.get_role_detail(实时接口,不经 char_detail 缓存),完成后cache_accounts_by_user+ emit。query_role_data(3064):统一数据查询入口,依赖get_accounts()提供凭据,再委托NetworkService::query_role_data按paths取叶节点。- 缓存四件套(496/503/510/551)+
gather_preload_role_infos(560)支撑懒加载与预载。
get_skland_user_info → 刷新 cred → read_skland_credentials → SklandService::get_user_info → 写 account_token_{user_id}.user_info(供 get_skland_accounts 列表展示昵称/头像)。get_skland_games → skland_games_cache(12 小时 TTL,force 可绕过;刷新失败回退旧缓存)。get_skland_account_roles → fetch_endfield_roles(binding → 逐角色 detail → 头像)。
一个森空岛账户可以同时绑定《明日方舟:终末地》和《明日方舟》,两者的角色都进同一个 roles[],用 app 字段区分(endfield / arknights)。差异只在与「怎么拿角色展示信息」:
| 终末地 | 明日方舟 | |
|---|---|---|
| 展示数据来源 | GET /api/v1/game/endfield/card/detail |
GET /api/v1/game/player/info?uid=(由 ArknightsService 拉取) |
| 头像 | 详情接口的 base.avatarUrl,下载转 base64 |
player/info 的 status.avatar.url,同样转 base64(这是明日方舟唯一的头像来源) |
账户列表(get_accounts) |
走 char_detail_service().get_with_cache |
读 ArknightsService 缓存(内存 → <app_data>/arknights/player_info_{uid}.json),缓存缺失才回退 roles[] 里的绑定快照(AccountService::arknights_account) |
手动刷新(refresh_accounts) |
走 skland_service.get_role_detail |
调 ArknightsService::refresh_player_info 重拉;失败不标 FAILED(还有缓存可用,标了会让账户页误报「账号不可用」) |
| 懒加载预载 | 参与 | 跳过(gather_preload_role_infos 过滤掉) |
query_role_data |
支持 | 直接报错(该数据源只服务终末地) |
| 公开方法 | query_role_data |
get_arknights_player_info / get_arknights_cultivate / get_arknights_attendance / do_arknights_attendance |
关键实现:
SklandService::extract_game_roles是唯一入口,按app_code识别游戏并跳过is_delete的渠道绑定;未知app_code记 debug 日志后忽略。AccountService::fetch_game_roles按game分支:终末地角色调get_role_detail补头像与精确等级;明日方舟角色调ArknightsService::refresh_player_info(绑定时就拉一次,昵称/等级/头像一并确定并落盘缓存)。拉取失败不阻断绑定,退回绑定接口给的昵称/等级、头像留空。save_selected_roles把app/nickname/level/bindingUid一并落盘 ——bindingUid既是换抽卡令牌的键,也是明日方舟所有数据接口的寻址键(?uid=)。backfill_binding_uid除了补bindingUid,还会给缺app的老角色按绑定列表补上正确的游戏(而不只是endfield)。RoleDisplayInfo.game/AccountInfo.game/AccountSummary.game三处都带#[serde(default = "default_game")],老缓存/老配置缺字段时按终末地处理。- 解绑/删号会连带清明日方舟缓存:
logout_account与delete_skland_account收集该账号下app == "arknights"角色的bindingUid,调ArknightsService::clear_cache(uid)删掉player_info_{uid}.json与原始留档 —— 否则换绑别的明日方舟账号会读到上一个角色的数据。注意两处都必须先drop(config)再清缓存(std::sync::Mutex不可重入,之前的revoke_role_u8token就踩过这个坑)。
已接入:明日方舟的
player/info(干员/基建/任务/剿灭/保全/公招)、养成+仓库、签到状态与执行。未接入:抽卡记录(走 u8token 的终末地链路,明日方舟不适用)。前端的按游戏分流界面也尚未接入 —— 当前后端只保证数据能正确拉取、解析、缓存与标识。
- 绑定:
save_skland_account写cred/token/skland_account_bound/roles;save_selected_roles写roles[](含app/nickname/level/bindingUid)、保留hytoken/device_token/phone/ 已缓存的user_info、同步account_list、清理被解绑角色的role_u8token_*,并写双缓存。 - 解绑:
logout_account按 roleId 从roles[]移除(可选删除device_token)并清缓存;batch_logout清account_list与account_token_{id}。 - 迁移:
migrate_roles_app_field给旧角色补app字段(在save_selected_roles开头执行);backfill_binding_uid在拿到绑定列表时按真实游戏回填app。
set_lazy_load_enabled 在两种模式间转换缓存:开启 → 精简缓存补全 + 清空完整缓存 + retain_only_char_detail(当前角色);关闭 → 用 account_token_* 的 cred/token 合成完整缓存 + preload_all_char_details 预载全部角色。set_current_role_id 把激活角色同步给 NetworkService(影响详情缓存裁剪)。
本文件唯一的 emit:notify_cache_refreshed 发送事件 accounts-refreshed,payload 为 AccountRefreshResult { success, error_message, accounts, refresh_time },仅在 refresh_accounts 末尾触发(约 1836)。文件内无任何 listen。start_auto_refresh 以 300 秒周期执行刷新 + 自动签到。
配置键(本文件读写)
account_token_{user_id}:核心凭据对象,字段含cred、token、hytoken、device_token、phone、roles[{userId,serverId,roleId,app,nickname,level,bindingUid,channelMasterId,channelName}]、skland_account_bound、user_info(SklandUserInfo 序列化)。roles[].app区分游戏:endfield(终末地)/arknights(明日方舟);缺失时按终末地处理。roles[].nickname/level是绑定时的快照 —— 明日方舟角色只能靠它们展示(没有终末地详情接口)。⚠️ 账号级u8token已废弃:启动时由migrate_legacy_u8token删除;secret_registry仍登记它只为能清理旧凭据。save_selected_roles重建该对象时会保留hytoken/device_token/phone/user_info(后两者以前会被覆盖丢失,已修正),并写入每个角色的bindingUid/channelMasterId/channelName。
role_u8token_{roleId}:已废弃,启动时由migrate_legacy_u8token删除(含凭据库条目)。- 抽卡授权:不存在独立配置键 —— 它就是
account_token_{user_id}.roles[].u8Authorized(布尔,明文)。u8token 本身从不落盘。 skland_games_cache:森空岛游戏列表缓存(常量SKLAND_GAMES_CACHE_KEY,TTL 12 小时,结构{fetched_at, games})。lazy_load_enabled:懒加载开关(bool,默认 true)。card_auto_sign_users:开启自动签到的 role_id 列表(只读)。account_list:角色 ID 列表(登出/绑定时增删)。selected_account_id:当前选中账户,由commands/account.rs通过get_config_service()读写,本文件不涉及(仓库中不存在selected_account键)。
HTTP 端点(本文件直接请求,均经 http_client::create_client + capture::send)
POST https://as.hypergryph.com/general/v1/send_phone_code(1857)POST https://as.hypergryph.com/user/auth/v2/token_by_phone_code(1911)POST https://as.hypergryph.com/general/v1/gen_scan/login(2011)GET https://as.hypergryph.com/general/v1/scan_status(2074)POST https://as.hypergryph.com/user/auth/v1/token_by_scan_code(2139)POST https://as.hypergryph.com/user/auth/v1/token_by_phone_password(2868)POST https://as.hypergryph.com/user/oauth2/v2/grant(2948)POST https://zonai.skland.com/api/v1/user/auth/generate_cred_by_code(3004)- 经
SklandService::call_skland_api:GET/POST /web/v1/game/endfield/attendance(约 879、约 950,基址https://zonai.skland.com)。 - 其余森空岛读接口(user/game/binding/role detail/check_cred/refresh/u8token)全部委托
SklandService。
emit / listen 事件
- emit:
accounts-refreshed(789,payloadAccountRefreshResult)。 - listen:无。
调用方(command / 内部)
commands/account.rs:get_accounts、get_skland_accounts、save_skland_account、get_skland_account_roles、get_skland_user_info、get_skland_user_info_by_cred、get_skland_games、add_account、logout_account、batch_logout、refresh_accounts、send_verification_code、add_account_by_code、gen_scan_login、scan_status、add_account_by_scan、save_selected_roles、get_selected_account/set_selected_account(仅经get_config_service)、check_and_refresh_cred、set_lazy_load_enabled、is_lazy_load_enabled、set_current_role_id、query_role_data、authorize_role_u8token/revoke_role_u8token(新增)。commands/attendance.rs:get_attendance/do_attendance使用get_config_service、check_and_refresh_user_cred、skland_service、avatar_cache_service。commands/gacha.rs:check_and_refresh_user_cred、fetch_role_u8token(现换令牌)、query_role_data(wiki_catalog)、get_config_service。commands/card_config.rs:仅get_config_service。- 内部定时器
start_auto_refresh:refresh_accounts、get_auto_sign_roles、check_attendance_today、do_attendance_for_role(这三者未暴露为 command)。
发现的异常/注意点
- 任务提示中的
selected_account在仓库中实际为selected_account_id,且只出现在commands/account.rs。 batch_logout(约 1595)把入参当作 user_id 拼account_token_{id}删除,而logout_account(约 1490)把同名入参当作 roleId 处理,两者语义不一致,batch_logout多半删不掉真实凭据键。set_lazy_load_enabled内两个分支的锁顺序相反(先完整后精简 / 先精简后完整),并发调用存在理论上的锁顺序风险。save_selected_roles中data.as_object_mut().expect("object checked above")为可 panic 点;get_accounts等处&role_id[..8.min(role_id.len())]按字节截断,非 ASCII roleId 会 panic。get_network_service与访问器network_service为死代码,已删除;AsyncAccountService的avatar_cache_service字段亦未使用,已删除。- 渠道服已打通:官服与 B 服共用同一条
u8_token_by_uid链路(同一个 hytoken / appCode),差异只在roles[].bindingUid。代价是 appCodebe36d44aa36bfb5b属于鹰角前端硬编码值,存在轮换风险。