fix: query local GPC OTP helper by phone
- 合并 PR #211 的核心改动:GPC 本地 OTP helper 改为按 GPC 手机号查询 /latest-otp,并让后台携带 E.164 phone 与 consume=1。 - 本地补充修复:将 consume 行为收敛为只消费本次返回的 OTP 记录,避免同手机号后续验证码被误删,并同步文档说明。 - 影响范围:GPC Plus 第 7 步队列任务 OTP 提交、本地 macOS SMS helper、相关回归测试与链路文档。
This commit is contained in:
@@ -463,11 +463,48 @@
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function normalizeLocalSmsHelperCountryCode(value = '') {
|
||||
const rootScope = typeof self !== 'undefined' ? self : globalThis;
|
||||
if (rootScope.GoPayUtils?.normalizeGoPayCountryCode) {
|
||||
return rootScope.GoPayUtils.normalizeGoPayCountryCode(value || '+86');
|
||||
}
|
||||
const digits = String(value || '+86').replace(/\D/g, '');
|
||||
return digits ? `+${digits}` : '+86';
|
||||
}
|
||||
|
||||
function normalizeLocalSmsHelperPhoneE164(phone = '', countryCode = '+86') {
|
||||
const rawPhone = String(phone || '').trim();
|
||||
if (!rawPhone) {
|
||||
return '';
|
||||
}
|
||||
const rootScope = typeof self !== 'undefined' ? self : globalThis;
|
||||
const normalizedCountryCode = normalizeLocalSmsHelperCountryCode(countryCode);
|
||||
const normalizedPhone = rootScope.GoPayUtils?.normalizeGoPayPhone
|
||||
? rootScope.GoPayUtils.normalizeGoPayPhone(rawPhone)
|
||||
: rawPhone.replace(/[^\d+]/g, '');
|
||||
const phoneDigits = normalizedPhone.replace(/\D/g, '');
|
||||
if (!phoneDigits) {
|
||||
return '';
|
||||
}
|
||||
if (normalizedPhone.startsWith('+')) {
|
||||
return `+${phoneDigits}`;
|
||||
}
|
||||
const countryDigits = normalizedCountryCode.replace(/\D/g, '') || '86';
|
||||
let nationalNumber = phoneDigits;
|
||||
if (countryDigits && nationalNumber.startsWith(countryDigits) && nationalNumber.length > countryDigits.length) {
|
||||
nationalNumber = nationalNumber.slice(countryDigits.length);
|
||||
}
|
||||
return `+${countryDigits}${nationalNumber}`;
|
||||
}
|
||||
|
||||
function buildLocalSmsHelperOtpUrl(state = {}, taskId = '', options = {}) {
|
||||
const baseUrl = normalizeLocalSmsHelperBaseUrl(state?.gopayHelperLocalSmsHelperUrl);
|
||||
const url = new URL(`${baseUrl}/otp`);
|
||||
const url = new URL(`${baseUrl}/latest-otp`);
|
||||
const normalizedTaskId = String(taskId || '').trim();
|
||||
const phoneNumber = String(state?.gopayHelperPhoneNumber || '').trim();
|
||||
const phoneNumber = normalizeLocalSmsHelperPhoneE164(
|
||||
state?.gopayHelperPhoneNumber,
|
||||
state?.gopayHelperCountryCode || '+86'
|
||||
);
|
||||
const afterOverrideMs = normalizeEpochMilliseconds(options?.afterMs || options?.after_ms || 0);
|
||||
const orderCreatedAt = normalizeEpochMilliseconds(
|
||||
state?.gopayHelperOrderCreatedAt
|
||||
@@ -483,8 +520,9 @@
|
||||
url.searchParams.set('reference_id', normalizedTaskId);
|
||||
}
|
||||
if (phoneNumber) {
|
||||
url.searchParams.set('phone_number', phoneNumber);
|
||||
url.searchParams.set('phone', phoneNumber);
|
||||
}
|
||||
url.searchParams.set('consume', '1');
|
||||
const effectiveAfterMs = Math.max(orderCreatedAt, afterOverrideMs);
|
||||
if (effectiveAfterMs > 0) {
|
||||
url.searchParams.set('after_ms', String(effectiveAfterMs));
|
||||
|
||||
+136
-24
@@ -74,6 +74,11 @@ def extract_gopay_otp(text: str, require_keywords: bool = True) -> Optional[str]
|
||||
return None
|
||||
|
||||
|
||||
def normalize_phone_key(value: object) -> str:
|
||||
digits = re.sub(r"\D+", "", str(value or ""))
|
||||
return f"+{digits}" if digits else ""
|
||||
|
||||
|
||||
def mac_message_time_to_datetime(value: int | float | None) -> dt.datetime:
|
||||
if not value:
|
||||
return dt.datetime.now(dt.timezone.utc)
|
||||
@@ -113,24 +118,52 @@ def read_recent_messages(db_path: Path, after_rowid: int = 0, limit: int = 80) -
|
||||
try:
|
||||
conn = sqlite3.connect(str(copied))
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
message.ROWID AS rowid,
|
||||
message.guid AS guid,
|
||||
message.text AS text,
|
||||
message.date AS date,
|
||||
message.service AS service,
|
||||
handle.id AS handle
|
||||
FROM message
|
||||
LEFT JOIN handle ON message.handle_id = handle.ROWID
|
||||
WHERE message.ROWID > ?
|
||||
AND message.text IS NOT NULL
|
||||
ORDER BY message.ROWID DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(max(0, int(after_rowid or 0)), max(1, int(limit or 80))),
|
||||
).fetchall()
|
||||
params = (max(0, int(after_rowid or 0)), max(1, int(limit or 80)))
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
message.ROWID AS rowid,
|
||||
message.guid AS guid,
|
||||
message.text AS text,
|
||||
message.date AS date,
|
||||
message.service AS service,
|
||||
message.destination_caller_id AS destination_caller_id,
|
||||
message.account AS account,
|
||||
handle.id AS handle,
|
||||
chat.last_addressed_handle AS last_addressed_handle,
|
||||
chat.account_login AS account_login
|
||||
FROM message
|
||||
LEFT JOIN handle ON message.handle_id = handle.ROWID
|
||||
LEFT JOIN chat_message_join ON chat_message_join.message_id = message.ROWID
|
||||
LEFT JOIN chat ON chat.ROWID = chat_message_join.chat_id
|
||||
WHERE message.ROWID > ?
|
||||
AND message.text IS NOT NULL
|
||||
ORDER BY message.ROWID DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
message.ROWID AS rowid,
|
||||
message.guid AS guid,
|
||||
message.text AS text,
|
||||
message.date AS date,
|
||||
message.service AS service,
|
||||
message.account AS account,
|
||||
handle.id AS handle
|
||||
FROM message
|
||||
LEFT JOIN handle ON message.handle_id = handle.ROWID
|
||||
WHERE message.ROWID > ?
|
||||
AND message.text IS NOT NULL
|
||||
ORDER BY message.ROWID DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
try:
|
||||
@@ -140,8 +173,17 @@ def read_recent_messages(db_path: Path, after_rowid: int = 0, limit: int = 80) -
|
||||
shutil.rmtree(copied.parent, ignore_errors=True)
|
||||
|
||||
|
||||
def get_record_phone(row: dict) -> str:
|
||||
for key in ("destination_caller_id", "last_addressed_handle", "account_login", "account"):
|
||||
phone = normalize_phone_key(row.get(key))
|
||||
if phone:
|
||||
return phone
|
||||
return ""
|
||||
|
||||
|
||||
def make_otp_record(row: dict, otp: str) -> dict:
|
||||
received_at = mac_message_time_to_datetime(row.get("date")).isoformat()
|
||||
phone_e164 = get_record_phone(row)
|
||||
return {
|
||||
"otp": otp,
|
||||
"code": otp,
|
||||
@@ -149,6 +191,8 @@ def make_otp_record(row: dict, otp: str) -> dict:
|
||||
"rowid": int(row.get("rowid") or 0),
|
||||
"sender": str(row.get("handle") or ""),
|
||||
"service": str(row.get("service") or ""),
|
||||
"account_phone": phone_e164,
|
||||
"phone_e164": phone_e164,
|
||||
"received_at": received_at,
|
||||
"message_text": str(row.get("text") or ""),
|
||||
}
|
||||
@@ -189,19 +233,86 @@ def parse_timestamp_ms(value: object) -> int:
|
||||
return int(numeric)
|
||||
|
||||
|
||||
def select_otp_record(state: dict, after_ms: int = 0) -> Optional[dict]:
|
||||
def record_matches_phone(record: dict, phone: str = "") -> bool:
|
||||
wanted = normalize_phone_key(phone)
|
||||
if not wanted:
|
||||
return True
|
||||
return normalize_phone_key(record.get("phone_e164") or record.get("account_phone")) == wanted
|
||||
|
||||
|
||||
def select_otp_record(state: dict, after_ms: int = 0, phone: str = "") -> Optional[dict]:
|
||||
records = state.get("otps")
|
||||
if not isinstance(records, list):
|
||||
records = []
|
||||
if after_ms > 0:
|
||||
for record in records:
|
||||
if isinstance(record, dict) and parse_timestamp_ms(record.get("received_at")) >= after_ms:
|
||||
if (
|
||||
isinstance(record, dict)
|
||||
and record_matches_phone(record, phone)
|
||||
and parse_timestamp_ms(record.get("received_at")) >= after_ms
|
||||
):
|
||||
return record
|
||||
return None
|
||||
record = state.get("last_otp") or None
|
||||
if isinstance(record, dict):
|
||||
if isinstance(record, dict) and record_matches_phone(record, phone):
|
||||
return record
|
||||
return records[0] if records and isinstance(records[0], dict) else None
|
||||
for record in records:
|
||||
if isinstance(record, dict) and record_matches_phone(record, phone):
|
||||
return record
|
||||
return None
|
||||
|
||||
|
||||
def consume_otp_record(phone: str = "", record: Optional[dict] = None) -> None:
|
||||
wanted = normalize_phone_key(phone)
|
||||
consumed_message_id = str((record or {}).get("message_id") or "").strip()
|
||||
consumed_rowid = int((record or {}).get("rowid") or 0)
|
||||
|
||||
def is_consumed_record(item: object) -> bool:
|
||||
if not isinstance(item, dict):
|
||||
return False
|
||||
if consumed_message_id and str(item.get("message_id") or "").strip() == consumed_message_id:
|
||||
return True
|
||||
if consumed_rowid and int(item.get("rowid") or 0) == consumed_rowid:
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_same_record(left: object, right: object) -> bool:
|
||||
if not isinstance(left, dict) or not isinstance(right, dict):
|
||||
return False
|
||||
left_message_id = str(left.get("message_id") or "").strip()
|
||||
right_message_id = str(right.get("message_id") or "").strip()
|
||||
if left_message_id and right_message_id and left_message_id == right_message_id:
|
||||
return True
|
||||
left_rowid = int(left.get("rowid") or 0)
|
||||
right_rowid = int(right.get("rowid") or 0)
|
||||
if left_rowid > 0 and right_rowid > 0 and left_rowid == right_rowid:
|
||||
return True
|
||||
return left == right
|
||||
|
||||
with STATE_LOCK:
|
||||
records = STATE.get("otps")
|
||||
if not isinstance(records, list):
|
||||
records = []
|
||||
|
||||
if consumed_message_id or consumed_rowid:
|
||||
next_records = [item for item in records if isinstance(item, dict) and not is_consumed_record(item)]
|
||||
elif wanted:
|
||||
removed_once = False
|
||||
next_records = []
|
||||
for item in records:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if not removed_once and record_matches_phone(item, wanted):
|
||||
removed_once = True
|
||||
continue
|
||||
next_records.append(item)
|
||||
else:
|
||||
next_records = []
|
||||
|
||||
STATE["otps"] = next_records
|
||||
last_otp = STATE.get("last_otp")
|
||||
if isinstance(last_otp, dict) and not any(is_same_record(last_otp, item) for item in STATE["otps"]):
|
||||
STATE["last_otp"] = STATE["otps"][0] if STATE["otps"] else None
|
||||
|
||||
|
||||
def scan_once(db_path: Path, require_keywords: bool = True) -> None:
|
||||
@@ -263,14 +374,15 @@ class HelperHandler(BaseHTTPRequestHandler):
|
||||
query = parse_qs(parsed.query)
|
||||
consume = str(query.get("consume", ["0"])[0]).strip().lower() in {"1", "true", "yes"}
|
||||
after_ms = parse_timestamp_ms(query.get("after_ms", query.get("after", ["0"]))[0])
|
||||
phone = str((query.get("phone") or query.get("phone_e164") or query.get("phone_number") or [""])[0]).strip()
|
||||
state = get_state()
|
||||
record = select_otp_record(state, after_ms=after_ms)
|
||||
record = select_otp_record(state, after_ms=after_ms, phone=phone)
|
||||
if not record:
|
||||
write_json(self, 200, {"ok": True, "otp": "", "code": "", "status": "waiting", "message": "未查询到验证码"})
|
||||
return
|
||||
payload = {"ok": True, "status": "found", **record}
|
||||
if consume:
|
||||
update_state(last_otp=None)
|
||||
consume_otp_record(phone=phone, record=record)
|
||||
write_json(self, 200, payload)
|
||||
return
|
||||
write_json(self, 404, {"ok": False, "error": "not_found"})
|
||||
|
||||
@@ -79,3 +79,123 @@ print(json.dumps(payload))
|
||||
none_after_fresh: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('GPC SMS helper selects and consumes cached OTP records by phone', () => {
|
||||
const code = `
|
||||
import importlib.util
|
||||
import json
|
||||
|
||||
script_path = ${JSON.stringify(scriptPath)}
|
||||
spec = importlib.util.spec_from_file_location("gpc_sms_helper_macos", script_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
record_a = {
|
||||
"otp": "111111",
|
||||
"code": "111111",
|
||||
"message_id": "a",
|
||||
"rowid": 1,
|
||||
"phone_e164": "+8615808505050",
|
||||
"account_phone": "+8615808505050",
|
||||
"received_at": "2026-05-05T00:00:00+00:00",
|
||||
}
|
||||
record_b = {
|
||||
"otp": "222222",
|
||||
"code": "222222",
|
||||
"message_id": "b",
|
||||
"rowid": 2,
|
||||
"phone_e164": "+8618984829950",
|
||||
"account_phone": "+8618984829950",
|
||||
"received_at": "2026-05-05T00:00:10+00:00",
|
||||
}
|
||||
module.STATE.update({"last_otp": record_b, "otps": [record_b, record_a]})
|
||||
selected_a = module.select_otp_record(module.get_state(), phone="+8615808505050")
|
||||
module.consume_otp_record(phone="+8615808505050", record=selected_a)
|
||||
state_after = module.get_state()
|
||||
payload = {
|
||||
"selected_a": selected_a["otp"],
|
||||
"selected_b_after": module.select_otp_record(state_after, phone="+8618984829950")["otp"],
|
||||
"selected_a_after": module.select_otp_record(state_after, phone="+8615808505050") is None,
|
||||
"global_after": module.select_otp_record(state_after)["otp"],
|
||||
}
|
||||
print(json.dumps(payload))
|
||||
`;
|
||||
const run = runPython(['-c', code], {
|
||||
timeout: 3000,
|
||||
env: { GPC_SMS_HELPER_ALLOW_NON_MAC: '1' },
|
||||
});
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
assert.deepEqual(JSON.parse(run.stdout.trim()), {
|
||||
selected_a: '111111',
|
||||
selected_b_after: '222222',
|
||||
selected_a_after: true,
|
||||
global_after: '222222',
|
||||
});
|
||||
});
|
||||
|
||||
test('GPC SMS helper consume keeps newer same-phone OTP when consuming an older record', () => {
|
||||
const code = `
|
||||
import importlib.util
|
||||
import json
|
||||
|
||||
script_path = ${JSON.stringify(scriptPath)}
|
||||
spec = importlib.util.spec_from_file_location("gpc_sms_helper_macos", script_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
record_old = {
|
||||
"otp": "111111",
|
||||
"code": "111111",
|
||||
"message_id": "old",
|
||||
"rowid": 1,
|
||||
"phone_e164": "+8613800138000",
|
||||
"account_phone": "+8613800138000",
|
||||
"received_at": "2026-05-05T00:00:00+00:00",
|
||||
}
|
||||
record_new = {
|
||||
"otp": "222222",
|
||||
"code": "222222",
|
||||
"message_id": "new",
|
||||
"rowid": 2,
|
||||
"phone_e164": "+8613800138000",
|
||||
"account_phone": "+8613800138000",
|
||||
"received_at": "2026-05-05T00:00:10+00:00",
|
||||
}
|
||||
record_other = {
|
||||
"otp": "333333",
|
||||
"code": "333333",
|
||||
"message_id": "other",
|
||||
"rowid": 3,
|
||||
"phone_e164": "+8618984829950",
|
||||
"account_phone": "+8618984829950",
|
||||
"received_at": "2026-05-05T00:00:20+00:00",
|
||||
}
|
||||
module.STATE.update({"last_otp": record_new, "otps": [record_new, record_old, record_other]})
|
||||
module.consume_otp_record(phone="+8613800138000", record=record_old)
|
||||
state_after = module.get_state()
|
||||
payload = {
|
||||
"same_phone_after": module.select_otp_record(state_after, phone="+8613800138000")["otp"],
|
||||
"other_phone_after": module.select_otp_record(state_after, phone="+8618984829950")["otp"],
|
||||
"records_after": [item["message_id"] for item in state_after.get("otps", [])],
|
||||
"global_after": module.select_otp_record(state_after)["otp"],
|
||||
}
|
||||
print(json.dumps(payload))
|
||||
`;
|
||||
const run = runPython(['-c', code], {
|
||||
timeout: 3000,
|
||||
env: { GPC_SMS_HELPER_ALLOW_NON_MAC: '1' },
|
||||
});
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
assert.deepEqual(JSON.parse(run.stdout.trim()), {
|
||||
same_phone_after: '222222',
|
||||
other_phone_after: '333333',
|
||||
records_after: ['new', 'other'],
|
||||
global_after: '222222',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -916,7 +916,7 @@ test('GPC billing reads SMS OTP from local helper for sms_otp_wait', async () =>
|
||||
stateByFrame: {},
|
||||
fetchImpl: async (url, options = {}) => {
|
||||
fetchCalls.push({ url, options });
|
||||
if (url.startsWith('http://127.0.0.1:18767/otp')) {
|
||||
if (url.startsWith('http://127.0.0.1:18767/latest-otp')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -973,17 +973,19 @@ test('GPC billing reads SMS OTP from local helper for sms_otp_wait', async () =>
|
||||
gopayHelperOtpChannel: 'sms',
|
||||
gopayHelperLocalSmsHelperEnabled: true,
|
||||
gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767',
|
||||
gopayHelperPhoneNumber: '+8613800138000',
|
||||
gopayHelperCountryCode: '+86',
|
||||
gopayHelperPhoneNumber: '13800138000',
|
||||
gopayHelperOrderCreatedAt: 1710000000000,
|
||||
});
|
||||
|
||||
assert.equal(events.states.some((state) => state.plusManualConfirmationMethod === 'gopay-otp'), false);
|
||||
assert.equal(events.states.some((state) => state.gopayHelperResolvedOtp === '654321'), true);
|
||||
const helperUrl = new URL(fetchCalls[1].url);
|
||||
assert.equal(helperUrl.origin + helperUrl.pathname, 'http://127.0.0.1:18767/otp');
|
||||
assert.equal(helperUrl.origin + helperUrl.pathname, 'http://127.0.0.1:18767/latest-otp');
|
||||
assert.equal(helperUrl.searchParams.get('task_id'), 'task_sms');
|
||||
assert.equal(helperUrl.searchParams.get('reference_id'), 'task_sms');
|
||||
assert.equal(helperUrl.searchParams.get('phone_number'), '+8613800138000');
|
||||
assert.equal(helperUrl.searchParams.get('phone'), '+8613800138000');
|
||||
assert.equal(helperUrl.searchParams.get('consume'), '1');
|
||||
assert.equal(helperUrl.searchParams.get('after_ms'), '1710000000000');
|
||||
assert.deepEqual(JSON.parse(fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_sms/otp')).options.body), {
|
||||
otp: '654321',
|
||||
@@ -999,7 +1001,7 @@ test('GPC billing can read WhatsApp OTP from local helper when enabled', async (
|
||||
stateByFrame: {},
|
||||
fetchImpl: async (url, options = {}) => {
|
||||
fetchCalls.push({ url, options });
|
||||
if (url.startsWith('http://127.0.0.1:18767/otp')) {
|
||||
if (url.startsWith('http://127.0.0.1:18767/latest-otp')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -1056,11 +1058,15 @@ test('GPC billing can read WhatsApp OTP from local helper when enabled', async (
|
||||
gopayHelperOtpChannel: 'whatsapp',
|
||||
gopayHelperLocalSmsHelperEnabled: true,
|
||||
gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767',
|
||||
gopayHelperPhoneNumber: '+8613800138000',
|
||||
gopayHelperCountryCode: '+86',
|
||||
gopayHelperPhoneNumber: '18984829950',
|
||||
});
|
||||
|
||||
assert.equal(events.states.some((state) => state.plusManualConfirmationMethod === 'gopay-otp'), false);
|
||||
assert.equal(events.states.some((state) => state.gopayHelperResolvedOtp === '765432'), true);
|
||||
const helperUrl = new URL(fetchCalls.find((call) => call.url.startsWith('http://127.0.0.1:18767/latest-otp')).url);
|
||||
assert.equal(helperUrl.searchParams.get('phone'), '+8618984829950');
|
||||
assert.equal(helperUrl.searchParams.get('consume'), '1');
|
||||
assert.deepEqual(JSON.parse(fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_wa/otp')).options.body), {
|
||||
otp: '765432',
|
||||
});
|
||||
@@ -1075,7 +1081,7 @@ test('GPC billing helper mode does not open OTP dialog when helper has no code a
|
||||
stateByFrame: {},
|
||||
fetchImpl: async (url, options = {}) => {
|
||||
fetchCalls.push({ url, options });
|
||||
if (url.startsWith('http://127.0.0.1:18767/otp')) {
|
||||
if (url.startsWith('http://127.0.0.1:18767/latest-otp')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -1139,7 +1145,7 @@ test('GPC billing helper mode requests newer OTP after invalid OTP error', async
|
||||
stateByFrame: {},
|
||||
fetchImpl: async (url, options = {}) => {
|
||||
fetchCalls.push({ url, options });
|
||||
if (url.startsWith('http://127.0.0.1:18767/otp')) {
|
||||
if (url.startsWith('http://127.0.0.1:18767/latest-otp')) {
|
||||
helperCallCount += 1;
|
||||
return {
|
||||
ok: true,
|
||||
@@ -1190,9 +1196,13 @@ test('GPC billing helper mode requests newer OTP after invalid OTP error', async
|
||||
.map((call) => JSON.parse(call.options.body));
|
||||
assert.deepEqual(otpBodies, [{ otp: '111111' }, { otp: '222222' }]);
|
||||
assert.equal(otpPostCount, 2);
|
||||
const helperUrls = fetchCalls.filter((call) => call.url.startsWith('http://127.0.0.1:18767/otp')).map((call) => new URL(call.url));
|
||||
const helperUrls = fetchCalls.filter((call) => call.url.startsWith('http://127.0.0.1:18767/latest-otp')).map((call) => new URL(call.url));
|
||||
assert.equal(helperUrls.length, 2);
|
||||
assert.equal(helperUrls[0].searchParams.get('phone'), '+8613800138000');
|
||||
assert.equal(helperUrls[0].searchParams.get('consume'), '1');
|
||||
assert.equal(helperUrls[0].searchParams.get('after_ms'), '1710000000000');
|
||||
assert.equal(helperUrls[1].searchParams.get('phone'), '+8613800138000');
|
||||
assert.equal(helperUrls[1].searchParams.get('consume'), '1');
|
||||
assert.ok(Number(helperUrls[1].searchParams.get('after_ms')) > 1710000000000);
|
||||
assert.equal(events.logs.some((entry) => /OTP 校验失败/.test(entry.message)), true);
|
||||
assert.equal(events.completed[0].step, 7);
|
||||
|
||||
+1
-1
@@ -549,7 +549,7 @@ Plus 模式可见步骤:
|
||||
|
||||
1. 第 1~5 步:沿用普通注册入口、邮箱、密码、注册验证码、资料填写链路。
|
||||
2. 第 6 步 `创建 Plus Checkout`:PayPal / GoPay 打开已登录 ChatGPT 页面,通过 `/api/auth/session` 读取 accessToken,再请求 `https://chatgpt.com/backend-api/payments/checkout` 创建 `chatgptplusplan` 的 checkout session。PayPal 使用 `DE / EUR` 与 `https://chatgpt.com/checkout/openai_ie/{checkout_session_id}`;GoPay 使用 `ID / IDR` 与 `https://chatgpt.com/checkout/openai_llc/{checkout_session_id}`;GPC helper 模式改为把 accessToken、手机号、国家区号、`otp_channel` 等提交到 `gopayHelperApiUrl` 的 `/api/gp/tasks`,并通过 `gopayHelperApiKey` 发送 `X-API-Key` 认证,创建后保存 `task_id`。
|
||||
3. 第 7 步 `填写账单并提交订阅`:PayPal / GoPay 仍走 checkout 页面与 Stripe iframe 自动化;GPC helper 模式不再操作 checkout iframe,而是轮询 `/api/gp/tasks/{task_id}`,根据远端 `api_waiting_for` 依次向 `/otp` 和 `/pin` 提交验证码与 PIN。若侧栏启用本地 OTP helper,后台只轮询 `gopayHelperLocalSmsHelperUrl` 的 `/otp` 接口读取当前 OTP 通道验证码,不再弹手动验证码;未开启本地 helper 时才弹出手动 OTP 输入框。仓库内置的 `scripts/gpc_sms_helper_macos.py` 仍是 macOS Messages 短信读取实现,其他通道需要提供兼容的本地 `/otp` 接口。
|
||||
3. 第 7 步 `填写账单并提交订阅`:PayPal / GoPay 仍走 checkout 页面与 Stripe iframe 自动化;GPC helper 模式不再操作 checkout iframe,而是轮询 `/api/gp/tasks/{task_id}`,根据远端 `api_waiting_for` 依次向 `/otp` 和 `/pin` 提交验证码与 PIN。若侧栏启用本地 OTP helper,后台会轮询 `gopayHelperLocalSmsHelperUrl` 的 `/latest-otp?phone=...&consume=1` 接口,按当前 GPC 手机号读取并消费当前 OTP 通道的验证码;未开启本地 helper 时才弹出手动 OTP 输入框。仓库内置的 `scripts/gpc_sms_helper_macos.py` 是 macOS Messages/本地通知兼容读取实现,其他通道需要提供兼容的本地 `/latest-otp` 或 `/otp` 接口。
|
||||
4. 仅 PayPal / GoPay 会继续显示第 8 步:该步按当前支付方式显示为 `PayPal 登录与授权` 或 `GoPay 手机验证与授权`,底层 step key 仍为 `paypal-approve`。
|
||||
5. 第 9 步 `订阅回跳确认` 仅在 PayPal / GoPay 模式下等待授权后回跳到 ChatGPT / OpenAI 页面,页面加载完成后固定等待 1 秒。GPC helper 模式在第 7 步任务完成后会直接进入 Plus 可见第 10 步 OAuth 登录。
|
||||
6. 第 10 步:复用原 Step 7 OAuth 登录执行器,但状态和日志按 Plus 可见第 10 步记录。
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@
|
||||
|
||||
## `scripts/`
|
||||
|
||||
- `scripts/gpc_sms_helper_macos.py`:GPC Plus helper 的 macOS 本地短信验证码 helper,读取本机 Messages 数据库中的 GoPay/OpenAI 短信 OTP,并通过 `http://127.0.0.1:18767/otp` 提供给扩展自动轮询。
|
||||
- `scripts/gpc_sms_helper_macos.py`:GPC Plus helper 的 macOS 本地短信验证码 helper,读取本机 Messages 数据库中的 GoPay/OpenAI 短信 OTP,并通过 `http://127.0.0.1:18767/latest-otp?phone=...&consume=1` 提供给扩展按手机号自动轮询;`/otp` 仍保留兼容,`consume=1` 会消费本次返回的验证码记录,避免下次重复读取。
|
||||
- `scripts/hotmail_helper.py`:本地 helper 服务,负责通过本地接口协助 Hotmail 获取邮件和验证码,并提供账号记录 JSON 快照同步接口;旧的文本追加接口仍保留作兼容。
|
||||
|
||||
## `sidepanel/`
|
||||
|
||||
Reference in New Issue
Block a user