feat(semi-auto): 支持微博半自动履约与手机号国家码登录
新增微博内置 Connector:扫码/短信登录、Cookie 获取与一次性浏览器票据;客户页支持国家码选择、短信/扫码登录及登录即授权;dev-mock 与后台任务页适配微博 Mock 单。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<DevMockSemiAutoResult> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ConnectorLoginSession> {
|
||||
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<ConnectorLoginSession> {
|
||||
return task.platform === 'weibo'
|
||||
? getWeiboLoginSession(sessionId, task.connector_mode)
|
||||
: getHuyaLoginSession(sessionId, task.connector_mode)
|
||||
}
|
||||
|
||||
async function revokeSessionForTask(task: TaskRow, sessionId: string): Promise<void> {
|
||||
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<ConnectorBindingSession> {
|
||||
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<string, unknown> = {}) {
|
||||
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)
|
||||
|
||||
@@ -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<ConnectorLoginSession> {
|
||||
assertEmbedded(mode)
|
||||
return weiboNativeConnector.createQrLoginSession(input)
|
||||
}
|
||||
|
||||
export async function getWeiboLoginSession(
|
||||
sessionId: string,
|
||||
mode: SemiAutoConnectorMode = 'embedded',
|
||||
): Promise<ConnectorLoginSession> {
|
||||
assertEmbedded(mode)
|
||||
return weiboNativeConnector.getQrLoginSession(sessionId)
|
||||
}
|
||||
|
||||
export async function sendWeiboSmsCode(
|
||||
mobile: string,
|
||||
countryCode: string,
|
||||
mode: SemiAutoConnectorMode = 'embedded',
|
||||
): Promise<void> {
|
||||
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<WeiboCountryCode[]> {
|
||||
assertEmbedded(mode)
|
||||
return weiboNativeConnector.getCountryCodes()
|
||||
}
|
||||
|
||||
export async function getWeiboBinding(
|
||||
sessionId: string,
|
||||
mode: SemiAutoConnectorMode = 'embedded',
|
||||
): Promise<ConnectorBindingSession> {
|
||||
assertEmbedded(mode)
|
||||
return weiboNativeConnector.getBinding(sessionId)
|
||||
}
|
||||
|
||||
export async function createWeiboBrowserTicket(
|
||||
input: { sessionId: string; targetUrl?: string },
|
||||
mode: SemiAutoConnectorMode = 'embedded',
|
||||
): Promise<ConnectorBrowserTicket> {
|
||||
assertEmbedded(mode)
|
||||
return weiboNativeConnector.createBrowserTicket(input)
|
||||
}
|
||||
|
||||
export async function consumeWeiboBrowserTicket(
|
||||
ticket: string,
|
||||
mode: SemiAutoConnectorMode = 'embedded',
|
||||
): Promise<ConnectorInjectionPayload> {
|
||||
assertEmbedded(mode)
|
||||
return weiboNativeConnector.consumeBrowserTicket(ticket)
|
||||
}
|
||||
|
||||
export async function revokeWeiboSession(
|
||||
sessionId: string,
|
||||
mode: SemiAutoConnectorMode = 'embedded',
|
||||
): Promise<void> {
|
||||
assertEmbedded(mode)
|
||||
await weiboNativeConnector.revokeSession(sessionId)
|
||||
}
|
||||
@@ -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<string, WeiboTicket>
|
||||
}
|
||||
|
||||
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, string>): 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<string, WeiboSession>()
|
||||
private readonly smsCookies = new Map<string, { cookie: string; expiresAtMs: number }>()
|
||||
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<ConnectorLoginSession> {
|
||||
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<ConnectorLoginSession> {
|
||||
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<WeiboCountryCode[]> {
|
||||
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<void> {
|
||||
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<ConnectorBindingSession> {
|
||||
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<ConnectorBrowserTicket> {
|
||||
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<ConnectorInjectionPayload> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, string> {
|
||||
const pairs = new Map<string, string>()
|
||||
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<string, string>(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<WeiboAccount> {
|
||||
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<string> {
|
||||
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<string, string> {
|
||||
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<string, string> {
|
||||
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
|
||||
@@ -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<Response>,
|
||||
) {
|
||||
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('<html></html>', {
|
||||
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<string, string>
|
||||
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), /票据不存在或已过期/)
|
||||
})
|
||||
@@ -304,6 +304,7 @@ export default function AdminDevMockPage() {
|
||||
<Col xs={24} md={12}>
|
||||
<Typography.Text type="secondary">内部 SKU / 任务</Typography.Text>
|
||||
<div>
|
||||
{lastSemiAuto.semiAutoPlatform === 'weibo' ? '微博' : '虎牙'} ·{' '}
|
||||
{lastSemiAuto.skuKey} · 任务 #{lastSemiAuto.taskId}
|
||||
</div>
|
||||
</Col>
|
||||
@@ -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}
|
||||
>
|
||||
<Form.Item
|
||||
label="Mock 范围"
|
||||
name="connectorMode"
|
||||
extra="只 Mock 订单时,登录、角色绑定和浏览器票据注入可走内置真实 Connector,也可显式使用远程 Connector。"
|
||||
label="履约平台"
|
||||
name="platform"
|
||||
extra="微博 Mock 单登录成功即视为授权完成,不再有角色绑定步骤。"
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{
|
||||
value: 'embedded',
|
||||
label: '仅 Mock 订单(内置真实登录 / 绑定 / 注入)',
|
||||
},
|
||||
{
|
||||
value: 'external',
|
||||
label: '仅 Mock 订单(远程真实 Connector)',
|
||||
},
|
||||
{
|
||||
value: 'mock',
|
||||
label: '订单 + 登录绑定都 Mock(内置 Connector)',
|
||||
disabled: mockConnectorAvailable === false,
|
||||
},
|
||||
{ value: 'huya', label: '虎牙(扫码登录 + 游戏角色绑定)' },
|
||||
{ value: 'weibo', label: '微博(短信 / 扫码登录,登录即授权)' },
|
||||
]}
|
||||
onChange={(value: 'huya' | 'weibo') => {
|
||||
form.setFieldsValue({
|
||||
pageTitle: value === 'weibo' ? '微博发帖授权测试' : '虎牙账号授权测试',
|
||||
browserTargetUrl: value === 'weibo' ? 'https://weibo.com/' : 'https://www.huya.com/',
|
||||
...(value === 'weibo' ? { connectorMode: 'embedded' } : {}),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{selectedPlatform === 'huya' ? (
|
||||
<Form.Item
|
||||
label="Mock 范围"
|
||||
name="connectorMode"
|
||||
extra="只 Mock 订单时,登录、角色绑定和浏览器票据注入可走内置真实 Connector,也可显式使用远程 Connector。"
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{
|
||||
value: 'embedded',
|
||||
label: '仅 Mock 订单(内置真实登录 / 绑定 / 注入)',
|
||||
},
|
||||
{
|
||||
value: 'external',
|
||||
label: '仅 Mock 订单(远程真实 Connector)',
|
||||
},
|
||||
{
|
||||
value: 'mock',
|
||||
label: '订单 + 登录绑定都 Mock(内置 Connector)',
|
||||
disabled: mockConnectorAvailable === false,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="微博目前只支持内置 Connector,连接模式固定为 embedded。"
|
||||
/>
|
||||
)}
|
||||
<Form.Item
|
||||
label="内部 SKU 名称(可空)"
|
||||
name="skuName"
|
||||
extra="留空自动生成“虎牙半自动测试-时间戳”;必须包含中文。"
|
||||
extra={`留空自动生成“${
|
||||
selectedPlatform === 'weibo' ? '微博半自动' : '虎牙半自动'
|
||||
}测试-时间戳”;必须包含中文。`}
|
||||
>
|
||||
<Input allowClear placeholder="虎牙半自动测试" />
|
||||
<Input
|
||||
allowClear
|
||||
placeholder={selectedPlatform === 'weibo' ? '微博半自动测试' : '虎牙半自动测试'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="订单号 orderNo(可空)" name="orderNo">
|
||||
<Input allowClear placeholder="留空自动 MOCKSA…" />
|
||||
<Input
|
||||
allowClear
|
||||
placeholder={selectedPlatform === 'weibo' ? '留空自动 MOCKWB…' : '留空自动 MOCKSA…'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Space size={12} style={{ display: 'flex' }}>
|
||||
<Form.Item label="数量" name="quantity" style={{ flex: 1 }}>
|
||||
@@ -602,26 +643,34 @@ function SemiAutoMockForm({
|
||||
name="browserTargetUrl"
|
||||
extra="客服点击“打开浏览器登录态”时,扩展会注入到此页面。"
|
||||
>
|
||||
<Input placeholder="https://www.huya.com/" />
|
||||
<Input
|
||||
placeholder={
|
||||
selectedPlatform === 'weibo' ? 'https://weibo.com/' : 'https://www.huya.com/'
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Alert
|
||||
type={
|
||||
selectedMode === 'mock'
|
||||
? 'warning'
|
||||
: selectedMode === 'external' && !externalConnectorConfigured
|
||||
? 'error'
|
||||
: 'success'
|
||||
selectedPlatform === 'weibo'
|
||||
? 'info'
|
||||
: selectedMode === 'mock'
|
||||
? 'warning'
|
||||
: selectedMode === 'external' && !externalConnectorConfigured
|
||||
? 'error'
|
||||
: 'success'
|
||||
}
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message={
|
||||
selectedMode === 'mock'
|
||||
? '登录和绑定会使用内置 Mock Connector,仅用于联调页面。'
|
||||
: selectedMode === 'external'
|
||||
? externalConnectorConfigured
|
||||
? '远程真实 Connector 已配置;只有订单由 Mock 生成。'
|
||||
: '远程真实 Connector 未配置:请设置 SEMI_AUTO_CONNECTOR_BASE_URL 和 API_KEY。'
|
||||
: '使用 order_site 内置真实 Connector;无需启动 live-hub-py,只有订单由 Mock 生成。'
|
||||
selectedPlatform === 'weibo'
|
||||
? '微博使用内置真实 Connector:短信验证码会真实发送到客户手机号,扫码需在微博 App 确认;登录成功即视为授权完成。'
|
||||
: selectedMode === 'mock'
|
||||
? '登录和绑定会使用内置 Mock Connector,仅用于联调页面。'
|
||||
: selectedMode === 'external'
|
||||
? externalConnectorConfigured
|
||||
? '远程真实 Connector 已配置;只有订单由 Mock 生成。'
|
||||
: '远程真实 Connector 未配置:请设置 SEMI_AUTO_CONNECTOR_BASE_URL 和 API_KEY。'
|
||||
: '使用 order_site 内置真实 Connector;无需启动 live-hub-py,只有订单由 Mock 生成。'
|
||||
}
|
||||
/>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
|
||||
@@ -290,7 +290,7 @@ export default function AdminSemiAutoTasksPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '绑定角色',
|
||||
title: '授权 / 绑定角色',
|
||||
key: 'binding',
|
||||
width: 190,
|
||||
render: (_, task) =>
|
||||
@@ -302,7 +302,7 @@ export default function AdminSemiAutoTasksPage() {
|
||||
</Text>
|
||||
</Space>
|
||||
) : (
|
||||
<Text type="secondary">未绑定</Text>
|
||||
<Text type="secondary">{task.platform === 'weibo' ? '未授权' : '未绑定'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -481,19 +481,29 @@ export default function AdminSemiAutoTasksPage() {
|
||||
{detail.task.displayStatus.label}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="虎牙 UID">{detail.task.huyaUid || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={detail.task.platform === 'weibo' ? '微博 UID' : '虎牙 UID'}>
|
||||
{detail.task.huyaUid || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="平台昵称">
|
||||
{detail.task.platformNickname || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="游戏账号">
|
||||
<Descriptions.Item label={detail.task.platform === 'weibo' ? '微博昵称' : '游戏账号'}>
|
||||
{detail.task.bindGameAccount || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="游戏角色">
|
||||
{detail.task.bindGameRole || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="区服" span={2}>
|
||||
{detail.task.bindGameChannel || '-'}
|
||||
</Descriptions.Item>
|
||||
{detail.task.platform === 'weibo' ? (
|
||||
<Descriptions.Item label="授权状态">
|
||||
{detail.task.bindingStatus === 'confirmed' ? '登录即授权' : '未授权'}
|
||||
</Descriptions.Item>
|
||||
) : (
|
||||
<Descriptions.Item label="游戏角色">
|
||||
{detail.task.bindGameRole || '-'}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{detail.task.platform !== 'weibo' ? (
|
||||
<Descriptions.Item label="区服" span={2}>
|
||||
{detail.task.bindGameChannel || '-'}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
<Descriptions.Item label="客服">
|
||||
{detail.task.assignedAdminName || '未领取'}
|
||||
</Descriptions.Item>
|
||||
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
Card,
|
||||
Col,
|
||||
Image,
|
||||
Input,
|
||||
Result,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Steps,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
@@ -26,12 +29,18 @@ import { useParams } from 'react-router'
|
||||
import {
|
||||
confirmCustomerBinding,
|
||||
fetchCustomerFulfillment,
|
||||
fetchCustomerWeiboCountryCodes,
|
||||
loginCustomerWeiboSms,
|
||||
markCustomerFulfillmentOpened,
|
||||
pollCustomerBinding,
|
||||
pollCustomerLogin,
|
||||
pollCustomerWeiboQrLogin,
|
||||
sendCustomerWeiboSmsCode,
|
||||
startCustomerBinding,
|
||||
startCustomerLogin,
|
||||
startCustomerWeiboQrLogin,
|
||||
} from '@/services/customer-fulfillment'
|
||||
import type { CustomerWeiboCountryCode } from '@/services/customer-fulfillment'
|
||||
import type {
|
||||
CustomerBindingInfo,
|
||||
CustomerLoginInfo,
|
||||
@@ -43,6 +52,14 @@ const { Text, Title, Paragraph } = Typography
|
||||
|
||||
const POLL_INTERVAL_MS = 2000
|
||||
|
||||
const DEFAULT_WEIBO_COUNTRY_CODES: CustomerWeiboCountryCode[] = [
|
||||
{ code: '86', name: '中国' },
|
||||
{ code: '852', name: '中国香港' },
|
||||
{ code: '886', name: '中国台湾' },
|
||||
{ code: '853', name: '中国澳门' },
|
||||
{ code: '81', name: '日本' },
|
||||
]
|
||||
|
||||
export default function CustomerFulfillmentPage() {
|
||||
const { message } = App.useApp()
|
||||
const { orderNo: routeOrderNo } = useParams<{ orderNo?: string }>()
|
||||
@@ -55,6 +72,17 @@ export default function CustomerFulfillmentPage() {
|
||||
const [bindingInfo, setBindingInfo] = useState<CustomerBindingInfo | null>(null)
|
||||
const [loginQrImage, setLoginQrImage] = useState('')
|
||||
const [bindingActionLoading, setBindingActionLoading] = useState(false)
|
||||
const [loginMode, setLoginMode] = useState<'sms' | 'qr'>('sms')
|
||||
const [mobile, setMobile] = useState('')
|
||||
const [countryCode, setCountryCode] = useState('86')
|
||||
const [countryCodes, setCountryCodes] = useState<CustomerWeiboCountryCode[]>(
|
||||
DEFAULT_WEIBO_COUNTRY_CODES,
|
||||
)
|
||||
const [smsCode, setSmsCode] = useState('')
|
||||
const [smsSending, setSmsSending] = useState(false)
|
||||
const [smsLogging, setSmsLogging] = useState(false)
|
||||
const [smsCountdown, setSmsCountdown] = useState(0)
|
||||
const [smsMfaRequired, setSmsMfaRequired] = useState(false)
|
||||
const startingRef = useRef(false)
|
||||
const hydratingBindingRef = useRef(false)
|
||||
|
||||
@@ -62,6 +90,7 @@ export default function CustomerFulfillmentPage() {
|
||||
() => tasks.find((task) => task.id === selectedTaskId) || tasks[0] || null,
|
||||
[selectedTaskId, tasks],
|
||||
)
|
||||
const isWeibo = selectedTask?.platform === 'weibo'
|
||||
|
||||
const load = useCallback(
|
||||
async (silent = false) => {
|
||||
@@ -101,6 +130,21 @@ export default function CustomerFulfillmentPage() {
|
||||
void markCustomerFulfillmentOpened(orderNo, selectedTask.id).catch(() => undefined)
|
||||
}, [orderNo, selectedTask?.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTask || selectedTask.platform !== 'weibo' || !orderNo) return
|
||||
let cancelled = false
|
||||
void fetchCustomerWeiboCountryCodes(orderNo, selectedTask.id)
|
||||
.then((response) => {
|
||||
if (cancelled) return
|
||||
const list = response.data?.countryCodes || []
|
||||
if (list.length > 0) setCountryCodes(list)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [orderNo, selectedTask?.id, selectedTask?.platform])
|
||||
|
||||
useEffect(() => {
|
||||
if (loginInfo?.qrImage) {
|
||||
setLoginQrImage(loginInfo.qrImage)
|
||||
@@ -117,12 +161,21 @@ export default function CustomerFulfillmentPage() {
|
||||
}).then(setLoginQrImage)
|
||||
}, [loginInfo?.qrContent, loginInfo?.qrImage])
|
||||
|
||||
useEffect(() => {
|
||||
if (smsCountdown <= 0) return
|
||||
const timer = window.setTimeout(() => setSmsCountdown((value) => value - 1), 1000)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [smsCountdown])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTask || !orderNo) return
|
||||
if (!['qr_pending', 'scanned'].includes(selectedTask.loginStatus)) return
|
||||
const tick = async () => {
|
||||
try {
|
||||
const response = await pollCustomerLogin(orderNo, selectedTask.id)
|
||||
const response =
|
||||
selectedTask.platform === 'weibo'
|
||||
? await pollCustomerWeiboQrLogin(orderNo, selectedTask.id)
|
||||
: await pollCustomerLogin(orderNo, selectedTask.id)
|
||||
if (response.data.task) {
|
||||
setTasks((current) =>
|
||||
current.map((task) => (task.id === response.data.task.id ? response.data.task : task)),
|
||||
@@ -137,10 +190,10 @@ export default function CustomerFulfillmentPage() {
|
||||
void tick()
|
||||
}, POLL_INTERVAL_MS)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [orderNo, selectedTask?.id, selectedTask?.loginStatus])
|
||||
}, [orderNo, selectedTask?.id, selectedTask?.loginStatus, selectedTask?.platform])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTask || !orderNo) return
|
||||
if (!selectedTask || !orderNo || selectedTask.platform === 'weibo') return
|
||||
if (
|
||||
selectedTask.loginStatus !== 'authenticated' ||
|
||||
selectedTask.bindingStatus === 'confirmed'
|
||||
@@ -185,7 +238,14 @@ export default function CustomerFulfillmentPage() {
|
||||
}, [message, orderNo, selectedTask?.bindingStatus, selectedTask?.id, selectedTask?.loginStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTask || !orderNo || bindingInfo || hydratingBindingRef.current) return
|
||||
if (
|
||||
!selectedTask ||
|
||||
!orderNo ||
|
||||
selectedTask.platform === 'weibo' ||
|
||||
bindingInfo ||
|
||||
hydratingBindingRef.current
|
||||
)
|
||||
return
|
||||
if (
|
||||
selectedTask.loginStatus !== 'authenticated' ||
|
||||
selectedTask.bindingStatus !== 'role_ready'
|
||||
@@ -216,7 +276,10 @@ export default function CustomerFulfillmentPage() {
|
||||
if (!selectedTask) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await startCustomerLogin(orderNo, selectedTask.id)
|
||||
const response =
|
||||
selectedTask.platform === 'weibo'
|
||||
? await startCustomerWeiboQrLogin(orderNo, selectedTask.id)
|
||||
: await startCustomerLogin(orderNo, selectedTask.id)
|
||||
setTasks((current) =>
|
||||
current.map((task) => (task.id === response.data.task.id ? response.data.task : task)),
|
||||
)
|
||||
@@ -229,6 +292,69 @@ export default function CustomerFulfillmentPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendWeiboSms() {
|
||||
if (!selectedTask) return
|
||||
const phone = mobile.trim()
|
||||
if (!isValidWeiboMobile(countryCode, phone)) {
|
||||
message.warning('请输入正确的手机号')
|
||||
return
|
||||
}
|
||||
setSmsSending(true)
|
||||
try {
|
||||
const response = await sendCustomerWeiboSmsCode(orderNo, selectedTask.id, phone, countryCode)
|
||||
message.success(response.data.message || '验证码已发送,请注意查收')
|
||||
setSmsMfaRequired(false)
|
||||
setSmsCountdown(60)
|
||||
} catch (error) {
|
||||
const errorCode = (error as { errorCode?: string } | null)?.errorCode || ''
|
||||
if (errorCode === 'weibo_sms_mfa_required') {
|
||||
setSmsMfaRequired(true)
|
||||
message.warning('微博要求二次验证,请改用扫码登录')
|
||||
return
|
||||
}
|
||||
message.error(error instanceof Error ? error.message : '发送微博验证码失败')
|
||||
} finally {
|
||||
setSmsSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoginWeiboSms() {
|
||||
if (!selectedTask) return
|
||||
const phone = mobile.trim()
|
||||
if (!isValidWeiboMobile(countryCode, phone)) {
|
||||
message.warning('请输入正确的手机号')
|
||||
return
|
||||
}
|
||||
if (!smsCode.trim()) {
|
||||
message.warning('请输入微博短信验证码')
|
||||
return
|
||||
}
|
||||
setSmsLogging(true)
|
||||
try {
|
||||
const response = await loginCustomerWeiboSms(orderNo, selectedTask.id, {
|
||||
mobile: phone,
|
||||
code: smsCode.trim(),
|
||||
countryCode,
|
||||
})
|
||||
setTasks((current) =>
|
||||
current.map((task) => (task.id === response.data.task.id ? response.data.task : task)),
|
||||
)
|
||||
setLoginInfo(response.data.login)
|
||||
setBindingInfo(null)
|
||||
message.success('微博登录成功,等待客服处理')
|
||||
} catch (error) {
|
||||
const errorCode = (error as { errorCode?: string } | null)?.errorCode || ''
|
||||
if (errorCode === 'weibo_sms_mfa_required') {
|
||||
setSmsMfaRequired(true)
|
||||
message.warning('微博要求二次验证,请改用扫码登录')
|
||||
return
|
||||
}
|
||||
message.error(error instanceof Error ? error.message : '微博短信登录失败')
|
||||
} finally {
|
||||
setSmsLogging(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmBinding() {
|
||||
if (!selectedTask) return
|
||||
setBindingActionLoading(true)
|
||||
@@ -294,7 +420,9 @@ export default function CustomerFulfillmentPage() {
|
||||
subTitle={
|
||||
selectedTask.completionNote
|
||||
? selectedTask.completionNote
|
||||
: '客服已完成本订单的充值或赠送操作,请到平台账号中确认。'
|
||||
: isWeibo
|
||||
? '客服已完成本订单的微博发帖操作,请到微博账号中确认。'
|
||||
: '客服已完成本订单的充值或赠送操作,请到平台账号中确认。'
|
||||
}
|
||||
extra={
|
||||
selectedTask.completedAt
|
||||
@@ -307,13 +435,17 @@ export default function CustomerFulfillmentPage() {
|
||||
}
|
||||
|
||||
const config = selectedTask?.semiAutoConfig || {}
|
||||
const title = config.pageTitle || '账号授权与角色绑定'
|
||||
const title = config.pageTitle || (isWeibo ? '微博账号授权' : '账号授权与角色绑定')
|
||||
const description =
|
||||
config.pageDescription || '请按页面指引完成平台登录和游戏角色绑定,客服随后会处理您的订单。'
|
||||
config.pageDescription ||
|
||||
(isWeibo
|
||||
? '请使用本人微博账号完成登录,登录成功后客服会处理您的订单。'
|
||||
: '请按页面指引完成平台登录和游戏角色绑定,客服随后会处理您的订单。')
|
||||
const currentStep = resolveStep(selectedTask, bindingInfo)
|
||||
const loginQrVisible =
|
||||
selectedTask && ['qr_pending', 'scanned'].includes(selectedTask.loginStatus)
|
||||
const bindingQrVisible =
|
||||
selectedTask?.platform !== 'weibo' &&
|
||||
selectedTask?.loginStatus === 'authenticated' &&
|
||||
['qr_pending', 'scanned', 'role_ready'].includes(selectedTask.bindingStatus)
|
||||
const bindingQrPending = ['qr_pending', 'scanned'].includes(selectedTask?.bindingStatus || '')
|
||||
@@ -374,12 +506,20 @@ export default function CustomerFulfillmentPage() {
|
||||
<Col xs={24} lg={16}>
|
||||
<Steps
|
||||
current={currentStep}
|
||||
items={[
|
||||
{ title: '登录平台', icon: <QrcodeOutlined /> },
|
||||
{ title: '扫码绑定', icon: <SafetyCertificateOutlined /> },
|
||||
{ title: '客服处理', icon: <LoadingOutlined /> },
|
||||
{ title: '完成' },
|
||||
]}
|
||||
items={
|
||||
isWeibo
|
||||
? [
|
||||
{ title: '登录微博', icon: <QrcodeOutlined /> },
|
||||
{ title: '客服处理', icon: <LoadingOutlined /> },
|
||||
{ title: '完成' },
|
||||
]
|
||||
: [
|
||||
{ title: '登录平台', icon: <QrcodeOutlined /> },
|
||||
{ title: '扫码绑定', icon: <SafetyCertificateOutlined /> },
|
||||
{ title: '客服处理', icon: <LoadingOutlined /> },
|
||||
{ title: '完成' },
|
||||
]
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -397,35 +537,160 @@ export default function CustomerFulfillmentPage() {
|
||||
{selectedTask?.loginStatus === 'waiting_login' ||
|
||||
selectedTask?.loginStatus === 'failed' ||
|
||||
selectedTask?.loginStatus === 'expired' ? (
|
||||
<Card title="第一步:登录平台账号">
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="请使用本人账号登录"
|
||||
description={
|
||||
config.loginInstructions ||
|
||||
'点击下方按钮后使用虎牙 App 扫码登录。登录仅用于本次订单履约,不会在页面展示账号 Cookie。'
|
||||
}
|
||||
<Card title={isWeibo ? '第一步:登录微博账号' : '第一步:登录平台账号'}>
|
||||
{isWeibo ? (
|
||||
<Tabs
|
||||
activeKey={loginMode}
|
||||
onChange={(key) => setLoginMode(key as 'sms' | 'qr')}
|
||||
items={[
|
||||
{
|
||||
key: 'sms',
|
||||
label: '手机号登录',
|
||||
children: (
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="使用微博绑定手机号登录"
|
||||
description={
|
||||
config.loginInstructions ||
|
||||
'验证码将发送到你的微博绑定手机号。登录仅用于本次订单履约,不会在页面展示账号 Cookie。'
|
||||
}
|
||||
/>
|
||||
{smsMfaRequired ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="微博要求二次验证"
|
||||
description="当前网络环境触发了微博人机验证,短信登录无法自动完成,请改用扫码登录。"
|
||||
action={
|
||||
<Button size="small" onClick={() => setLoginMode('qr')}>
|
||||
去扫码
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Space.Compact style={{ width: '100%', maxWidth: 520 }}>
|
||||
<Select
|
||||
value={countryCode}
|
||||
onChange={(value) => setCountryCode(String(value))}
|
||||
style={{ width: 110 }}
|
||||
options={countryCodes.map((item) => ({
|
||||
value: item.code,
|
||||
label: `+${item.code}`,
|
||||
name: item.name,
|
||||
}))}
|
||||
optionRender={(option) => (
|
||||
<Space size={6}>
|
||||
<span>{(option.data as { name?: string }).name}</span>
|
||||
<Text type="secondary">+{option.data.value}</Text>
|
||||
</Space>
|
||||
)}
|
||||
filterOption={(input, option) =>
|
||||
String(
|
||||
(option as { name?: string } | undefined)?.name || '',
|
||||
).includes(input) || String(option?.value || '').includes(input)
|
||||
}
|
||||
showSearch
|
||||
popupMatchSelectWidth={240}
|
||||
/>
|
||||
<Input
|
||||
value={mobile}
|
||||
onChange={(event) => setMobile(event.target.value)}
|
||||
placeholder="手机号"
|
||||
maxLength={countryCode === '86' ? 11 : 15}
|
||||
inputMode="numeric"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
loading={smsSending}
|
||||
disabled={smsCountdown > 0}
|
||||
onClick={() => void handleSendWeiboSms()}
|
||||
>
|
||||
{smsCountdown > 0 ? `重新发送(${smsCountdown}s)` : '发送验证码'}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Space.Compact style={{ width: '100%', maxWidth: 520 }}>
|
||||
<Input
|
||||
value={smsCode}
|
||||
onChange={(event) => setSmsCode(event.target.value)}
|
||||
placeholder="请输入短信验证码"
|
||||
maxLength={8}
|
||||
onPressEnter={() => void handleLoginWeiboSms()}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={smsLogging}
|
||||
onClick={() => void handleLoginWeiboSms()}
|
||||
>
|
||||
登录微博
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Text type="secondary">
|
||||
如微博要求二次验证导致登录失败,请改用扫码登录。
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'qr',
|
||||
label: '扫码登录',
|
||||
children: (
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="请使用本人账号登录"
|
||||
description={
|
||||
config.loginInstructions ||
|
||||
'点击下方按钮后使用微博 App 扫码登录。登录仅用于本次订单履约,不会在页面展示账号 Cookie。'
|
||||
}
|
||||
/>
|
||||
<Button type="primary" size="large" onClick={() => void handleStartLogin()}>
|
||||
获取登录二维码
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Button type="primary" size="large" onClick={() => void handleStartLogin()}>
|
||||
获取登录二维码
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="请使用本人账号登录"
|
||||
description={
|
||||
config.loginInstructions ||
|
||||
'点击下方按钮后使用虎牙 App 扫码登录。登录仅用于本次订单履约,不会在页面展示账号 Cookie。'
|
||||
}
|
||||
/>
|
||||
<Button type="primary" size="large" onClick={() => void handleStartLogin()}>
|
||||
获取登录二维码
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{loginQrVisible ? (
|
||||
<Card title="使用虎牙 App 扫码登录">
|
||||
<Card title={isWeibo ? '使用微博 App 扫码登录' : '使用虎牙 App 扫码登录'}>
|
||||
<Space direction="vertical" align="center" size={14} style={{ width: '100%' }}>
|
||||
{loginQrImage ? (
|
||||
<Image src={loginQrImage} alt="虎牙登录二维码" width={260} preview={false} />
|
||||
<Image
|
||||
src={loginQrImage}
|
||||
alt={isWeibo ? '微博登录二维码' : '虎牙登录二维码'}
|
||||
width={260}
|
||||
preview={false}
|
||||
/>
|
||||
) : (
|
||||
<Spin tip="生成二维码..." />
|
||||
)}
|
||||
<Text type="secondary">{loginInfo?.message || '等待扫码'}</Text>
|
||||
{selectedTask.loginStatus === 'scanned' ? (
|
||||
<Tag color="cyan">已扫码,请在 App 确认</Tag>
|
||||
<Tag color="cyan">
|
||||
{isWeibo ? '已扫码,请在微博客户端确认登录' : '已扫码,请在 App 确认'}
|
||||
</Tag>
|
||||
) : null}
|
||||
<Button onClick={() => void handleStartLogin()}>重新获取二维码</Button>
|
||||
</Space>
|
||||
@@ -503,8 +768,12 @@ export default function CustomerFulfillmentPage() {
|
||||
<Card>
|
||||
<Result
|
||||
status="info"
|
||||
title="账号已绑定,客服正在处理"
|
||||
subTitle="客服会使用本次授权完成充值或赠送,请稍后刷新页面查看结果。"
|
||||
title={isWeibo ? '微博账号已授权,客服正在处理' : '账号已绑定,客服正在处理'}
|
||||
subTitle={
|
||||
isWeibo
|
||||
? '客服会使用本次授权完成微博发帖,请稍后刷新页面查看结果。'
|
||||
: '客服会使用本次授权完成充值或赠送,请稍后刷新页面查看结果。'
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
) : 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user