feat(simyo): 完善 eSIM 更换流程(仅 EMAIL)

- 修 handleApiResponse:登录/查询体无 result.success 时正确判定成功
- 设备更换:先 GET settings/simcard,再 POST EMAIL 申请;等待验证码时跳过重复下单
- 登录识别 mfaStatus,未关闭 MFA 时明确阻断
- bad session 单独文案;UA 更新为 iOS 18.2 / iPhone12,8
This commit is contained in:
Abner
2026-07-22 19:19:04 +08:00
parent 70766c3a32
commit 0a70ce47cb
16 changed files with 397 additions and 139 deletions

View File

@@ -41,13 +41,13 @@ ACCESS_KEY=
SIMYO_CLIENT_TOKEN=
# 可选:固定 X-Device-IDUUID官方 App 每个请求必带;未设则进程内自动生成)
# SIMYO_DEVICE_ID=
# 可选:覆盖默认客户端身份(默认对齐 iOS 4.28.0 / iOS 27.0 / iPhone16,1 抓包)
# 可选:覆盖默认客户端身份(默认对齐 iOS 4.28.0 / iOS 18.2 / iPhone12,8 抓包)
# 与 src/simyo/js/modules/client-identity.js 保持同步
# SIMYO_CLIENT_VERSION=4.28.0
# SIMYO_CLIENT_PLATFORM=ios
# SIMYO_IOS_VERSION=27.0
# SIMYO_DEVICE_MODEL=iPhone16,1
# SIMYO_USER_AGENT=MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)
# SIMYO_IOS_VERSION=18.2
# SIMYO_DEVICE_MODEL=iPhone12,8
# SIMYO_USER_AGENT=MijnSimyoFT/4.28.0 (iOS 18.2; iPhone12,8)
# Giffgaff OAuth token exchange 回调 URI
# 注意:该变量只影响服务端 token exchange前端授权跳转 URI 仍在 src/giffgaff/js/modules/api-config.js 中配置。

View File

@@ -35,22 +35,14 @@
# API代理重定向用于解决CORS问题
# 通用 Simyo API 代理 (webapi - 新版API)
# User-Agent 覆盖为官方 iOS 4.28.0 抓包值(浏览器无法可靠设置 UA
# User-Agent 覆盖为官方 iOS 4.28.0 eSIM 更换抓包值(浏览器无法可靠设置 UA
# 与 src/simyo/js/modules/client-identity.js 保持同步
[[redirects]]
from = "/api/simyo/*"
to = "https://appapi.simyo.nl/webapi/api/v1/:splat"
status = 200
force = true
headers = {X-Forwarded-Host = "appapi.simyo.nl", User-Agent = "MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)"}
# v2 API 代理(用于特定端点如可用验证方式查询)
[[redirects]]
from = "/api/simyo/esim.availableValidationMethods"
to = "https://appapi.simyo.nl/webapi/api/v2/esim.availableValidationMethods"
status = 200
force = true
headers = {X-Forwarded-Host = "appapi.simyo.nl", User-Agent = "MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)"}
headers = {X-Forwarded-Host = "appapi.simyo.nl", User-Agent = "MijnSimyoFT/4.28.0 (iOS 18.2; iPhone12,8)"}
# 保留旧版 simyoapi 路径的兼容性(如果需要)
[[redirects]]
@@ -58,7 +50,7 @@
to = "https://appapi.simyo.nl/simyoapi/api/v1/:splat"
status = 200
force = true
headers = {X-Forwarded-Host = "appapi.simyo.nl", User-Agent = "MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)"}
headers = {X-Forwarded-Host = "appapi.simyo.nl", User-Agent = "MijnSimyoFT/4.28.0 (iOS 18.2; iPhone12,8)"}
[[headers]]
# 限制 Simyo 代理仅允许指定站点跨域访问

View File

@@ -29,11 +29,11 @@ const { parseOrigins, isAllowedOrigin: _isAllowedOrigin, resolveCorsOrigin: _res
const origins = parseOrigins(process.env.ALLOWED_ORIGIN);
const isAllowedOrigin = (origin) => _isAllowedOrigin(origin, origins);
const getCorsOrigin = (origin) => _resolveCorsOrigin(origin, origins);
// 与 src/simyo/js/modules/client-identity.js 保持同步(官方 iOS 4.28.0 抓包
// 与 src/simyo/js/modules/client-identity.js 保持同步(官方 iOS 4.28.0 eSIM 更换 HAR
const DEFAULT_SIMYO_CLIENT_PLATFORM = 'ios';
const DEFAULT_SIMYO_CLIENT_VERSION = '4.28.0';
const DEFAULT_SIMYO_IOS_VERSION = '27.0';
const DEFAULT_SIMYO_DEVICE_MODEL = 'iPhone16,1';
const DEFAULT_SIMYO_IOS_VERSION = '18.2';
const DEFAULT_SIMYO_DEVICE_MODEL = 'iPhone12,8';
// 版本号与括号之间为两个空格
const DEFAULT_SIMYO_USER_AGENT =
`MijnSimyoFT/${DEFAULT_SIMYO_CLIENT_VERSION} (iOS ${DEFAULT_SIMYO_IOS_VERSION}; ${DEFAULT_SIMYO_DEVICE_MODEL})`;

View File

@@ -558,9 +558,12 @@ export const TRANSLATIONS = {
'simyo.errors.requirePassword': '请输入密码',
'simyo.errors.invalidCodeFormat': '请输入6位数字验证码',
'simyo.device.applyFailed': '设备更换请求失败',
'simyo.device.applySuccess': '验证码已成功发送到邮箱',
'simyo.device.applySuccessAlreadyOrdered': '验证码已发送到邮箱您的账户已有eSIM配置系统正在处理设备更换',
'simyo.device.applySuccess': '验证码已成功发送到邮箱,请查收后在下方输入',
'simyo.device.applySuccessAlreadyOrdered': '已有进行中的 eSIM 更换订单,验证码已发送到邮箱。请直接输入验证码,无需重复申请',
'simyo.device.readyForDownload': '验证已完成,可直接获取 eSIM 二维码 / LPA',
'simyo.device.statusFailed': '查询 eSIM 状态失败',
'simyo.device.log.applyFailed': '设备更换请求失败:',
'simyo.device.log.statusFailed': '查询 simcard 状态失败,将继续尝试申请:',
'simyo.device.queryFailed': '查询可用验证方式失败',
'simyo.device.querySuccess': '查询成功',
'simyo.device.log.queryFailed': '查询可用验证方式失败:',
@@ -573,6 +576,7 @@ export const TRANSLATIONS = {
'simyo.device.log.verifyFailed': '验证码验证失败:',
'simyo.auth.loginMissingToken': '登录失败未获取到session token',
'simyo.auth.loginSuccess': '登录成功',
'simyo.auth.mfaRequired': '账号已开启登录二次验证MFA{status} / {method})。本工具暂不支持在线完成 MFA。请先在官方 Mijn Simyo App 关闭 MFA或完成验证后再试',
'simyo.auth.log.loginFailed': '登录失败:',
'simyo.esim.errors.notFound': '未找到有效的eSIM信息',
'simyo.esim.status.fetchSuccess': 'eSIM信息获取成功',
@@ -589,6 +593,7 @@ export const TRANSLATIONS = {
'simyo.api.error.missingDeviceId': '缺少设备标识X-Device-ID。请刷新页面后重试',
'simyo.api.error.invalidCredentials': '手机号或密码不正确,请检查后重试',
'simyo.api.error.sessionExpired': '登录已失效,请重新登录',
'simyo.api.error.badSession': '会话无效或未完成登录验证bad session。请重新登录若登录后立刻收到验证码请先在官方 App 关闭 MFA 或完成验证后再使用本工具',
'simyo.api.error.rateLimited': '请求过于频繁,请稍后再试',
'simyo.api.error.badRequest': '请求参数有误,请检查输入后重试',
'simyo.api.error.unauthorized': '未授权或登录已过期,请重新登录',
@@ -864,9 +869,12 @@ export const TRANSLATIONS = {
'simyo.errors.requirePassword': 'Enter your password.',
'simyo.errors.invalidCodeFormat': 'Enter the six-digit verification code.',
'simyo.device.applyFailed': 'Device change request failed.',
'simyo.device.applySuccess': 'Verification code sent to your email successfully.',
'simyo.device.applySuccessAlreadyOrdered': 'Code sent to email (You already have an eSIM. Processing device change...)',
'simyo.device.applySuccess': 'Verification code sent to your email. Enter it below.',
'simyo.device.applySuccessAlreadyOrdered': 'An eSIM change is already in progress; the code should be in your email. Enter it below without requesting again.',
'simyo.device.readyForDownload': 'Verification complete. You can fetch the eSIM QR / LPA now.',
'simyo.device.statusFailed': 'Failed to query eSIM status.',
'simyo.device.log.applyFailed': 'New eSIM request failed:',
'simyo.device.log.statusFailed': 'simcard status query failed; will still try apply:',
'simyo.device.queryFailed': 'Failed to query available validation methods.',
'simyo.device.querySuccess': 'Query succeeded.',
'simyo.device.log.queryFailed': 'Query validation methods failed:',
@@ -879,6 +887,7 @@ export const TRANSLATIONS = {
'simyo.device.log.verifyFailed': 'Verification failed:',
'simyo.auth.loginMissingToken': 'Login succeeded but no session token was returned.',
'simyo.auth.loginSuccess': 'Signed in successfully.',
'simyo.auth.mfaRequired': 'Login MFA is enabled on this account ({status} / {method}). This tool cannot complete MFA online. Disable MFA in the official Mijn Simyo app, or finish verification there first.',
'simyo.auth.log.loginFailed': 'Login failed:',
'simyo.esim.errors.notFound': 'No valid eSIM profile found.',
'simyo.esim.status.fetchSuccess': 'eSIM details retrieved.',
@@ -895,6 +904,7 @@ export const TRANSLATIONS = {
'simyo.api.error.missingDeviceId': 'Missing device id (X-Device-ID). Please refresh the page and try again.',
'simyo.api.error.invalidCredentials': 'Incorrect phone number or password. Please check and try again.',
'simyo.api.error.sessionExpired': 'Your session has expired. Please sign in again.',
'simyo.api.error.badSession': 'Invalid or incomplete session (bad session). Sign in again. If you receive a code immediately after login, disable MFA in the official Mijn Simyo app or complete verification first.',
'simyo.api.error.rateLimited': 'Too many requests. Please wait a moment and try again.',
'simyo.api.error.badRequest': 'Invalid request. Please check your input and try again.',
'simyo.api.error.unauthorized': 'Unauthorized or session expired. Please sign in again.',

View File

@@ -5,6 +5,10 @@
* 请求头字段对齐官方 Mijn Simyo iOS 抓包:
* X-Client-Token / X-Client-Platform / X-Client-Version / X-Device-ID / User-Agent
* 版本与 UA 见 client-identity.js单一事实来源
*
* eSIM 更换主路径HAR 2026-07-22仅 EMAIL
* POST /sessions → GET /settings/simcard → POST /settings/simcard
* → POST /esim/verify-code → GET /esim/get-by-customer
*/
import { isNetlifyEnvironment } from './utils.js';
@@ -34,7 +38,6 @@ function generateUuidV4() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID().toUpperCase();
}
// 无 crypto.randomUUID 时的回退(仍保证 RFC4122 形态)
const bytes = new Uint8Array(16);
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
crypto.getRandomValues(bytes);
@@ -61,7 +64,6 @@ function generateUuidV4() {
/**
* 获取或创建稳定的 X-Device-ID
* 官方 App 每个请求都携带 UUID 形态的设备 ID缺失会返回 400 missing X-Device-ID
* @returns {string}
*/
export function getOrCreateDeviceId() {
@@ -85,7 +87,7 @@ export function getOrCreateDeviceId() {
}
/**
* API端点配置
* API端点配置(对齐官方 webapi 路径)
*/
export function getApiEndpoints() {
const isNetlify = isNetlifyEnvironment();
@@ -93,30 +95,27 @@ export function getApiEndpoints() {
const localBase = isBrowserServed ? '' : 'http://localhost:3000';
return {
// 认证相关
login: isNetlify
? '/api/simyo/sessions'
: `${localBase}/api/simyo/sessions`,
// eSIM相关
getEsim: isNetlify
? '/api/simyo/esim/get-by-customer'
: `${localBase}/api/simyo/esim/get-by-customer`,
// 设备更换相关
// GET 查询 / POST 申请 共用 settings/simcard
simcard: isNetlify
? '/api/simyo/settings/simcard'
: `${localBase}/api/simyo/settings/simcard`,
// 兼容旧命名
applyNewEsim: isNetlify
? '/api/simyo/settings/simcard'
: `${localBase}/api/simyo/settings/simcard`,
verifyCode: isNetlify
? '/api/simyo/esim/verify-code'
: `${localBase}/api/simyo/esim/verify-code`,
// 新增:查询可用的验证方式 (v2 API)
availableValidationMethods: isNetlify
? '/api/simyo/esim.availableValidationMethods'
: `${localBase}/api/simyo/esim.availableValidationMethods`,
getEsim: isNetlify
? '/api/simyo/esim/get-by-customer'
: `${localBase}/api/simyo/esim/get-by-customer`,
// 安装确认
confirmInstall: isNetlify
? '/api/simyo/esim/reorder-profile-installed'
: `${localBase}/api/simyo/esim/reorder-profile-installed`
@@ -148,10 +147,8 @@ export function createHeaders(includeSession = false, sessionToken = '') {
/**
* 将 Simyo / 代理返回的原始错误映射为用户可读文案
* 优先匹配已知业务短语,其次按 HTTP 状态码,最后保留原文或通用失败
*
* @param {number} status - HTTP 状态码(业务体错误可传 0
* @param {object|string|null} data - 响应体或原始字符串
* @param {number} status
* @param {object|string|null} data
* @returns {string}
*/
export function mapSimyoErrorMessage(status, data) {
@@ -167,7 +164,16 @@ export function mapSimyoErrorMessage(status, data) {
const raw = rawParts.filter((p) => p != null && String(p).trim() !== '').map(String).join(' ');
const normalized = raw.toLowerCase();
// 已知业务错误Simyo 原文 / 代理透传
// 会话类(须先于笼统 unauthorized / credentials
if (
normalized.includes('bad session') ||
(normalized.includes('unauthoris') && normalized.includes('session')) ||
(normalized.includes('unauthorized') && normalized.includes('session')) ||
(normalized.includes('session') && (normalized.includes('expired') || normalized.includes('invalid')))
) {
return t('simyo.api.error.badSession');
}
if (normalized.includes('missing x-device-id') || normalized.includes('x-device-id')) {
return t('simyo.api.error.missingDeviceId');
}
@@ -175,19 +181,14 @@ export function mapSimyoErrorMessage(status, data) {
normalized.includes('invalid credentials') ||
normalized.includes('invalid password') ||
normalized.includes('authentication failed') ||
normalized.includes('unauthorized') ||
normalized.includes('wrong password')
) {
return t('simyo.api.error.invalidCredentials');
}
if (normalized.includes('session') && (normalized.includes('expired') || normalized.includes('invalid'))) {
return t('simyo.api.error.sessionExpired');
}
if (normalized.includes('too many') || normalized.includes('rate limit') || normalized.includes('throttl')) {
return t('simyo.api.error.rateLimited');
}
// HTTP 状态码语义(含历史上 426 客户端版本过旧)
const statusMap = {
400: 'simyo.api.error.badRequest',
401: 'simyo.api.error.unauthorized',
@@ -202,10 +203,8 @@ export function mapSimyoErrorMessage(status, data) {
504: 'simyo.api.error.timeout'
};
if (status && statusMap[status]) {
// 有状态码映射时,优先友好文案;附带简短原文便于排查(过长则截断)
const friendly = t(statusMap[status]);
if (raw && raw.length > 0 && raw.length <= 80 && !/^HTTP\s+\d+/i.test(raw)) {
// 避免重复拼接已是友好文案的情况
if (!friendly.includes(raw) && normalized !== friendly.toLowerCase()) {
return `${friendly}${raw}`;
}
@@ -214,7 +213,6 @@ export function mapSimyoErrorMessage(status, data) {
}
if (raw) {
// 纯 HTTP NNN 形态的原文也换成通用失败
if (/^HTTP\s+\d+$/i.test(raw.trim())) {
return t('simyo.api.error.genericWithStatus', { status: raw.replace(/\D/g, '') || status || '?' });
}
@@ -227,9 +225,44 @@ export function mapSimyoErrorMessage(status, data) {
return t('simyo.api.error.generic');
}
/**
* 判断 Simyo result 体是否表示业务成功
* 官方登录/查询常无 result.success仅有 sessionToken / eSimStatus 等字段
* @param {object} result
* @returns {boolean}
*/
export function isSimyoResultSuccessful(result) {
if (!result || typeof result !== 'object') {
return false;
}
if (result.success === false) {
return false;
}
if (result.success === true) {
return true;
}
// 登录
if (result.sessionToken) {
return true;
}
// 取 eSIM
if (result.activationCode) {
return true;
}
// simcard 状态查询
if (result.eSimStatus || result.canCreateESim || result.canCreateSimcard) {
return true;
}
// 申请更换reason + remainingNumberOfTries
if (result.reason != null || result.remainingNumberOfTries != null) {
return true;
}
return false;
}
/**
* 处理API响应
* @param {Response} response - Fetch API响应对象
* @param {Response} response
*/
export async function handleApiResponse(response) {
const contentType = response.headers.get('content-type') || '';
@@ -238,7 +271,6 @@ export async function handleApiResponse(response) {
try {
data = await response.json();
} catch (e) {
// JSON 解析失败(空体/坏体),回退为空对象
data = {};
}
} else {
@@ -246,32 +278,27 @@ export async function handleApiResponse(response) {
data = text ? { message: text } : {};
}
// HTTP 错误:映射为用户可读中文/英文避免只显示「HTTP 426」
if (!response.ok) {
throw new Error(mapSimyoErrorMessage(response.status, data));
}
// 非 JSON 响应直接返回文本内容
if (!contentType.includes('application/json')) {
return { success: true, result: data };
}
// 统一兼容三种返回格式:
// 1. 本地旧代理包装: { success, result, message }
// 2. 直通 Simyo: { result: {...} }
// 3. Simyo 展平成功结构: { success: true, ... }
// 本地代理包装: { success, result, message }
if (data.success === true && data.result) {
return data;
}
// 直通 Simyo: { result: {...} }
if (data.result) {
const nestedSuccess = data.result.success;
// 业务失败HTTP 200 但 success=false / 无 success 字段)也走友好文案
if (nestedSuccess === false) {
if (data.result.success === false) {
throw new Error(mapSimyoErrorMessage(0, data));
}
const ok = isSimyoResultSuccessful(data.result);
return {
success: nestedSuccess === true,
success: ok,
result: data.result,
message: data.message || data.result.message || data.result.reason
};

View File

@@ -1,6 +1,6 @@
/**
* Simyo认证处理模块
* 负责账户登录认证
* 负责账户登录认证;识别 mfaStatus未关闭 MFA 时阻断后续业务
*/
import { stateManager } from './state-manager.js';
@@ -9,6 +9,29 @@ import { validatePhoneNumber } from './utils.js';
import { t } from '../../../js/modules/i18n.js';
import Logger from '../../../js/modules/logger.js';
/**
* 判断登录返回的 mfaStatus 是否允许继续业务
* 官方 HARDISABLED_BY_CUSTOMER 表示客户关闭 MFA
* @param {string} mfaStatus
* @returns {boolean}
*/
export function isMfaDisabledOrComplete(mfaStatus) {
if (mfaStatus == null || mfaStatus === '') {
return true;
}
const s = String(mfaStatus).toUpperCase();
return (
s === 'DISABLED_BY_CUSTOMER' ||
s === 'DISABLED' ||
s === 'NONE' ||
s === 'OFF' ||
s === 'OK' ||
s === 'VERIFIED' ||
s === 'COMPLETED' ||
s === 'NOT_REQUIRED'
);
}
export class AuthHandler {
constructor() {
this.apiEndpoints = getApiEndpoints();
@@ -16,12 +39,11 @@ export class AuthHandler {
/**
* 登录Simyo账户
* @param {string} phoneNumber - 手机号
* @param {string} password - 密码
* @returns {Promise<Object>} 登录结果
* @param {string} phoneNumber
* @param {string} password
* @returns {Promise<Object>}
*/
async login(phoneNumber, password) {
// 验证手机号格式
if (!validatePhoneNumber(phoneNumber)) {
throw new Error(t('simyo.errors.invalidPhone'));
}
@@ -41,24 +63,42 @@ export class AuthHandler {
});
const data = await handleApiResponse(response);
// 提取session token
const sessionToken = data.result && data.result.sessionToken;
const result = data.result || {};
const sessionToken = result.sessionToken;
if (!sessionToken) {
throw new Error(data.message || t('simyo.auth.loginMissingToken'));
}
// 仅保存会话令牌与手机号;密码不进入状态
const mfaStatus = result.mfaStatus || '';
const mfaMethod = result.mfaMethod || '';
const methodHint = result.methodHint || '';
// 先落会话便于诊断MFA 未关闭则禁止进入设备更换
stateManager.setState({
sessionToken: sessionToken,
phoneNumber: phoneNumber
phoneNumber: phoneNumber,
mfaStatus: mfaStatus,
mfaMethod: mfaMethod
});
if (!isMfaDisabledOrComplete(mfaStatus)) {
const err = new Error(
t('simyo.auth.mfaRequired', {
status: mfaStatus,
method: mfaMethod || methodHint || '—'
})
);
err.code = 'MFA_REQUIRED';
err.mfaStatus = mfaStatus;
throw err;
}
return {
success: true,
message: t('simyo.auth.loginSuccess'),
sessionToken: sessionToken
sessionToken: sessionToken,
mfaStatus: mfaStatus
};
} catch (error) {
Logger.error(t('simyo.auth.log.loginFailed'), error);
@@ -66,22 +106,13 @@ export class AuthHandler {
}
}
/**
* 检查登录状态
* @returns {boolean} 是否已登录
*/
isLoggedIn() {
return !!stateManager.get('sessionToken');
}
/**
* 获取当前session token
* @returns {string} session token
*/
getSessionToken() {
return stateManager.get('sessionToken');
}
}
// 创建单例实例
export const authHandler = new AuthHandler();

View File

@@ -1,13 +1,13 @@
/**
* Simyo 官方 App 客户端身份(单一事实来源)
*
* 对齐 Mijn Simyo iOS 抓包2026-07-22 HAR
* 对齐 Mijn Simyo iOS 抓包2026-07-22 eSIM 更换 HAR
* - App 版本: 4.28.0
* - API User-Agent: MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)
* - API User-Agent: MijnSimyoFT/4.28.0 (iOS 18.2; iPhone12,8)
* (版本号与括号之间为两个空格)
* - 平台: ios
* - 设备型号: iPhone16,1
* - 系统版本: 27.0
* - 设备型号: iPhone12,8
* - 系统版本: 18.2
*
* 修改此处后请同步:
* - server.js 中的 DEFAULT_SIMYO_* 常量
@@ -26,10 +26,10 @@ export const SIMYO_CLIENT_PLATFORM = 'ios';
export const SIMYO_CLIENT_TOKEN = 'e77b7e2f43db41bb95b17a2a11581a38';
/** 抓包中的 iOS 系统版本 */
export const SIMYO_IOS_VERSION = '27.0';
export const SIMYO_IOS_VERSION = '18.2';
/** 抓包中的设备型号identifier */
export const SIMYO_DEVICE_MODEL = 'iPhone16,1';
export const SIMYO_DEVICE_MODEL = 'iPhone12,8';
/**
* appapi 请求使用的 User-Agent

View File

@@ -1,6 +1,7 @@
/**
* Simyo设备更换处理模块
* 负责设备更换的完整流程
* 对齐官方 App HAR仅 EMAIL 验证):
* GET settings/simcard → POST settings/simcard(EMAIL) → POST esim/verify-code
*/
import { stateManager } from './state-manager.js';
@@ -9,41 +10,109 @@ import { validateVerificationCode } from './utils.js';
import { t } from '../../../js/modules/i18n.js';
import Logger from '../../../js/modules/logger.js';
/** 官方 eSimStatus 状态 */
export const ESIM_STATUS = {
START_REQUEST: 'ESIM_START_REQUEST',
WAITING_FOR_VALIDATION_CODE: 'ESIM_REQUEST_WAITING_FOR_VALIDATION_CODE',
READY_FOR_DOWNLOAD: 'ESIM_REQUEST_READY_FOR_DOWNLOAD'
};
export class DeviceChangeHandler {
constructor() {
this.apiEndpoints = getApiEndpoints();
}
/**
* 请求设备更换(支持邮箱验证)
* @param {string} validationMethod - 验证方式 (默认: EMAIL)
* @returns {Promise<Object>} 请求结果
* 读取当前 session token未登录则抛错
* @returns {string}
*/
async applyNewEsim(validationMethod = 'EMAIL') {
requireSessionToken() {
const sessionToken = stateManager.get('sessionToken');
if (!sessionToken) {
throw new Error(t('simyo.errors.requireLogin'));
}
return sessionToken;
}
/**
* GET /settings/simcard — 查询 eSIM 订单状态
* @returns {Promise<Object>}
*/
async getSimcardStatus() {
const sessionToken = this.requireSessionToken();
const response = await fetch(this.apiEndpoints.simcard, {
method: 'GET',
headers: createHeaders(true, sessionToken)
});
const data = await handleApiResponse(response);
if (!data.result) {
throw new Error(data.message || t('simyo.device.statusFailed'));
}
return data.result;
}
/**
* 请求设备更换(固定 EMAIL对齐官方 body
* 会先 GET 状态:已在等待验证码 / 可下载时避免重复下单
* @returns {Promise<Object>}
*/
async applyNewEsim() {
const sessionToken = this.requireSessionToken();
try {
// 1) 查询当前状态
let status = null;
try {
status = await this.getSimcardStatus();
} catch (statusError) {
Logger.warn(t('simyo.device.log.statusFailed'), statusError);
// 查询失败仍尝试 POST兼容旧代理
}
if (status) {
if (status.eSimStatus === ESIM_STATUS.WAITING_FOR_VALIDATION_CODE) {
return {
success: true,
message: t('simyo.device.applySuccessAlreadyOrdered'),
remainingTries: status.remainingNumberOfTries,
eSimStatus: status.eSimStatus,
reason: status.canCreateESim || 'AlreadyOrderedSimcardEsim',
alreadyPending: true
};
}
if (status.eSimStatus === ESIM_STATUS.READY_FOR_DOWNLOAD) {
return {
success: true,
message: t('simyo.device.readyForDownload'),
remainingTries: status.remainingNumberOfTries,
eSimStatus: status.eSimStatus,
readyForDownload: true
};
}
}
// 2) POST 申请(仅 EMAIL
const response = await fetch(this.apiEndpoints.applyNewEsim, {
method: 'POST',
headers: createHeaders(true, sessionToken),
body: JSON.stringify({
initialValidationMethod: validationMethod,
initialValidationMethod: 'EMAIL',
esim: true
})
});
const data = await handleApiResponse(response);
if (!data.success || !data.result) {
if (!data.result) {
throw new Error(data.message || t('simyo.device.applyFailed'));
}
// 根据reason字段选择不同的提示消息
let message = data.message || t('simyo.device.applySuccess');
// 官方success:true + reason Available | AlreadyOrderedSimcardEsim
if (data.result.success === false) {
throw new Error(data.message || data.result.reason || t('simyo.device.applyFailed'));
}
let message = t('simyo.device.applySuccess');
if (data.result.reason === 'AlreadyOrderedSimcardEsim') {
message = t('simyo.device.applySuccessAlreadyOrdered');
}
@@ -51,7 +120,9 @@ export class DeviceChangeHandler {
return {
success: true,
message: message,
remainingTries: data.result.remainingNumberOfTries
remainingTries: data.result.remainingNumberOfTries,
reason: data.result.reason,
eSimStatus: ESIM_STATUS.WAITING_FOR_VALIDATION_CODE
};
} catch (error) {
Logger.error(t('simyo.device.log.applyFailed'), error);
@@ -60,16 +131,12 @@ export class DeviceChangeHandler {
}
/**
* 验证验证码
* @param {string} validationCode - 验证码
* @returns {Promise<Object>} 验证结果
* 验证邮箱验证码 POST /esim/verify-code
* @param {string} validationCode
* @returns {Promise<Object>}
*/
async verifyCode(validationCode) {
const sessionToken = stateManager.get('sessionToken');
if (!sessionToken) {
throw new Error(t('simyo.errors.requireLogin'));
}
const sessionToken = this.requireSessionToken();
if (!validateVerificationCode(validationCode)) {
throw new Error(t('simyo.errors.invalidCodeFormat'));
@@ -86,17 +153,17 @@ export class DeviceChangeHandler {
const data = await handleApiResponse(response);
if (!data.success) {
if (!data.result || data.result.success === false) {
throw new Error(data.message || t('simyo.device.verifyFailed'));
}
// 保存验证码到状态
stateManager.set('validationCode', validationCode);
return {
success: true,
message: data.message || t('simyo.device.verifySuccess'),
remainingTries: data.result && data.result.remainingNumberOfTries
remainingTries: data.result.remainingNumberOfTries,
eSimStatus: ESIM_STATUS.READY_FOR_DOWNLOAD
};
} catch (error) {
Logger.error(t('simyo.device.log.verifyFailed'), error);
@@ -105,5 +172,4 @@ export class DeviceChangeHandler {
}
}
// 创建单例实例
export const deviceChangeHandler = new DeviceChangeHandler();

View File

@@ -32,8 +32,9 @@ export class EsimService {
});
const data = await handleApiResponse(response);
if (!data.success || !data.result || !data.result.activationCode) {
// 官方 HAR{ result: { activationCode, success: true, errorCode: 0 } }
if (!data.result || !data.result.activationCode) {
throw new Error(data.message || t('simyo.esim.errors.notFound'));
}

View File

@@ -13,6 +13,8 @@ export class StateManager {
// 认证相关(密码仅在登录请求时使用,不进入状态)
sessionToken: "",
phoneNumber: "",
mfaStatus: "",
mfaMethod: "",
// eSIM相关
activationCode: "",
@@ -96,6 +98,8 @@ export class StateManager {
const sessionData = {
sessionToken: this.state.sessionToken,
phoneNumber: this.state.phoneNumber,
mfaStatus: this.state.mfaStatus,
mfaMethod: this.state.mfaMethod,
activationCode: this.state.activationCode,
validationCode: this.state.validationCode,
isDeviceChange: this.state.isDeviceChange,
@@ -130,6 +134,8 @@ export class StateManager {
...this.state,
sessionToken: data.sessionToken || "",
phoneNumber: data.phoneNumber || "",
mfaStatus: data.mfaStatus || "",
mfaMethod: data.mfaMethod || "",
activationCode: data.activationCode || "",
validationCode: data.validationCode || "",
isDeviceChange: typeof data.isDeviceChange === 'boolean' ? data.isDeviceChange : false,
@@ -154,6 +160,8 @@ export class StateManager {
this.state = {
sessionToken: "",
phoneNumber: "",
mfaStatus: "",
mfaMethod: "",
activationCode: "",
validationCode: "",
isDeviceChange: false,

View File

@@ -295,11 +295,18 @@ class SimyoApp {
Logger.log('[Simyo] 调用 deviceChangeHandler.applyNewEsim()...');
const result = await deviceChangeHandler.applyNewEsim();
Logger.log('[Simyo] 设备更换请求成功:', result.message);
Logger.log('[Simyo] 设备更换请求成功:', result.message, result.eSimStatus || '');
uiController.showStatus(statusEl, result.message || t('simyo.app.status.applySuccess'), "success");
// 新版流程使用邮箱验证码:引导用户直接输入验证码
// 已验证完成:可直接跳过验证码进入获取 eSIM
if (result.readyForDownload) {
await delay(1500);
uiController.skipDeviceChange();
return;
}
// 邮箱验证码:引导用户输入(含已下单仅等待验证码)
const codeInput = document.getElementById('validationCodeInput');
const verifyBtn = document.getElementById('verifyCodeBtn');
if (codeInput) {
@@ -308,7 +315,6 @@ class SimyoApp {
if (verifyBtn) verifyBtn.disabled = !hasValidCode;
}
// 显示下一步提示
if (result.nextStep) {
await delay(3000);
uiController.showStatus(statusEl, result.nextStep, "info");

View File

@@ -33,11 +33,11 @@ function isAllowedTarget(urlStr) {
}
}
// Simyo API配置与 client-identity.js 同步iOS 4.28.0 / iOS 27.0 / iPhone16,1
// Simyo API配置与 client-identity.js 同步iOS 4.28.0 / iOS 18.2 / iPhone12,8
const crypto = require('crypto');
const SIMYO_CLIENT_VERSION = process.env.SIMYO_CLIENT_VERSION || '4.28.0';
const SIMYO_IOS_VERSION = process.env.SIMYO_IOS_VERSION || '27.0';
const SIMYO_DEVICE_MODEL = process.env.SIMYO_DEVICE_MODEL || 'iPhone16,1';
const SIMYO_IOS_VERSION = process.env.SIMYO_IOS_VERSION || '18.2';
const SIMYO_DEVICE_MODEL = process.env.SIMYO_DEVICE_MODEL || 'iPhone12,8';
const SIMYO_USER_AGENT =
process.env.SIMYO_USER_AGENT ||
`MijnSimyoFT/${SIMYO_CLIENT_VERSION} (iOS ${SIMYO_IOS_VERSION}; ${SIMYO_DEVICE_MODEL})`;

View File

@@ -22,17 +22,17 @@ import {
const UUID_RE = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/;
/** 官方抓包 UA版本号后恰好两个空格 */
const OFFICIAL_UA = 'MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)';
const OFFICIAL_UA = 'MijnSimyoFT/4.28.0 (iOS 18.2; iPhone12,8)';
describe('Simyo client-identity', () => {
it('版本 / 设备 / UA 应与 4.28.0 抓包完全一致', () => {
it('版本 / 设备 / UA 应与 4.28.0 eSIM 更换抓包一致', () => {
expect(SIMYO_CLIENT_VERSION).toBe('4.28.0');
expect(SIMYO_CLIENT_PLATFORM).toBe('ios');
expect(SIMYO_IOS_VERSION).toBe('27.0');
expect(SIMYO_DEVICE_MODEL).toBe('iPhone16,1');
expect(SIMYO_IOS_VERSION).toBe('18.2');
expect(SIMYO_DEVICE_MODEL).toBe('iPhone12,8');
expect(SIMYO_USER_AGENT).toBe(OFFICIAL_UA);
// 版本号与括号之间必须是两个空格(不是一个)
expect(SIMYO_USER_AGENT).toMatch(/^MijnSimyoFT\/4\.28\.0 {2}\(iOS 27\.0; iPhone16,1\)$/);
expect(SIMYO_USER_AGENT).toMatch(/^MijnSimyoFT\/4\.28\.0 {2}\(iOS 18\.2; iPhone12,8\)$/);
expect(simyoClientIdentity.userAgent).toBe(OFFICIAL_UA);
});
});
@@ -85,6 +85,12 @@ describe('Simyo mapSimyoErrorMessage / handleApiResponse', () => {
expect(msg).not.toBe('missing X-Device-ID');
});
it('bad session 应映射为会话无效,而非账号密码错误', () => {
const msg = mapSimyoErrorMessage(401, { message: 'Unauthorised - bad session' });
expect(msg.toLowerCase()).toMatch(/session|会话|mfa|bad session/);
expect(msg).not.toMatch(/密码不正确|incorrect phone number or password/i);
});
it('401 凭据错误原文应优先识别为账号密码错误', () => {
const msg = mapSimyoErrorMessage(401, { message: 'Invalid credentials' });
expect(msg.toLowerCase()).toMatch(/password|密码|phone|手机号|incorrect|不正确/);
@@ -100,6 +106,23 @@ describe('Simyo mapSimyoErrorMessage / handleApiResponse', () => {
await expect(handleApiResponse(response)).rejects.toThrow(/升级|upgrade|客户端|client version|refresh|刷新/i);
});
it('handleApiResponse 官方登录体(无 result.success应视为成功', async () => {
const response = {
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({
result: {
sessionToken: 'abc',
mfaStatus: 'DISABLED_BY_CUSTOMER'
}
})
};
const data = await handleApiResponse(response);
expect(data.success).toBe(true);
expect(data.result.sessionToken).toBe('abc');
});
it('handleApiResponse 成功路径仍返回 result', async () => {
const response = {
ok: true,

View File

@@ -4,7 +4,7 @@
* Simyo AuthHandler 冒烟测试
*/
import { authHandler } from '../../src/simyo/js/modules/auth-handler.js';
import { authHandler, isMfaDisabledOrComplete } from '../../src/simyo/js/modules/auth-handler.js';
import { stateManager } from '../../src/simyo/js/modules/state-manager.js';
describe('Simyo AuthHandler', () => {
@@ -24,12 +24,17 @@ describe('Simyo AuthHandler', () => {
});
it('登录成功应写入 token/手机号,且不保留 password', async () => {
// 官方 HAR 形态:无顶层 successresult 内无 success 字段
global.fetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => ({
success: true,
result: { sessionToken: 'sess-abc' }
result: {
sessionToken: 'sess-abc',
mfaStatus: 'DISABLED_BY_CUSTOMER',
mfaMethod: '',
methodHint: ''
}
})
});
@@ -39,11 +44,30 @@ describe('Simyo AuthHandler', () => {
expect(result.sessionToken).toBe('sess-abc');
expect(stateManager.get('sessionToken')).toBe('sess-abc');
expect(stateManager.get('phoneNumber')).toBe('0612345678');
expect(stateManager.get('mfaStatus')).toBe('DISABLED_BY_CUSTOMER');
expect(stateManager.get('password')).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(stateManager.getState(), 'password')).toBe(false);
expect(authHandler.isLoggedIn()).toBe(true);
});
it('MFA 未关闭时应阻断并提示', async () => {
global.fetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => ({
result: {
sessionToken: 'sess-mfa',
mfaStatus: 'REQUIRED',
mfaMethod: 'EMAIL',
methodHint: 'email'
}
})
});
await expect(authHandler.login('0612345678', 'secret')).rejects.toThrow(/MFA|二次验证|mfa/i);
expect(stateManager.get('sessionToken')).toBe('sess-mfa');
});
it('响应缺少 sessionToken 应抛错', async () => {
global.fetch.mockResolvedValueOnce({
ok: true,
@@ -58,4 +82,10 @@ describe('Simyo AuthHandler', () => {
await expect(authHandler.login('0612345678', 'secret')).rejects.toThrow();
expect(stateManager.get('sessionToken')).toBe('');
});
it('isMfaDisabledOrComplete 识别官方 DISABLED_BY_CUSTOMER', () => {
expect(isMfaDisabledOrComplete('DISABLED_BY_CUSTOMER')).toBe(true);
expect(isMfaDisabledOrComplete('REQUIRED')).toBe(false);
expect(isMfaDisabledOrComplete('')).toBe(true);
});
});

View File

@@ -1,8 +1,8 @@
/**
* Simyo 设备更换处理模块集成测试
* Simyo 设备更换处理模块集成测试(对齐 HAR 状态机,仅 EMAIL
*/
import { deviceChangeHandler } from '../../src/simyo/js/modules/device-change-handler.js';
import { deviceChangeHandler, ESIM_STATUS } from '../../src/simyo/js/modules/device-change-handler.js';
import { stateManager } from '../../src/simyo/js/modules/state-manager.js';
describe('Simyo DeviceChangeHandler 集成覆盖', () => {
@@ -12,27 +12,40 @@ describe('Simyo DeviceChangeHandler 集成覆盖', () => {
global.fetch.mockReset();
});
it('应完成设备更换主流程:applyNewEsim -> verifyCode', async () => {
const jsonHeaders = { get: (name) => name === 'content-type' ? 'application/json' : null };
it('应完成设备更换主流程:GET simcard → POST simcard → verifyCode', async () => {
const jsonHeaders = { get: (name) => (name === 'content-type' ? 'application/json' : null) };
global.fetch
// GET status
.mockResolvedValueOnce({
headers: jsonHeaders,
ok: true,
json: async () => ({
success: true,
result: {
message: 'apply success',
eSimStatus: ESIM_STATUS.START_REQUEST,
canCreateESim: 'Available',
remainingNumberOfTries: 3
}
})
})
// POST apply
.mockResolvedValueOnce({
headers: jsonHeaders,
ok: true,
json: async () => ({
success: true,
result: {
message: 'verify success',
success: true,
reason: 'Available',
remainingNumberOfTries: 3
}
})
})
// verify
.mockResolvedValueOnce({
headers: jsonHeaders,
ok: true,
json: async () => ({
result: {
success: true,
remainingNumberOfTries: 2
}
})
@@ -42,18 +55,69 @@ describe('Simyo DeviceChangeHandler 集成覆盖', () => {
const verifyResult = await deviceChangeHandler.verifyCode('123456');
expect(applyResult.success).toBe(true);
expect(applyResult.reason).toBe('Available');
expect(verifyResult.success).toBe(true);
expect(stateManager.get('validationCode')).toBe('123456');
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch).toHaveBeenCalledTimes(3);
expect(global.fetch).toHaveBeenNthCalledWith(
1,
'/api/simyo/settings/simcard',
expect.objectContaining({ method: 'POST' })
expect.objectContaining({ method: 'GET' })
);
expect(global.fetch).toHaveBeenNthCalledWith(
2,
'/api/simyo/settings/simcard',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ initialValidationMethod: 'EMAIL', esim: true })
})
);
expect(global.fetch).toHaveBeenNthCalledWith(
3,
'/api/simyo/esim/verify-code',
expect.objectContaining({ method: 'POST' })
);
});
it('已在等待验证码时应跳过重复 POST', async () => {
const jsonHeaders = { get: (name) => (name === 'content-type' ? 'application/json' : null) };
global.fetch.mockResolvedValueOnce({
headers: jsonHeaders,
ok: true,
json: async () => ({
result: {
eSimStatus: ESIM_STATUS.WAITING_FOR_VALIDATION_CODE,
canCreateESim: 'AlreadyOrderedSimcardEsim',
remainingNumberOfTries: 2
}
})
});
const applyResult = await deviceChangeHandler.applyNewEsim();
expect(applyResult.success).toBe(true);
expect(applyResult.alreadyPending).toBe(true);
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith(
'/api/simyo/settings/simcard',
expect.objectContaining({ method: 'GET' })
);
});
it('已 READY_FOR_DOWNLOAD 时应提示可直接获取 eSIM', async () => {
const jsonHeaders = { get: (name) => (name === 'content-type' ? 'application/json' : null) };
global.fetch.mockResolvedValueOnce({
headers: jsonHeaders,
ok: true,
json: async () => ({
result: {
eSimStatus: ESIM_STATUS.READY_FOR_DOWNLOAD,
remainingNumberOfTries: 2
}
})
});
const applyResult = await deviceChangeHandler.applyNewEsim();
expect(applyResult.readyForDownload).toBe(true);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});

View File

@@ -540,8 +540,8 @@
'X-Client-Platform': 'ios',
'X-Client-Version': '4.28.0',
'X-Device-ID': 'E766D17B-BDF5-43B8-8807-35E43BA129E2',
// 官方抓包:版本号后两个空格 + iOS 27.0 + iPhone16,1
'User-Agent': 'MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)',
// 官方 eSIM 更换抓包:版本号后两个空格 + iOS 18.2 + iPhone12,8
'User-Agent': 'MijnSimyoFT/4.28.0 (iOS 18.2; iPhone12,8)',
'Content-Type': 'application/json',
'Accept': 'application/json',
'Connection': 'keep-alive',