From 90173e3bd7b2cd438b9bcfd4ee9c28a7eb716b3b Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Tue, 10 Nov 2026 19:05:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(semi-auto):=20=E6=94=AF=E6=8C=81=E5=BE=AE?= =?UTF-8?q?=E5=8D=9A=E5=8D=8A=E8=87=AA=E5=8A=A8=E5=B1=A5=E7=BA=A6=E4=B8=8E?= =?UTF-8?q?=E6=89=8B=E6=9C=BA=E5=8F=B7=E5=9B=BD=E5=AE=B6=E7=A0=81=E7=99=BB?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增微博内置 Connector:扫码/短信登录、Cookie 获取与一次性浏览器票据;客户页支持国家码选择、短信/扫码登录及登录即授权;dev-mock 与后台任务页适配微博 Mock 单。 --- .../src/routes/customer-fulfillment.ts | 65 ++ .../src/services/dev-mock/dev-mock-service.ts | 112 ++- .../semi-auto/semi-auto-task-service.ts | 329 +++++++- .../semi-auto/weibo-connector-client.ts | 100 +++ .../weibo-native/weibo-native-connector.ts | 750 ++++++++++++++++++ .../weibo-native/weibo-native.test.ts | 224 ++++++ .../src/pages/admin/AdminDevMockPage.tsx | 113 ++- .../pages/admin/AdminSemiAutoTasksPage.tsx | 30 +- .../CustomerFulfillmentPage.tsx | 347 +++++++- apps/frontend/src/services/admin/dev-mock.ts | 2 + .../src/services/customer-fulfillment.ts | 46 ++ 11 files changed, 1980 insertions(+), 138 deletions(-) create mode 100644 apps/backend/src/services/semi-auto/weibo-connector-client.ts create mode 100644 apps/backend/src/services/semi-auto/weibo-native/weibo-native-connector.ts create mode 100644 apps/backend/src/services/semi-auto/weibo-native/weibo-native.test.ts diff --git a/apps/backend/src/routes/customer-fulfillment.ts b/apps/backend/src/routes/customer-fulfillment.ts index ba8ed107..f04f798d 100644 --- a/apps/backend/src/routes/customer-fulfillment.ts +++ b/apps/backend/src/routes/customer-fulfillment.ts @@ -2,12 +2,17 @@ import { Router } from 'express' import { confirmPublicBinding, + getPublicWeiboCountryCodes, listPublicSemiAutoTasks, + loginPublicWeiboSms, markCustomerLinkOpened, pollPublicBinding, pollPublicLogin, + pollPublicWeiboQrLogin, + sendPublicWeiboSmsCode, startPublicBinding, startPublicLogin, + startPublicWeiboQrLogin, } from '../services/semi-auto/semi-auto-task-service.js' import { createRateLimitMiddleware, getParamRateLimitKey } from '../middleware/rate-limit.js' import { createRouteHandler } from '../utils/http.js' @@ -94,4 +99,64 @@ router.post( }), ) +router.get( + '/:orderNo/tasks/:taskId/weibo/config', + readLimit, + createRouteHandler((req) => getPublicWeiboCountryCodes(req.params.orderNo, req.params.taskId), { + errorMessage: '获取微博国家码列表失败', + scope: '[customer-fulfillment/:orderNo/tasks/:taskId/weibo/config]', + }), +) + +router.post( + '/:orderNo/tasks/:taskId/weibo/sms/send', + writeLimit, + createRouteHandler( + (req) => + sendPublicWeiboSmsCode( + req.params.orderNo, + req.params.taskId, + req.body?.mobile, + req.body?.countryCode, + ), + { + successMessage: '验证码已发送', + errorMessage: '发送微博短信验证码失败', + scope: '[customer-fulfillment/:orderNo/tasks/:taskId/weibo/sms/send]', + }, + ), +) + +router.post( + '/:orderNo/tasks/:taskId/weibo/sms/login', + writeLimit, + createRouteHandler( + (req) => loginPublicWeiboSms(req.params.orderNo, req.params.taskId, req.body || {}), + { + successMessage: '微博登录成功', + errorMessage: '微博短信登录失败', + scope: '[customer-fulfillment/:orderNo/tasks/:taskId/weibo/sms/login]', + }, + ), +) + +router.get( + '/:orderNo/tasks/:taskId/weibo/qrcode', + writeLimit, + createRouteHandler((req) => startPublicWeiboQrLogin(req.params.orderNo, req.params.taskId), { + successMessage: '登录二维码已生成', + errorMessage: '获取微博登录二维码失败', + scope: '[customer-fulfillment/:orderNo/tasks/:taskId/weibo/qrcode]', + }), +) + +router.get( + '/:orderNo/tasks/:taskId/weibo/qrcode/status', + readLimit, + createRouteHandler((req) => pollPublicWeiboQrLogin(req.params.orderNo, req.params.taskId), { + errorMessage: '刷新微博扫码登录状态失败', + scope: '[customer-fulfillment/:orderNo/tasks/:taskId/weibo/qrcode/status]', + }), +) + export default router diff --git a/apps/backend/src/services/dev-mock/dev-mock-service.ts b/apps/backend/src/services/dev-mock/dev-mock-service.ts index 55f611ee..8e22fc23 100644 --- a/apps/backend/src/services/dev-mock/dev-mock-service.ts +++ b/apps/backend/src/services/dev-mock/dev-mock-service.ts @@ -68,6 +68,7 @@ export type DevMockKuaishouIndustryVoucherResult = { export type DevMockSemiAutoResult = { platform: 'semi_auto' + semiAutoPlatform: 'huya' | 'weibo' orderId: number orderNo: string orderItemId: number @@ -310,6 +311,7 @@ export async function createAffiliateDashMockClaim( */ export async function createSemiAutoMock( input: { + platform?: unknown orderNo?: unknown skuName?: unknown quantity?: unknown @@ -321,19 +323,32 @@ export async function createSemiAutoMock( ): Promise { assertDevMockEnabled() - const orderNo = String(input.orderNo || `MOCKSA${Date.now()}`).trim() - const skuKey = normalizeMockSemiAutoSku(input.skuName) + const platform = normalizeMockSemiAutoPlatform(input.platform) + const orderNo = String( + input.orderNo || `${platform === 'weibo' ? 'MOCKWB' : 'MOCKSA'}${Date.now()}`, + ).trim() + const skuKey = normalizeMockSemiAutoSku(input.skuName, platform) const quantity = normalizeMockSemiAutoQuantity(input.quantity) - const connectorMode = normalizeMockSemiAutoConnectorMode(input.connectorMode) - const pageTitle = String(input.pageTitle || '虎牙账号授权测试') + // 微博目前只支持内置 Connector,忽略其他模式,避免生成必然失败的测试单。 + const connectorMode = + platform === 'weibo' ? 'embedded' : normalizeMockSemiAutoConnectorMode(input.connectorMode) + const pageTitle = String( + input.pageTitle || (platform === 'weibo' ? '微博发帖授权测试' : '虎牙账号授权测试'), + ) .trim() .slice(0, 60) const pageDescription = String( - input.pageDescription || '开发 Mock:完成登录和角色绑定后,客服在后台完成履约。', + input.pageDescription || + (platform === 'weibo' + ? '开发 Mock:完成微博登录即视为授权,客服在后台完成发帖履约。' + : '开发 Mock:完成登录和角色绑定后,客服在后台完成履约。'), ) .trim() .slice(0, 500) - const browserTargetUrl = String(input.browserTargetUrl || 'https://www.huya.com/') + const browserTargetUrl = String( + input.browserTargetUrl || + (platform === 'weibo' ? 'https://weibo.com/' : 'https://www.huya.com/'), + ) .trim() .slice(0, 1000) @@ -345,19 +360,30 @@ export async function createSemiAutoMock( note: '开发 Mock 半自动履约', channels: [{ platformKey: 'fund_cash', creditQuantity: 100, enabled: true }], semiAutoEnabled: true, - semiAutoPlatform: 'huya', + semiAutoPlatform: platform, semiAutoConfig: { pageTitle, pageDescription, loginInstructions: - connectorMode === 'mock' - ? 'Mock 登录:点击获取二维码后,页面轮询两次会自动模拟登录成功。' - : '真实登录:点击获取二维码后,请使用虎牙 App 扫码并在手机上确认登录。', + platform === 'weibo' + ? '真实登录:使用微博绑定手机号接收验证码登录,或使用微博 App 扫码登录。登录仅用于本次订单履约,不会在页面展示账号 Cookie。' + : connectorMode === 'mock' + ? 'Mock 登录:点击获取二维码后,页面轮询两次会自动模拟登录成功。' + : '真实登录:点击获取二维码后,请使用虎牙 App 扫码并在手机上确认登录。', bindingInstructions: - connectorMode === 'mock' - ? 'Mock 绑定:页面轮询两次后会自动识别角色,点击确认即可。' - : '真实绑定:扫码后等待 Connector 识别角色,核对账号和区服后点击确认绑定。', - browserTargets: [{ key: 'huya', label: '虎牙', url: browserTargetUrl }], + platform === 'weibo' + ? '微博登录成功即完成授权,无需再绑定游戏角色。' + : connectorMode === 'mock' + ? 'Mock 绑定:页面轮询两次后会自动识别角色,点击确认即可。' + : '真实绑定:扫码后等待 Connector 识别角色,核对账号和区服后点击确认绑定。', + ...(platform === 'weibo' ? { loginMethods: ['sms', 'qr'] } : {}), + browserTargets: [ + { + key: platform, + label: platform === 'weibo' ? '微博' : '虎牙', + url: browserTargetUrl, + }, + ], }, }) const configId = Number(config.id || 0) @@ -386,12 +412,12 @@ export async function createSemiAutoMock( specJson: JSON.stringify({ source: 'dev_mock', skuNick: skuKey, - semiAuto: { platform: 'huya', mock: true }, + semiAuto: { platform, mock: true }, }), itemSnapshotJson: JSON.stringify({ source: 'dev_mock', skuKey, - platform: 'huya', + platform, mock: true, }), createdAt: now, @@ -463,6 +489,7 @@ export async function createSemiAutoMock( connectorMode === 'external' ? isExternalSemiAutoConnectorConfigured() : true return { platform: 'semi_auto', + semiAutoPlatform: platform, orderId: Number(order.id), orderNo, orderItemId, @@ -474,24 +501,40 @@ export async function createSemiAutoMock( connectorConfigured, customerPath: `/c/${encodeURIComponent(orderNo)}`, adminPath: `/admin/semi-auto-tasks?keyword=${encodeURIComponent(orderNo)}`, - tips: [ - '完成履约仍走真实确认流程,会写入成本快照和财务确认。', - connectorMode === 'mock' - ? '当前使用内置 Mock Connector:打开客户页点击登录,轮询两次后自动登录;绑定同样自动识别角色。' - : connectorMode === 'external' - ? connectorConfigured - ? '当前使用远程真实 Connector:订单是 Mock 的,登录、绑定和浏览器票据都走外部服务。' - : '当前选择远程真实 Connector,但 SEMI_AUTO_CONNECTOR_BASE_URL / API_KEY 未配置,登录会失败。' - : '当前使用 order_site 内置真实 Connector:无需启动 live-hub-py,扫码登录、绑定和票据都在本进程完成。', - connectorMode === 'mock' - ? '后台领取任务后生成浏览器票据;扩展消费票据后写入 Mock Cookie。' - : connectorMode === 'external' - ? '请确保远程 Connector 已启动,并保证两边 SEMI_AUTO_CONNECTOR_API_KEY 一致。' - : '登录凭据只保存在当前进程内存中,重启后端会要求客户重新扫码。', - ], + tips: + platform === 'weibo' + ? [ + '完成履约仍走真实确认流程,会写入成本快照和财务确认。', + '微博使用内置 Connector:短信验证码会真实发送到客户手机号;扫码登录需在微博 App 中确认。', + '后台领取任务后生成 weibo-ticket-* 浏览器票据,扩展消费票据后写入微博 Cookie。', + '微博登录成功即视为授权完成,任务会直接进入等待客服处理。', + ] + : [ + '完成履约仍走真实确认流程,会写入成本快照和财务确认。', + connectorMode === 'mock' + ? '当前使用内置 Mock Connector:打开客户页点击登录,轮询两次后自动登录;绑定同样自动识别角色。' + : connectorMode === 'external' + ? connectorConfigured + ? '当前使用远程真实 Connector:订单是 Mock 的,登录、绑定和浏览器票据都走外部服务。' + : '当前选择远程真实 Connector,但 SEMI_AUTO_CONNECTOR_BASE_URL / API_KEY 未配置,登录会失败。' + : '当前使用 order_site 内置真实 Connector:无需启动 live-hub-py,扫码登录、绑定和票据都在本进程完成。', + connectorMode === 'mock' + ? '后台领取任务后生成浏览器票据;扩展消费票据后写入 Mock Cookie。' + : connectorMode === 'external' + ? '请确保远程 Connector 已启动,并保证两边 SEMI_AUTO_CONNECTOR_API_KEY 一致。' + : '登录凭据只保存在当前进程内存中,重启后端会要求客户重新扫码。', + ], } } +function normalizeMockSemiAutoPlatform(value: unknown): 'huya' | 'weibo' { + return String(value || '') + .trim() + .toLowerCase() === 'weibo' + ? 'weibo' + : 'huya' +} + function normalizeMockSemiAutoConnectorMode(value: unknown): SemiAutoConnectorMode { const mode = String(value || '') .trim() @@ -500,13 +543,14 @@ function normalizeMockSemiAutoConnectorMode(value: unknown): SemiAutoConnectorMo return 'embedded' } -function normalizeMockSemiAutoSku(value: unknown) { +function normalizeMockSemiAutoSku(value: unknown, platform: 'huya' | 'weibo' = 'huya') { + const prefix = platform === 'weibo' ? '微博半自动' : '虎牙半自动' const raw = String(value || '').trim() const withCjk = raw ? containsCjk(raw) ? raw - : `虎牙半自动-${raw}` - : `虎牙半自动测试-${Date.now()}` + : `${prefix}-${raw}` + : `${prefix}测试-${Date.now()}` return withCjk.slice(0, 48) } diff --git a/apps/backend/src/services/semi-auto/semi-auto-task-service.ts b/apps/backend/src/services/semi-auto/semi-auto-task-service.ts index 63bb2179..5f0471d0 100644 --- a/apps/backend/src/services/semi-auto/semi-auto-task-service.ts +++ b/apps/backend/src/services/semi-auto/semi-auto-task-service.ts @@ -17,7 +17,6 @@ import { type UploadedFileDto, } from '../file-storage/file-storage-service.js' import { - confirmHuyaBinding, createHuyaBrowserTicket, createHuyaLoginSession, getHuyaBinding, @@ -29,6 +28,18 @@ import { type SemiAutoConnectorMode, type ConnectorLoginSession, } from './huya-connector-client.js' +import { + consumeWeiboBrowserTicket, + createWeiboBrowserTicket, + createWeiboLoginSession, + getWeiboBinding, + getWeiboCountryCodes, + getWeiboLoginSession, + loginWeiboSms, + revokeWeiboSession, + sendWeiboSmsCode, +} from './weibo-connector-client.js' +import { WEIBO_TICKET_PREFIX } from './weibo-native/weibo-native-connector.js' const SEMI_AUTO_PLATFORMS = new Set(['huya', 'weibo']) type SemiAutoPlatform = 'huya' | 'weibo' @@ -308,6 +319,51 @@ async function loadTaskForUpdateWithClient(client: PoolClient, taskId: number): return task } +function assertWeiboTask(task: TaskRow) { + if (task.platform !== 'weibo') { + throw createHttpError('当前任务不是微博任务', { + statusCode: 409, + errorCode: 'semi_auto_platform_mismatch', + }) + } +} + +async function createLoginSessionForTask( + task: TaskRow, + orderNo: string, +): Promise { + const input = { taskId: task.id, orderNo } + return task.platform === 'weibo' + ? createWeiboLoginSession(input, task.connector_mode) + : createHuyaLoginSession(input, task.connector_mode) +} + +async function getLoginSessionForTask( + task: TaskRow, + sessionId: string, +): Promise { + return task.platform === 'weibo' + ? getWeiboLoginSession(sessionId, task.connector_mode) + : getHuyaLoginSession(sessionId, task.connector_mode) +} + +async function revokeSessionForTask(task: TaskRow, sessionId: string): Promise { + if (task.platform === 'weibo') { + await revokeWeiboSession(sessionId, task.connector_mode) + return + } + await revokeHuyaSession(sessionId, task.connector_mode) +} + +async function getBindingForTask( + task: TaskRow, + sessionId: string, +): Promise { + return task.platform === 'weibo' + ? getWeiboBinding(sessionId, task.connector_mode) + : getHuyaBinding(sessionId, task.connector_mode) +} + type SemiAutoCandidate = { order_id: number platform_order_id: string @@ -467,9 +523,14 @@ async function updateLoginTask( actorType: TaskActorType, ) { const mapped = mapLoginUpdate(session) + // 微博没有独立的游戏角色绑定步骤:登录成功即视为授权完成。 + const autoConfirmWeiboBinding = + task.platform === 'weibo' && + mapped.status === 'authenticated' && + task.binding_status !== 'confirmed' const nextExecutionStatus: ExecutionStatus = mapped.status === 'authenticated' - ? task.binding_status === 'confirmed' + ? task.platform === 'weibo' || task.binding_status === 'confirmed' ? 'waiting_operator' : 'waiting_customer' : task.execution_status @@ -481,12 +542,25 @@ async function updateLoginTask( huya_uid = CASE WHEN $3 <> '' THEN $3 ELSE huya_uid END, platform_nickname = CASE WHEN $4 <> '' THEN $4 ELSE platform_nickname END, authenticated_at = CASE WHEN $2 = 'authenticated' THEN COALESCE(authenticated_at, NOW()) ELSE authenticated_at END, + binding_status = CASE WHEN $6::boolean THEN 'confirmed' ELSE binding_status END, + bind_game_account = CASE WHEN $6::boolean AND $4 <> '' THEN $4 ELSE bind_game_account END, + bind_game_role = CASE WHEN $6::boolean AND $3 <> '' THEN $3 ELSE bind_game_role END, + bind_game_channel = CASE WHEN $6::boolean THEN '微博' ELSE bind_game_channel END, + bound_at = CASE WHEN $6::boolean THEN COALESCE(bound_at, NOW()) ELSE bound_at END, execution_status = $5, - last_error = CASE WHEN $2 IN ('failed', 'expired') THEN $6 ELSE '' END, + last_error = CASE WHEN $2 IN ('failed', 'expired') THEN $7 ELSE '' END, updated_at = NOW() WHERE id = $1 `, - [task.id, mapped.status, mapped.uid, mapped.nickname, nextExecutionStatus, mapped.message], + [ + task.id, + mapped.status, + mapped.uid, + mapped.nickname, + nextExecutionStatus, + autoConfirmWeiboBinding, + mapped.message, + ], ) await appendTaskEvent(client, task.id, { eventType: 'login_status_changed', @@ -500,6 +574,22 @@ async function updateLoginTask( message: mapped.message, }, }) + if (autoConfirmWeiboBinding) { + await appendTaskEvent(client, task.id, { + eventType: 'binding_status_changed', + actorType, + fromBindingStatus: task.binding_status, + toBindingStatus: 'confirmed', + fromExecutionStatus: task.execution_status, + toExecutionStatus: nextExecutionStatus, + detail: { + gameAccount: mapped.nickname, + gameRole: mapped.uid, + gameChannel: '微博', + message: '微博登录成功,授权已完成', + }, + }) + } }) } @@ -605,13 +695,13 @@ export async function startPublicLogin(orderNoInput: unknown, taskIdInput: unkno task.login_status !== 'failed' ) { try { - reusableSession = await getHuyaLoginSession(task.connector_session_id, task.connector_mode) + reusableSession = await getLoginSessionForTask(task, task.connector_session_id) } catch { // Connector 重启或短期会话被清理后,任务里的旧 ID 已不可用;重新创建前先尽力撤销。 - await revokeHuyaSession(task.connector_session_id, task.connector_mode).catch(() => undefined) + await revokeSessionForTask(task, task.connector_session_id).catch(() => undefined) } } else if (task.connector_session_id) { - await revokeHuyaSession(task.connector_session_id, task.connector_mode).catch(() => undefined) + await revokeSessionForTask(task, task.connector_session_id).catch(() => undefined) } if (reusableSession) { @@ -629,7 +719,7 @@ export async function startPublicLogin(orderNoInput: unknown, taskIdInput: unkno } } - const session = await createHuyaLoginSession({ taskId: task.id, orderNo }, task.connector_mode) + const session = await createLoginSessionForTask(task, orderNo) await withTransaction(async (client) => { await client.query( ` @@ -675,7 +765,7 @@ export async function pollPublicLogin(orderNoInput: unknown, taskIdInput: unknow if (!task.connector_session_id) { return { task: mapTask(task), login: null } } - const session = await getHuyaLoginSession(task.connector_session_id, task.connector_mode) + const session = await getLoginSessionForTask(task, task.connector_session_id) await updateLoginTask(task, session, 'customer') const updated = await loadTask(task.id) return { @@ -710,9 +800,12 @@ export async function startPublicBinding( errorCode: 'semi_auto_login_required', }) } - const session = await startHuyaBinding(task.connector_session_id, task.connector_mode, { - forceRebind: input.forceRebind === true, - }) + const session = + task.platform === 'weibo' + ? await getWeiboBinding(task.connector_session_id, task.connector_mode) + : await startHuyaBinding(task.connector_session_id, task.connector_mode, { + forceRebind: input.forceRebind === true, + }) const binding = await updateBindingTask(task, session, 'customer') const updated = await loadTask(task.id) return { task: mapTask(updated), binding } @@ -728,7 +821,7 @@ export async function pollPublicBinding(orderNoInput: unknown, taskIdInput: unkn if (!task.connector_session_id || task.login_status !== 'authenticated') { return { task: mapTask(task), binding: null } } - const session = await getHuyaBinding(task.connector_session_id, task.connector_mode) + const session = await getBindingForTask(task, task.connector_session_id) const binding = await updateBindingTask(task, session, 'customer') const updated = await loadTask(task.id) return { task: mapTask(updated), binding } @@ -744,18 +837,188 @@ export async function confirmPublicBinding(orderNoInput: unknown, taskIdInput: u errorCode: 'semi_auto_task_closed', }) } - if (!task.connector_session_id || task.binding_status !== 'role_ready') { + if ( + !task.connector_session_id || + (task.platform !== 'weibo' && task.binding_status !== 'role_ready') + ) { throw createHttpError('当前没有待确认的绑定角色', { statusCode: 409, errorCode: 'semi_auto_binding_not_ready', }) } - const session = await confirmHuyaBinding(task.connector_session_id, task.connector_mode) + const session = await getBindingForTask(task, task.connector_session_id) const binding = await updateBindingTask(task, session, 'customer') const updated = await loadTask(task.id) return { task: mapTask(updated), binding } } +export async function sendPublicWeiboSmsCode( + orderNoInput: unknown, + taskIdInput: unknown, + mobileInput: unknown, + countryCodeInput: unknown = '86', +) { + const orderNo = normalizeOrderNo(orderNoInput) + const taskId = normalizeId(taskIdInput) + const task = await loadTaskForOrder(orderNo, taskId) + assertWeiboTask(task) + if (task.execution_status === 'completed' || task.execution_status === 'cancelled') { + throw createHttpError('当前订单已结束,不能重新登录', { + statusCode: 409, + errorCode: 'semi_auto_task_closed', + }) + } + await sendWeiboSmsCode( + String(mobileInput ?? '').trim(), + String(countryCodeInput ?? '86').trim() || '86', + task.connector_mode, + ) + return { ok: true, message: '验证码已发送,请注意查收' } +} + +export async function loginPublicWeiboSms( + orderNoInput: unknown, + taskIdInput: unknown, + input: { mobile?: unknown; code?: unknown; countryCode?: unknown } = {}, +) { + const orderNo = normalizeOrderNo(orderNoInput) + const taskId = normalizeId(taskIdInput) + const task = await loadTaskForOrder(orderNo, taskId) + assertWeiboTask(task) + if (task.execution_status === 'completed' || task.execution_status === 'cancelled') { + throw createHttpError('当前订单已结束,不能重新登录', { + statusCode: 409, + errorCode: 'semi_auto_task_closed', + }) + } + + const previousSessionId = task.connector_session_id + const { sessionId, session } = await loginWeiboSms( + { + taskId: task.id, + orderNo, + mobile: String(input.mobile ?? '').trim(), + code: String(input.code ?? '').trim(), + countryCode: String(input.countryCode ?? '86').trim() || '86', + }, + task.connector_mode, + ) + const mapped = mapLoginUpdate(session) + + if (session.status !== 'authenticated') { + await withTransaction(async (client) => { + await client.query( + ` + UPDATE semi_auto_tasks + SET connector_session_id = $2, + login_status = $3, + last_error = $4, + updated_at = NOW() + WHERE id = $1 + `, + [task.id, sessionId, mapped.status, mapped.message], + ) + await appendTaskEvent(client, task.id, { + eventType: 'login_status_changed', + actorType: 'customer', + fromLoginStatus: task.login_status, + toLoginStatus: mapped.status, + fromExecutionStatus: task.execution_status, + toExecutionStatus: task.execution_status, + detail: { method: 'sms', message: mapped.message }, + }) + }) + throw createHttpError(mapped.message || '微博登录失败,请重试', { + statusCode: 409, + errorCode: 'weibo_login_failed', + }) + } + + const nextExecutionStatus: ExecutionStatus = 'waiting_operator' + await withTransaction(async (client) => { + await client.query( + ` + UPDATE semi_auto_tasks + SET connector_session_id = $2, + login_status = 'authenticated', + binding_status = 'confirmed', + execution_status = 'waiting_operator', + huya_uid = CASE WHEN $3 <> '' THEN $3 ELSE huya_uid END, + platform_nickname = CASE WHEN $4 <> '' THEN $4 ELSE platform_nickname END, + bind_game_account = CASE WHEN $4 <> '' THEN $4 ELSE bind_game_account END, + bind_game_role = CASE WHEN $3 <> '' THEN $3 ELSE bind_game_role END, + bind_game_channel = '微博', + auth_started_at = COALESCE(auth_started_at, NOW()), + authenticated_at = COALESCE(authenticated_at, NOW()), + bound_at = COALESCE(bound_at, NOW()), + last_error = '', + updated_at = NOW() + WHERE id = $1 + `, + [task.id, sessionId, mapped.uid, mapped.nickname], + ) + await appendTaskEvent(client, task.id, { + eventType: 'login_status_changed', + actorType: 'customer', + fromLoginStatus: task.login_status, + toLoginStatus: 'authenticated', + fromExecutionStatus: task.execution_status, + toExecutionStatus: nextExecutionStatus, + detail: { method: 'sms', hasCredential: mapped.hasCredential, message: mapped.message }, + }) + if (task.binding_status !== 'confirmed') { + await appendTaskEvent(client, task.id, { + eventType: 'binding_status_changed', + actorType: 'customer', + fromBindingStatus: task.binding_status, + toBindingStatus: 'confirmed', + fromExecutionStatus: task.execution_status, + toExecutionStatus: nextExecutionStatus, + detail: { + gameAccount: mapped.nickname, + gameRole: mapped.uid, + gameChannel: '微博', + method: 'sms', + message: '微博登录成功,授权已完成', + }, + }) + } + }) + + if (previousSessionId && previousSessionId !== sessionId) { + await revokeWeiboSession(previousSessionId, task.connector_mode).catch(() => undefined) + } + const updated = await loadTask(task.id) + return { + task: mapTask(updated), + login: { + status: mapped.status, + qrContent: '', + qrImage: '', + expiresAt: session.expiresAt, + message: session.message, + }, + } +} + +export async function getPublicWeiboCountryCodes(orderNoInput: unknown, taskIdInput: unknown) { + const task = await loadTaskForOrder(normalizeOrderNo(orderNoInput), normalizeId(taskIdInput)) + assertWeiboTask(task) + return { countryCodes: await getWeiboCountryCodes(task.connector_mode) } +} + +export async function startPublicWeiboQrLogin(orderNoInput: unknown, taskIdInput: unknown) { + const task = await loadTaskForOrder(normalizeOrderNo(orderNoInput), normalizeId(taskIdInput)) + assertWeiboTask(task) + return startPublicLogin(orderNoInput, taskIdInput) +} + +export async function pollPublicWeiboQrLogin(orderNoInput: unknown, taskIdInput: unknown) { + const task = await loadTaskForOrder(normalizeOrderNo(orderNoInput), normalizeId(taskIdInput)) + assertWeiboTask(task) + return pollPublicLogin(orderNoInput, taskIdInput) +} + export async function listAdminSemiAutoTasks(input: Record = {}) { await backfillSemiAutoTasks() const page = Math.max(1, Math.trunc(Number(input.page) || 1)) @@ -886,12 +1149,12 @@ export async function syncAdminSemiAutoTask(taskIdInput: unknown) { task.login_status !== 'failed' && task.login_status !== 'expired' ) { - const login = await getHuyaLoginSession(task.connector_session_id, task.connector_mode) + const login = await getLoginSessionForTask(task, task.connector_session_id) await updateLoginTask(task, login, 'connector') task = await loadTask(taskId) } if (task.login_status === 'authenticated' && task.binding_status !== 'confirmed') { - const binding = await getHuyaBinding(task.connector_session_id, task.connector_mode) + const binding = await getBindingForTask(task, task.connector_session_id) await updateBindingTask(task, binding, 'connector') } return getAdminSemiAutoTask(taskId) @@ -924,13 +1187,19 @@ export async function createAdminBrowserSession( Boolean(item && typeof item === 'object'), )?.url const targetUrl = explicitTarget || String(fallbackTarget || '') - const ticket = await createHuyaBrowserTicket( - { - sessionId: task.connector_session_id, - targetUrl, - }, - task.connector_mode, - ) + const ticket = + task.platform === 'weibo' + ? await createWeiboBrowserTicket( + { sessionId: task.connector_session_id, targetUrl }, + task.connector_mode, + ) + : await createHuyaBrowserTicket( + { + sessionId: task.connector_session_id, + targetUrl, + }, + task.connector_mode, + ) await withTransaction(async (client) => { await client.query( `UPDATE semi_auto_tasks @@ -958,6 +1227,9 @@ export async function consumeSemiAutoInjectionTicket(ticketInput: unknown) { errorCode: 'semi_auto_injection_ticket_invalid', }) } + if (ticket.startsWith(WEIBO_TICKET_PREFIX)) { + return consumeWeiboBrowserTicket(ticket) + } return consumeHuyaBrowserTicket(ticket) } @@ -1108,10 +1380,9 @@ export async function completeAdminSemiAutoTask( } await runManualFulfillmentPostCommit(completion.task.order_item_id) if (completion.task.connector_session_id) { - await revokeHuyaSession( - completion.task.connector_session_id, - completion.task.connector_mode, - ).catch(() => undefined) + await revokeSessionForTask(completion.task, completion.task.connector_session_id).catch( + () => undefined, + ) } publishAdminOrderRealtimeChange(Number(completion.task.order_id)) return getAdminSemiAutoTask(taskId) @@ -1155,7 +1426,7 @@ export async function cancelAdminSemiAutoTask( }) }) if (task.connector_session_id) { - await revokeHuyaSession(task.connector_session_id, task.connector_mode).catch(() => undefined) + await revokeSessionForTask(task, task.connector_session_id).catch(() => undefined) } publishAdminOrderRealtimeChange(Number(task.order_id)) return getAdminSemiAutoTask(taskId) diff --git a/apps/backend/src/services/semi-auto/weibo-connector-client.ts b/apps/backend/src/services/semi-auto/weibo-connector-client.ts new file mode 100644 index 00000000..8bc5809b --- /dev/null +++ b/apps/backend/src/services/semi-auto/weibo-connector-client.ts @@ -0,0 +1,100 @@ +import { createHttpError } from '../../utils/http.js' +import type { + ConnectorBindingSession, + ConnectorBrowserTicket, + ConnectorInjectionPayload, + ConnectorLoginSession, + SemiAutoConnectorMode, +} from './huya-connector-client.js' +import { + weiboNativeConnector, + type WeiboCountryCode, +} from './weibo-native/weibo-native-connector.js' + +/** + * 微博半自动 Connector 客户端。 + * + * Mock 闭环阶段只实现 embedded(order_site 进程内);external 保留接口, + * 后续接生产微博 Connector 时再补远程请求。 + */ + +function assertEmbedded(mode: SemiAutoConnectorMode) { + if (mode !== 'embedded') { + throw createHttpError('微博半自动履约目前仅支持内置 Connector', { + statusCode: 503, + errorCode: 'weibo_connector_mode_unsupported', + }) + } +} + +export async function createWeiboLoginSession( + input: { taskId: number; orderNo: string }, + mode: SemiAutoConnectorMode = 'embedded', +): Promise { + assertEmbedded(mode) + return weiboNativeConnector.createQrLoginSession(input) +} + +export async function getWeiboLoginSession( + sessionId: string, + mode: SemiAutoConnectorMode = 'embedded', +): Promise { + assertEmbedded(mode) + return weiboNativeConnector.getQrLoginSession(sessionId) +} + +export async function sendWeiboSmsCode( + mobile: string, + countryCode: string, + mode: SemiAutoConnectorMode = 'embedded', +): Promise { + assertEmbedded(mode) + await weiboNativeConnector.sendSmsCode(mobile, countryCode) +} + +export async function loginWeiboSms( + input: { taskId: number; orderNo: string; mobile: string; code: string; countryCode?: string }, + mode: SemiAutoConnectorMode = 'embedded', +): Promise<{ sessionId: string; session: ConnectorLoginSession }> { + assertEmbedded(mode) + return weiboNativeConnector.loginWithSms(input) +} + +export async function getWeiboCountryCodes( + mode: SemiAutoConnectorMode = 'embedded', +): Promise { + assertEmbedded(mode) + return weiboNativeConnector.getCountryCodes() +} + +export async function getWeiboBinding( + sessionId: string, + mode: SemiAutoConnectorMode = 'embedded', +): Promise { + assertEmbedded(mode) + return weiboNativeConnector.getBinding(sessionId) +} + +export async function createWeiboBrowserTicket( + input: { sessionId: string; targetUrl?: string }, + mode: SemiAutoConnectorMode = 'embedded', +): Promise { + assertEmbedded(mode) + return weiboNativeConnector.createBrowserTicket(input) +} + +export async function consumeWeiboBrowserTicket( + ticket: string, + mode: SemiAutoConnectorMode = 'embedded', +): Promise { + assertEmbedded(mode) + return weiboNativeConnector.consumeBrowserTicket(ticket) +} + +export async function revokeWeiboSession( + sessionId: string, + mode: SemiAutoConnectorMode = 'embedded', +): Promise { + assertEmbedded(mode) + await weiboNativeConnector.revokeSession(sessionId) +} diff --git a/apps/backend/src/services/semi-auto/weibo-native/weibo-native-connector.ts b/apps/backend/src/services/semi-auto/weibo-native/weibo-native-connector.ts new file mode 100644 index 00000000..4ec880a8 --- /dev/null +++ b/apps/backend/src/services/semi-auto/weibo-native/weibo-native-connector.ts @@ -0,0 +1,750 @@ +/** + * order_site 内置微博半自动 Connector(Mock 闭环用)。 + * + * 支持: + * - 微博扫码登录(passport /sso/v2/qrcode/*) + * - 手机号短信验证码登录(passport /sso/v2/sms/send + /sso/v2/login) + * - 登录后账号信息 + * - 一次性浏览器注入票据 + * + * 手机号、验证码、Cookie 都只保存在内存会话中,不落库、不写日志。 + */ + +import crypto from 'node:crypto' + +import { createHttpError } from '../../../utils/http.js' +import type { + ConnectorBindingSession, + ConnectorBrowserTicket, + ConnectorInjectionPayload, + ConnectorLoginSession, +} from '../huya-connector-client.js' + +const PASSPORT_BASE = 'https://passport.weibo.com' +const WEIBO_BASE = 'https://weibo.com' +const WEIBO_LOGIN_URL = + 'https://weibo.com/newlogin?tabtype=weibo&gid=102803&openLoginLayer=0&url=https%3A%2F%2Fweibo.com%2F' +const WEIBO_LOGIN_VER = '20250520' +const SIGNIN_REFERER = + `${PASSPORT_BASE}/sso/signin?entry=miniblog&source=miniblog&disp=popup` + + `&url=${encodeURIComponent(WEIBO_LOGIN_URL)}&from=weibopro` +const PC_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/155.0.0.0 Safari/537.36' + +const LOGIN_QR_TTL_MS = 5 * 60 * 1000 +const CREDENTIAL_TTL_MS = 24 * 60 * 60 * 1000 +const BROWSER_TICKET_TTL_MS = 2 * 60 * 1000 +const SMS_COOKIE_TTL_MS = 10 * 60 * 1000 +const COUNTRY_CODE_TTL_MS = 60 * 60 * 1000 +const MAX_SESSIONS = 200 +const TICKET_PREFIX = 'weibo-ticket-' + +export type WeiboCountryCode = { + code: string + name: string +} + +const FALLBACK_COUNTRY_CODES: WeiboCountryCode[] = [ + { code: '86', name: '中国' }, + { code: '852', name: '中国香港' }, + { code: '886', name: '中国台湾' }, + { code: '853', name: '中国澳门' }, + { code: '81', name: '日本' }, +] + +// 微博网页登录 JS 会对手机号/验证码做一层 XOR + base64url + 字母替换编码。 +const SSO_ENCODE_KEY = 'x9Lp2WnQ' +const SSO_BASE_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' +const SSO_MAP_ALPHABET = 'iaAehJvQ7RIDBX5L31T0kcf9ElVbpMdPZSmzoN_uxGO-2rFUtyY6sHCwKj4Wqgn8' + +function encodeSsoField(value: string): string { + const bytes = Buffer.from(String(value || ''), 'utf8') + const key = Buffer.from(SSO_ENCODE_KEY, 'utf8') + const xored = Buffer.allocUnsafe(bytes.length) + for (let index = 0; index < bytes.length; index += 1) { + const byte = bytes[index] ?? 0 + const keyByte = key[index % key.length] ?? 0 + xored[index] = byte ^ keyByte + } + const encoded = xored + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + let result = '' + for (const char of encoded) { + const alphabetIndex = SSO_BASE_ALPHABET.indexOf(char) + result += alphabetIndex >= 0 ? SSO_MAP_ALPHABET[alphabetIndex] : char + } + return result +} + +type FetchLike = typeof fetch + +type WeiboAccount = { + uid: string + nickname: string + avatar: string +} + +type WeiboTicket = { + ticket: string + targetUrl: string + cookie: string + expiresAtMs: number +} + +type WeiboSession = { + sessionId: string + taskId: number + orderNo: string + createdAtMs: number + expiresAtMs: number + status: ConnectorLoginSession['status'] + qrid: string + qrImage: string + message: string + cookie: string + account: WeiboAccount | null + tickets: Map +} + +export type WeiboNativeConnectorOptions = { + fetchImpl?: FetchLike + now?: () => number + timeoutMs?: number + stateRoot?: string +} + +function iso(value: number): string { + return new Date(value).toISOString() +} + +function normalizeCookiePairs(cookie: string): Array<[string, string]> { + const pairs: Array<[string, string]> = [] + for (const part of String(cookie || '').split(';')) { + const index = part.indexOf('=') + if (index <= 0) continue + const key = part.slice(0, index).trim() + const value = part.slice(index + 1).trim() + if (key) pairs.push([key, value]) + } + return pairs +} + +function cookieValue(cookie: string, key: string): string { + for (const [itemKey, itemValue] of normalizeCookiePairs(cookie)) { + if (itemKey === key) return itemValue + } + return '' +} + +function buildCookieHeader(pairs: Map): string { + return Array.from(pairs.entries()) + .map(([key, value]) => `${key}=${value}`) + .join('; ') +} + +function readSetCookie(headers: Headers): string[] { + const withGetSetCookie = headers as Headers & { getSetCookie?: () => string[] } + if (typeof withGetSetCookie.getSetCookie === 'function') { + return withGetSetCookie.getSetCookie() + } + return (headers.get('set-cookie') || '').split(/,(?=\s*[^;,=\s]+=)/g).filter(Boolean) +} + +export class WeiboNativeConnector { + private readonly sessions = new Map() + private readonly smsCookies = new Map() + private countryCodeCache: { items: WeiboCountryCode[]; expiresAtMs: number } | null = null + private readonly fetchImpl: FetchLike + private readonly now: () => number + private readonly timeoutMs: number + + constructor(options: WeiboNativeConnectorOptions = {}) { + this.fetchImpl = options.fetchImpl || fetch + this.now = options.now || (() => Date.now()) + this.timeoutMs = Math.max(1000, options.timeoutMs || 15_000) + } + + async createQrLoginSession(input: { + taskId: number + orderNo: string + }): Promise { + this.prune() + const sessionId = crypto.randomBytes(18).toString('base64url') + const response = await this.fetchImpl( + `${PASSPORT_BASE}/sso/v2/qrcode/image?entry=miniblog&size=180`, + { + method: 'GET', + headers: this.jsonHeaders(), + signal: AbortSignal.timeout(this.timeoutMs), + }, + ) + if (!response.ok) { + throw createHttpError(`获取微博登录二维码失败:HTTP ${response.status}`, { + statusCode: 502, + errorCode: 'weibo_qrcode_failed', + }) + } + const payload = (await response.json().catch(() => null)) as { + retcode?: number + msg?: string + data?: { qrid?: string; image?: string } + } | null + if (!payload || Number(payload.retcode) !== 20000000 || !payload.data?.qrid) { + throw createHttpError(payload?.msg || '获取微博登录二维码失败', { + statusCode: 502, + errorCode: 'weibo_qrcode_failed', + }) + } + const qrImage = await this.downloadQrImage(String(payload.data.image || '')) + const nowMs = this.now() + const session: WeiboSession = { + sessionId, + taskId: Number(input.taskId) || 0, + orderNo: String(input.orderNo || ''), + createdAtMs: nowMs, + expiresAtMs: nowMs + CREDENTIAL_TTL_MS, + status: 'waiting_scan', + qrid: String(payload.data.qrid), + qrImage, + message: '请使用微博 App 扫码登录', + cookie: '', + account: null, + tickets: new Map(), + } + this.sessions.set(sessionId, session) + return this.loginView(session) + } + + async getQrLoginSession(sessionId: string): Promise { + const session = this.requireSession(sessionId) + if (session.status === 'authenticated') return this.loginView(session) + if (session.status === 'expired' || session.status === 'failed') return this.loginView(session) + const checkUrl = + `${PASSPORT_BASE}/sso/v2/qrcode/check?entry=miniblog&source=miniblog` + + `&url=${encodeURIComponent(WEIBO_LOGIN_URL)}&qrid=${encodeURIComponent(session.qrid)}` + + `&disp=popup&rid=norid&ver=${WEIBO_LOGIN_VER}` + const response = await this.fetchImpl(checkUrl, { + method: 'GET', + headers: this.jsonHeaders(), + signal: AbortSignal.timeout(this.timeoutMs), + }) + if (!response.ok) { + session.status = 'failed' + session.message = `微博扫码状态查询失败:HTTP ${response.status}` + return this.loginView(session) + } + const payload = (await response.json().catch(() => null)) as { + retcode?: number + msg?: string + data?: { url?: string } + } | null + const retcode = Number(payload?.retcode || 0) + if (retcode === 20000000) { + const loginUrl = String(payload?.data?.url || '').trim() + if (!loginUrl) { + session.status = 'failed' + session.message = '微博登录成功但缺少跳转地址' + return this.loginView(session) + } + await this.finishLogin( + session, + loginUrl, + buildCookieHeader(this.collectCookiePairs(response.headers)), + ) + return this.loginView(session) + } + if ([50114000, 50114001].includes(retcode)) { + session.status = 'waiting_scan' + session.message = '请使用微博 App 扫码登录' + return this.loginView(session) + } + if (retcode === 50114002) { + session.status = 'scanned' + session.message = '已扫码,请在微博客户端确认登录' + return this.loginView(session) + } + if ([50114003, 50114004, 50114015].includes(retcode)) { + session.status = 'expired' + session.message = '登录二维码已过期,请刷新' + return this.loginView(session) + } + session.status = 'failed' + session.message = String(payload?.msg || '微博扫码登录失败') + return this.loginView(session) + } + + async getCountryCodes(): Promise { + const nowMs = this.now() + if (this.countryCodeCache && this.countryCodeCache.expiresAtMs > nowMs) { + return this.countryCodeCache.items + } + try { + const body = new URLSearchParams({ entry: 'miniblog', source: 'miniblog' }) + const response = await this.fetchImpl(`${PASSPORT_BASE}/sso/v2/web/config`, { + method: 'POST', + headers: this.formHeaders(), + body, + signal: AbortSignal.timeout(this.timeoutMs), + }) + if (response.ok) { + const payload = (await response.json().catch(() => null)) as { + retcode?: number + data?: { + country_code?: Array<{ + country_code?: string | number + local?: { zh_CN?: string } + }> + } + } | null + if (Number(payload?.retcode) === 20000000) { + const items = (payload?.data?.country_code || []) + .map((item) => ({ + code: String(item.country_code || '').trim(), + name: String(item.local?.zh_CN || '').trim(), + })) + .filter((item) => item.code && item.name) + if (items.length > 0) { + this.countryCodeCache = { items, expiresAtMs: nowMs + COUNTRY_CODE_TTL_MS } + return items + } + } + } + } catch { + // 国家码只影响输入体验,拉取失败时使用内置兜底列表。 + } + return FALLBACK_COUNTRY_CODES + } + + async sendSmsCode(mobileInput: string, countryCodeInput: unknown = '86'): Promise { + this.prune() + const mobile = this.normalizeMobile(mobileInput, countryCodeInput) + const body = new URLSearchParams({ + entry: 'miniblog', + mobile: encodeSsoField(mobile), + el: '2', + }) + const response = await this.fetchImpl(`${PASSPORT_BASE}/sso/v2/sms/send`, { + method: 'POST', + headers: this.formHeaders(), + body, + signal: AbortSignal.timeout(this.timeoutMs), + }) + const payload = (await response.json().catch(() => null)) as { + retcode?: number + msg?: string + data?: { act?: string; mfa_id?: string } + } | null + if (Number(payload?.retcode) === 0 && String(payload?.data?.act || '') === 'mfa_1') { + throw createHttpError('微博要求二次验证,请改用扫码登录', { + statusCode: 409, + errorCode: 'weibo_sms_mfa_required', + }) + } + if (!response.ok || !payload || Number(payload.retcode) !== 20000000) { + throw createHttpError(payload?.msg || `发送微博短信验证码失败:HTTP ${response.status}`, { + statusCode: 409, + errorCode: 'weibo_sms_send_failed', + }) + } + const sendCookie = buildCookieHeader(this.collectCookiePairs(response.headers)) + if (sendCookie) { + this.smsCookies.set(mobile, { + cookie: sendCookie, + expiresAtMs: this.now() + SMS_COOKIE_TTL_MS, + }) + } + } + + async loginWithSms(input: { + taskId: number + orderNo: string + mobile: string + code: string + countryCode?: unknown + }): Promise<{ sessionId: string; session: ConnectorLoginSession }> { + this.prune() + const mobile = this.normalizeMobile(input.mobile, input.countryCode) + const code = String(input.code || '').trim() + if (!code) { + throw createHttpError('请输入微博短信验证码', { + statusCode: 400, + errorCode: 'weibo_sms_code_required', + }) + } + const body = new URLSearchParams({ + entry: 'miniblog', + source: 'miniblog', + type: '2', + url: WEIBO_LOGIN_URL, + ver: WEIBO_LOGIN_VER, + username: encodeSsoField(mobile), + scode: encodeSsoField(code), + el: '2', + disp: 'popup', + rid: 'norid', + }) + const response = await this.fetchImpl(`${PASSPORT_BASE}/sso/v2/login`, { + method: 'POST', + headers: this.formHeaders(), + body, + signal: AbortSignal.timeout(this.timeoutMs), + }) + const payload = (await response.json().catch(() => null)) as { + retcode?: number + msg?: string + data?: { data?: { act?: string; location?: string; mfa_id?: string } } + } | null + if (!response.ok || !payload) { + throw createHttpError(`微博短信登录失败:HTTP ${response.status}`, { + statusCode: 502, + errorCode: 'weibo_sms_login_failed', + }) + } + const act = String(payload.data?.data?.act || '') + if (act === 'mfa_1' || act === 'mfa_2') { + throw createHttpError('微博要求二次验证,请在微博 App 完成验证后重试', { + statusCode: 409, + errorCode: 'weibo_sms_mfa_required', + }) + } + if (act !== 'goto') { + throw createHttpError(payload.msg || '微博短信登录失败,请检查验证码', { + statusCode: 409, + errorCode: 'weibo_sms_login_failed', + }) + } + const location = String(payload.data?.data?.location || '').trim() + if (!location) { + throw createHttpError('微博登录成功但缺少跳转地址', { + statusCode: 502, + errorCode: 'weibo_sms_login_failed', + }) + } + const nowMs = this.now() + const session: WeiboSession = { + sessionId: crypto.randomBytes(18).toString('base64url'), + taskId: Number(input.taskId) || 0, + orderNo: String(input.orderNo || ''), + createdAtMs: nowMs, + expiresAtMs: nowMs + CREDENTIAL_TTL_MS, + status: 'authenticated', + qrid: '', + qrImage: '', + message: '微博登录成功', + cookie: '', + account: null, + tickets: new Map(), + } + const pendingSmsCookie = this.smsCookies.get(mobile) + this.smsCookies.delete(mobile) + const responseCookie = buildCookieHeader(this.collectCookiePairs(response.headers)) + const seedCookie = [pendingSmsCookie?.cookie || '', responseCookie].filter(Boolean).join('; ') + await this.finishLogin(session, location, seedCookie) + this.sessions.set(session.sessionId, session) + return { sessionId: session.sessionId, session: this.loginView(session) } + } + + async getBinding(sessionId: string): Promise { + const session = this.requireSession(sessionId) + if (session.status !== 'authenticated' || !session.account) { + throw createHttpError('微博账号尚未登录', { + statusCode: 409, + errorCode: 'weibo_login_required', + }) + } + return this.bindingView(session) + } + + async createBrowserTicket(input: { + sessionId: string + targetUrl?: string + }): Promise { + const session = this.requireSession(input.sessionId) + if (!session.cookie) { + throw createHttpError('微博登录态不存在或已过期', { + statusCode: 404, + errorCode: 'weibo_credential_not_found', + }) + } + const ticket = `${TICKET_PREFIX}${crypto.randomBytes(32).toString('base64url')}` + const targetUrl = String(input.targetUrl || '').trim() || 'https://weibo.com/' + const expiresAtMs = this.now() + BROWSER_TICKET_TTL_MS + session.tickets.set(ticket, { + ticket, + targetUrl, + cookie: session.cookie, + expiresAtMs, + }) + return { ticket, expiresAt: iso(expiresAtMs), targetUrl } + } + + async consumeBrowserTicket(ticketInput: string): Promise { + this.prune() + const ticket = String(ticketInput || '').trim() + if (!ticket) { + throw createHttpError('注入票据无效', { + statusCode: 400, + errorCode: 'weibo_ticket_invalid', + }) + } + for (const session of this.sessions.values()) { + const item = session.tickets.get(ticket) + if (!item) continue + session.tickets.delete(ticket) + if (item.expiresAtMs <= this.now()) { + throw createHttpError('浏览器注入票据不存在或已过期', { + statusCode: 404, + errorCode: 'weibo_ticket_expired', + }) + } + return { + platform: 'weibo', + targetUrl: item.targetUrl, + expiresAt: iso(item.expiresAtMs), + cookies: normalizeCookiePairs(item.cookie) + .filter(([name, value]) => name && value) + .map(([name, value]) => ({ + name, + value, + domain: '.weibo.com', + path: '/', + secure: true, + })), + } + } + throw createHttpError('浏览器注入票据不存在或已过期', { + statusCode: 404, + errorCode: 'weibo_ticket_not_found', + }) + } + + async revokeSession(sessionIdInput: string): Promise { + const sessionId = String(sessionIdInput || '').trim() + const session = this.sessions.get(sessionId) + if (!session) return + session.cookie = '' + session.tickets.clear() + this.sessions.delete(sessionId) + } + + private async finishLogin( + session: WeiboSession, + loginUrl: string, + seedCookie = '', + ): Promise { + const { cookie } = await this.followWithCookies(loginUrl, seedCookie) + if (!cookie || !cookieValue(cookie, 'SUB')) { + session.status = 'failed' + session.message = '微博登录成功但未获取到登录 Cookie' + return + } + session.cookie = cookie + session.account = await this.fetchAccount(cookie) + session.status = 'authenticated' + session.message = '微博登录成功' + } + + private collectCookiePairs(headers: Headers): Map { + const pairs = new Map() + for (const raw of readSetCookie(headers)) { + const first = String(raw || '').split(';', 1)[0] || '' + const separator = first.indexOf('=') + if (separator <= 0) continue + const key = first.slice(0, separator).trim() + const value = first.slice(separator + 1).trim() + if (key) pairs.set(key, value) + } + return pairs + } + + private async followWithCookies( + url: string, + seedCookie = '', + ): Promise<{ cookie: string; finalUrl: string }> { + const pairs = new Map(normalizeCookiePairs(seedCookie)) + let currentUrl = url + for (let index = 0; index < 8; index += 1) { + const response = await this.fetchImpl(currentUrl, { + method: 'GET', + headers: { + 'user-agent': PC_UA, + accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'accept-language': 'zh-CN,zh;q=0.9', + ...(pairs.size > 0 ? { cookie: buildCookieHeader(pairs) } : {}), + }, + redirect: 'manual', + signal: AbortSignal.timeout(this.timeoutMs), + }) + for (const [key, value] of this.collectCookiePairs(response.headers)) { + pairs.set(key, value) + } + const location = response.headers.get('location') + if (response.status >= 300 && response.status < 400 && location) { + currentUrl = new URL(location, currentUrl).toString() + continue + } + await response.arrayBuffer().catch(() => undefined) + break + } + return { cookie: buildCookieHeader(pairs), finalUrl: currentUrl } + } + + private async fetchAccount(cookie: string): Promise { + try { + const response = await this.fetchImpl(`${WEIBO_BASE}/ajax/side/info`, { + method: 'GET', + headers: { + 'user-agent': PC_UA, + referer: 'https://weibo.com/', + 'x-requested-with': 'XMLHttpRequest', + accept: 'application/json, text/plain, */*', + cookie, + }, + signal: AbortSignal.timeout(this.timeoutMs), + }) + const payload = (await response.json().catch(() => null)) as { + data?: { + user?: { + id?: number | string + screen_name?: string + avatar_large?: string + } + } + } | null + const user = payload?.data?.user + return { + uid: String(user?.id || ''), + nickname: String(user?.screen_name || ''), + avatar: String(user?.avatar_large || ''), + } + } catch { + return { uid: '', nickname: '', avatar: '' } + } + } + + private async downloadQrImage(imageUrl: string): Promise { + const url = String(imageUrl || '').trim() + if (!url) return '' + const response = await this.fetchImpl(url, { + method: 'GET', + headers: { + 'user-agent': PC_UA, + referer: 'https://passport.weibo.com/', + accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + }, + signal: AbortSignal.timeout(this.timeoutMs), + }) + if (!response.ok) return '' + const contentType = String(response.headers.get('content-type') || 'image/png').split(';', 1)[0] + const buffer = Buffer.from(await response.arrayBuffer()) + if (buffer.length === 0) return '' + return `data:${contentType || 'image/png'};base64,${buffer.toString('base64')}` + } + + private loginView(session: WeiboSession): ConnectorLoginSession { + return { + sessionId: session.sessionId, + status: session.status, + qrContent: '', + qrImage: session.qrImage, + expiresAt: iso(session.createdAtMs + LOGIN_QR_TTL_MS), + huyaUid: session.account?.uid || '', + nickname: session.account?.nickname || '', + hasCredential: Boolean(session.cookie), + message: session.message, + } + } + + private bindingView(session: WeiboSession): ConnectorBindingSession { + const account = session.account + return { + sessionId: session.sessionId, + status: 'confirmed', + qrImage: '', + qrExpiresAt: '', + bindGameAccount: account?.nickname || '', + bindGameRole: account?.uid || '', + bindGameChannel: '微博', + hasExistingBinding: true, + rebindRequested: false, + message: '微博账号已授权', + } + } + + private requireSession(sessionIdInput: string): WeiboSession { + this.prune() + const sessionId = String(sessionIdInput || '').trim() + const session = this.sessions.get(sessionId) + if (!session) { + throw createHttpError('微博登录会话不存在或已过期', { + statusCode: 404, + errorCode: 'weibo_session_not_found', + }) + } + return session + } + + private normalizeMobile(value: string, countryCodeInput: unknown = '86'): string { + const digits = String(value || '').replace(/\D/g, '') + if (!digits) { + throw createHttpError('请输入手机号', { + statusCode: 400, + errorCode: 'weibo_mobile_required', + }) + } + const countryCode = String(countryCodeInput ?? '').replace(/\D/g, '') || '86' + // 微博网页端:大陆手机号直接使用 11 位号码;其他地区使用 00 + 国家码 + 号码。 + if (countryCode === '86') { + if (digits.startsWith('86') && digits.length > 11) return digits.slice(2) + return digits + } + if (digits.startsWith('00')) return digits + return `00${countryCode}${digits}` + } + + private jsonHeaders(): Record { + return { + 'user-agent': PC_UA, + origin: PASSPORT_BASE, + referer: SIGNIN_REFERER, + accept: 'application/json, text/plain, */*', + 'accept-language': 'zh-CN,zh;q=0.9', + 'x-requested-with': 'XMLHttpRequest', + } + } + + private formHeaders(): Record { + return { + ...this.jsonHeaders(), + 'content-type': 'application/x-www-form-urlencoded', + } + } + + private prune(): void { + const nowMs = this.now() + for (const [mobile, item] of Array.from(this.smsCookies.entries())) { + if (item.expiresAtMs <= nowMs) this.smsCookies.delete(mobile) + } + for (const [sessionId, session] of Array.from(this.sessions.entries())) { + if (session.expiresAtMs <= nowMs) { + session.cookie = '' + session.tickets.clear() + this.sessions.delete(sessionId) + } + } + if (this.sessions.size <= MAX_SESSIONS) return + const overflow = this.sessions.size - MAX_SESSIONS + for (const [sessionId, session] of Array.from(this.sessions.entries()).slice(0, overflow)) { + session.cookie = '' + session.tickets.clear() + this.sessions.delete(sessionId) + } + } +} + +export const weiboNativeConnector = new WeiboNativeConnector() +export const WEIBO_TICKET_PREFIX = TICKET_PREFIX diff --git a/apps/backend/src/services/semi-auto/weibo-native/weibo-native.test.ts b/apps/backend/src/services/semi-auto/weibo-native/weibo-native.test.ts new file mode 100644 index 00000000..6d39b357 --- /dev/null +++ b/apps/backend/src/services/semi-auto/weibo-native/weibo-native.test.ts @@ -0,0 +1,224 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { WEIBO_TICKET_PREFIX, WeiboNativeConnector } from './weibo-native-connector.js' + +type CapturedCall = { url: string; init: RequestInit } + +function jsonResponse(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +function createFetchMock( + handler: (url: string, init: RequestInit) => Response | Promise, +) { + const calls: CapturedCall[] = [] + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : String(input.url) + const requestInit = init || {} + calls.push({ url, init: requestInit }) + return handler(url, requestInit) + }) as typeof fetch + return { fetchImpl, calls } +} + +function defaultHandler(url: string): Response { + if (url.includes('/sso/v2/web/config')) { + return jsonResponse({ + retcode: 20000000, + data: { + country_code: [ + { country_code: '86', local: { zh_CN: '中国' } }, + { country_code: '852', local: { zh_CN: '中国香港' } }, + { country_code: '886', local: { zh_CN: '中国台湾' } }, + ], + }, + }) + } + if (url.includes('/sso/v2/qrcode/image')) { + return jsonResponse({ + retcode: 20000000, + data: { qrid: 'qr-1', image: 'https://v2.qr.weibo.cn/qr-demo.png' }, + }) + } + if (url.includes('/sso/v2/qrcode/check')) { + return jsonResponse({ retcode: 50114001, msg: '未使用', data: null }) + } + if (url.startsWith('https://v2.qr.weibo.cn/')) { + return new Response(Buffer.from('fake-png'), { + status: 200, + headers: { 'content-type': 'image/png' }, + }) + } + if (url.includes('/sso/v2/sms/send')) { + return jsonResponse({ retcode: 20000000 }) + } + if (url.includes('/sso/v2/login')) { + return jsonResponse({ + retcode: 20000000, + data: { + data: { + act: 'goto', + location: 'https://passport.weibo.com/sso/v2/goto?ticket=demo', + }, + }, + }) + } + if (url.includes('/sso/v2/goto')) { + return new Response(null, { + status: 302, + headers: { + location: 'https://weibo.com/', + 'set-cookie': 'SUB=demo-sub; Path=/; Domain=.weibo.com', + }, + }) + } + if (url === 'https://weibo.com/') { + return new Response('', { + status: 200, + headers: { 'content-type': 'text/html' }, + }) + } + if (url.includes('/ajax/side/info')) { + return jsonResponse({ + data: { + user: { + id: 123456, + screen_name: '微博测试账号', + avatar_large: 'https://tvax1.sinaimg.cn/demo.jpg', + }, + }, + }) + } + return new Response('not found', { status: 404 }) +} + +test('微博二维码会话解析 qrid 并把图片转成 base64', async () => { + const { fetchImpl, calls } = createFetchMock(defaultHandler) + const connector = new WeiboNativeConnector({ fetchImpl }) + const session = await connector.createQrLoginSession({ taskId: 1, orderNo: 'MOCKWB1' }) + + assert.equal(session.status, 'waiting_scan') + assert.match(session.qrImage || '', /^data:image\/png;base64,/) + assert.ok(calls.some((call) => call.url.includes('qrcode/image?entry=miniblog&size=180'))) +}) + +test('微博二维码未扫码时保持等待扫码状态', async () => { + const { fetchImpl } = createFetchMock(defaultHandler) + const connector = new WeiboNativeConnector({ fetchImpl }) + const session = await connector.createQrLoginSession({ taskId: 1, orderNo: 'MOCKWB1' }) + const polled = await connector.getQrLoginSession(session.sessionId) + assert.equal(polled.status, 'waiting_scan') +}) + +test('微博发送短信验证码使用大陆 11 位手机号', async () => { + const { fetchImpl, calls } = createFetchMock(defaultHandler) + const connector = new WeiboNativeConnector({ fetchImpl }) + await connector.sendSmsCode('18900762182') + + const call = calls.find((item) => item.url.includes('/sso/v2/sms/send')) + assert.ok(call) + const form = new URLSearchParams(String(call.init.body)) + assert.equal(form.get('entry'), 'miniblog') + assert.equal(form.get('mobile'), 'T3JH3iRZfvXRi9K') + assert.equal(form.get('mfa_id'), null) + assert.equal(form.get('el'), '2') +}) + +test('微博短信发送支持其他国家码并按 00 前缀编码', async () => { + const { fetchImpl, calls } = createFetchMock(defaultHandler) + const connector = new WeiboNativeConnector({ fetchImpl }) + await connector.sendSmsCode('61234567', '852') + + const call = calls.find((item) => item.url.includes('/sso/v2/sms/send')) + assert.ok(call) + const form = new URLSearchParams(String(call.init.body)) + assert.equal(form.get('mobile'), 'TiNs13aS9CXDe9Nva3') +}) + +test('微博国家码列表来自 web config 并缓存', async () => { + const { fetchImpl, calls } = createFetchMock(defaultHandler) + const connector = new WeiboNativeConnector({ fetchImpl }) + const first = await connector.getCountryCodes() + const second = await connector.getCountryCodes() + + assert.deepEqual(first, [ + { code: '86', name: '中国' }, + { code: '852', name: '中国香港' }, + { code: '886', name: '中国台湾' }, + ]) + assert.deepEqual(second, first) + assert.equal(calls.filter((call) => call.url.includes('/sso/v2/web/config')).length, 1) +}) + +test('微博短信登录会带上发码响应下发的 Cookie', async () => { + const { fetchImpl, calls } = createFetchMock((url) => { + if (url.includes('/sso/v2/sms/send')) { + return new Response(JSON.stringify({ retcode: 20000000 }), { + status: 200, + headers: { + 'content-type': 'application/json', + 'set-cookie': 'SCF=send-cookie; Path=/', + }, + }) + } + return defaultHandler(url) + }) + const connector = new WeiboNativeConnector({ fetchImpl }) + await connector.sendSmsCode('13800138000') + const { session } = await connector.loginWithSms({ + taskId: 1, + orderNo: 'MOCKWB1', + mobile: '13800138000', + code: '123456', + }) + + assert.equal(session.status, 'authenticated') + const redirectCall = calls.find((item) => item.url.includes('/sso/v2/goto')) + assert.ok(redirectCall) + const headers = redirectCall.init.headers as Record + assert.match(headers.cookie || '', /SCF=send-cookie/) +}) + +test('微博短信登录建立会话并可生成一次性注入票据', async () => { + const { fetchImpl, calls } = createFetchMock(defaultHandler) + const connector = new WeiboNativeConnector({ fetchImpl }) + const { sessionId, session } = await connector.loginWithSms({ + taskId: 1, + orderNo: 'MOCKWB1', + mobile: '138 0013 8000', + code: '123456', + }) + + assert.equal(session.status, 'authenticated') + assert.equal(session.huyaUid, '123456') + assert.equal(session.nickname, '微博测试账号') + + const loginCall = calls.find((item) => item.url.includes('/sso/v2/login')) + assert.ok(loginCall) + const form = new URLSearchParams(String(loginCall.init.body)) + assert.equal(form.get('type'), '2') + assert.equal(form.get('username'), 'T3Gs3iR_9fN7A9t') + assert.equal(form.get('scode'), 'T3r81iMS') + + const ticket = await connector.createBrowserTicket({ + sessionId, + targetUrl: 'https://weibo.com/', + }) + assert.ok(ticket.ticket.startsWith(WEIBO_TICKET_PREFIX)) + + const payload = await connector.consumeBrowserTicket(ticket.ticket) + assert.equal(payload.platform, 'weibo') + assert.equal(payload.targetUrl, 'https://weibo.com/') + assert.ok(payload.cookies.some((cookie) => cookie.name === 'SUB' && cookie.value === 'demo-sub')) + + await assert.rejects(() => connector.consumeBrowserTicket(ticket.ticket), /票据不存在或已过期/) +}) diff --git a/apps/frontend/src/pages/admin/AdminDevMockPage.tsx b/apps/frontend/src/pages/admin/AdminDevMockPage.tsx index e5dc481e..d0b6bdf5 100644 --- a/apps/frontend/src/pages/admin/AdminDevMockPage.tsx +++ b/apps/frontend/src/pages/admin/AdminDevMockPage.tsx @@ -304,6 +304,7 @@ export default function AdminDevMockPage() { 内部 SKU / 任务
+ {lastSemiAuto.semiAutoPlatform === 'weibo' ? '微博' : '虎牙'} ·{' '} {lastSemiAuto.skuKey} · 任务 #{lastSemiAuto.taskId}
@@ -525,6 +526,7 @@ function SemiAutoMockForm({ externalConnectorConfigured?: boolean mockConnectorAvailable?: boolean onSubmit: (values: { + platform: 'huya' | 'weibo' orderNo?: string skuName?: string quantity?: number @@ -534,6 +536,8 @@ function SemiAutoMockForm({ }) => void }) { const [form] = Form.useForm() + const selectedPlatform = + (Form.useWatch('platform', form) as 'huya' | 'weibo' | undefined) || 'huya' const defaultMode = defaultConnectorMode === 'mock' && mockConnectorAvailable ? 'mock' @@ -549,6 +553,7 @@ function SemiAutoMockForm({ form={form} layout="vertical" initialValues={{ + platform: 'huya', quantity: 1, pageTitle: '虎牙账号授权测试', browserTargetUrl: 'https://www.huya.com/', @@ -557,37 +562,73 @@ function SemiAutoMockForm({ onFinish={onSubmit} > + + ) : ( + + )} - + - + @@ -602,26 +643,34 @@ function SemiAutoMockForm({ name="browserTargetUrl" extra="客服点击“打开浏览器登录态”时,扩展会注入到此页面。" > - + + } + /> + ) : null} + + setMobile(event.target.value)} + placeholder="手机号" + maxLength={countryCode === '86' ? 11 : 15} + inputMode="numeric" + style={{ flex: 1 }} + /> + + + + setSmsCode(event.target.value)} + placeholder="请输入短信验证码" + maxLength={8} + onPressEnter={() => void handleLoginWeiboSms()} + /> + + + + 如微博要求二次验证导致登录失败,请改用扫码登录。 + + + ), + }, + { + key: 'qr', + label: '扫码登录', + children: ( + + + + + ), + }, + ]} /> - - + ) : ( + + + + + )} ) : null} {loginQrVisible ? ( - + {loginQrImage ? ( - 虎牙登录二维码 + {isWeibo ) : ( )} {loginInfo?.message || '等待扫码'} {selectedTask.loginStatus === 'scanned' ? ( - 已扫码,请在 App 确认 + + {isWeibo ? '已扫码,请在微博客户端确认登录' : '已扫码,请在 App 确认'} + ) : null} @@ -503,8 +768,12 @@ export default function CustomerFulfillmentPage() { ) : null} @@ -513,8 +782,20 @@ export default function CustomerFulfillmentPage() { ) } +function isValidWeiboMobile(countryCode: string, value: string) { + const digits = value.replace(/\D/g, '') + if (!digits) return false + if (countryCode === '86') return /^1\d{10}$/.test(digits) + return digits.length >= 5 && digits.length <= 15 +} + function resolveStep(task: CustomerSemiAutoTask | null, binding: CustomerBindingInfo | null) { if (!task) return 0 + if (task.platform === 'weibo') { + if (task.executionStatus === 'completed') return 2 + if (task.bindingStatus === 'confirmed') return 1 + return 0 + } if (task.executionStatus === 'completed') return 3 if (task.bindingStatus === 'confirmed') return 2 if (task.loginStatus === 'authenticated' || binding) return 1 diff --git a/apps/frontend/src/services/admin/dev-mock.ts b/apps/frontend/src/services/admin/dev-mock.ts index 933fa7af..7f54e343 100644 --- a/apps/frontend/src/services/admin/dev-mock.ts +++ b/apps/frontend/src/services/admin/dev-mock.ts @@ -53,6 +53,7 @@ export type DevMockKuaishouIndustryVoucherResult = { export type DevMockSemiAutoResult = { platform: 'semi_auto' + semiAutoPlatform: 'huya' | 'weibo' orderId: number orderNo: string orderItemId: number @@ -89,6 +90,7 @@ export function createKuaishouIndustryVoucherDevMock(payload: { sellerId?: strin } export function createSemiAutoDevMock(payload: { + platform?: 'huya' | 'weibo' orderNo?: string skuName?: string quantity?: number diff --git a/apps/frontend/src/services/customer-fulfillment.ts b/apps/frontend/src/services/customer-fulfillment.ts index 8e1cf980..bd24406a 100644 --- a/apps/frontend/src/services/customer-fulfillment.ts +++ b/apps/frontend/src/services/customer-fulfillment.ts @@ -54,3 +54,49 @@ export function confirmCustomerBinding(orderNo: string, taskId: number) { {}, ) } + +export type CustomerWeiboCountryCode = { + code: string + name: string +} + +export function fetchCustomerWeiboCountryCodes(orderNo: string, taskId: number) { + return apiGet<{ countryCodes: CustomerWeiboCountryCode[] }>( + `/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/weibo/config`, + ) +} + +export function sendCustomerWeiboSmsCode( + orderNo: string, + taskId: number, + mobile: string, + countryCode: string, +) { + return apiPost<{ ok: boolean; message: string }>( + `/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/weibo/sms/send`, + { mobile, countryCode }, + ) +} + +export function loginCustomerWeiboSms( + orderNo: string, + taskId: number, + input: { mobile: string; code: string; countryCode: string }, +) { + return apiPost<{ task: CustomerSemiAutoTask; login: CustomerLoginInfo }>( + `/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/weibo/sms/login`, + input, + ) +} + +export function startCustomerWeiboQrLogin(orderNo: string, taskId: number) { + return apiGet<{ task: CustomerSemiAutoTask; login: CustomerLoginInfo }>( + `/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/weibo/qrcode`, + ) +} + +export function pollCustomerWeiboQrLogin(orderNo: string, taskId: number) { + return apiGet<{ task: CustomerSemiAutoTask; login: CustomerLoginInfo | null }>( + `/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/weibo/qrcode/status`, + ) +}