收敛半自动平台为登录获取 CK 流程

移除角色绑定对客户页、状态展示和完成门禁的阻塞;虎牙与微博统一保存并直接导出 CK;同步精简客服工作台、配置项和权威文档。
This commit is contained in:
yml
2026-11-14 01:13:00 +08:00
parent 8a54c41476
commit d178556f03
22 changed files with 526 additions and 732 deletions
@@ -155,7 +155,7 @@ INSERT INTO admin_permissions (
'semi_auto.credential.export',
'导出半自动履约账号 CK',
'半自动履约',
'受控导出单个半自动履约任务的账号级 Cookie;导出动作写入审计但不记录 CK'
'兼容保留的权限记录;CK 导出由后台登录、禁止缓存和审计控制,不再要求该权限'
)
ON CONFLICT (permission_key) DO UPDATE SET
permission_name = EXCLUDED.permission_name,
+2 -3
View File
@@ -13,7 +13,7 @@ import {
syncAdminSemiAutoTask,
} from '../../services/semi-auto/semi-auto-task-service.js'
import { bodyOf, sessionOf } from './request-helpers.js'
import { createJsonHandler, requireAdminPermission, requireAdminRoles } from './session.js'
import { createJsonHandler, requireAdminRoles } from './session.js'
const router = Router()
@@ -39,13 +39,12 @@ router.get(
router.post(
'/semi-auto/tasks/:taskId/credential/export',
requireAdminPermission('semi_auto.credential.export'),
createJsonHandler(
(req, res) => {
res.setHeader('Cache-Control', 'no-store, max-age=0')
res.setHeader('Pragma', 'no-cache')
res.setHeader('Expires', '0')
return exportAdminSemiAutoCredential(req.params.taskId, sessionOf(req))
return exportAdminSemiAutoCredential(req.params.taskId)
},
{
successMessage: '账号 CK 已导出',
@@ -1,16 +1,13 @@
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'
@@ -70,35 +67,6 @@ router.get(
}),
)
router.post(
'/:orderNo/tasks/:taskId/binding/start',
writeLimit,
createRouteHandler((req) => startPublicBinding(req.params.orderNo, req.params.taskId, req.body), {
successMessage: '绑定会话已创建',
errorMessage: '开始账号绑定失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/binding/start]',
}),
)
router.get(
'/:orderNo/tasks/:taskId/binding/status',
readLimit,
createRouteHandler((req) => pollPublicBinding(req.params.orderNo, req.params.taskId), {
errorMessage: '刷新账号绑定状态失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/binding/status]',
}),
)
router.post(
'/:orderNo/tasks/:taskId/binding/confirm',
writeLimit,
createRouteHandler((req) => confirmPublicBinding(req.params.orderNo, req.params.taskId), {
successMessage: '绑定已确认',
errorMessage: '确认账号绑定失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/binding/confirm]',
}),
)
router.get(
'/:orderNo/tasks/:taskId/weibo/config',
readLimit,
@@ -1290,9 +1290,6 @@ function normalizeSemiAutoConfig(payload: JsonObject): {
loginInstructions: String(config.loginInstructions || '')
.trim()
.slice(0, 1000),
bindingInstructions: String(config.bindingInstructions || '')
.trim()
.slice(0, 1000),
browserTargets: Array.isArray(config.browserTargets)
? config.browserTargets
.filter((item): item is Record<string, unknown> =>
@@ -370,12 +370,6 @@ export async function createSemiAutoMock(
: connectorMode === 'mock'
? 'Mock 登录:点击获取二维码后,页面轮询两次会自动模拟登录成功。'
: '真实登录:点击获取二维码后,请使用虎牙 App 扫码并在手机上确认登录。',
bindingInstructions:
platform === 'weibo'
? '微博登录成功即完成授权,无需再绑定游戏角色。'
: connectorMode === 'mock'
? 'Mock 绑定:页面轮询两次后会自动识别角色,点击确认即可。'
: '真实绑定:扫码后等待 Connector 识别角色,核对账号和区服后点击确认绑定。',
...(platform === 'weibo' ? { loginMethods: ['sms', 'qr'] } : {}),
browserTargets: [
{
@@ -6,6 +6,7 @@ import {
consumeMockBrowserTicket,
createMockBrowserTicket,
createMockLoginSession,
exportMockSessionCredential,
getMockBinding,
getMockLoginSession,
resetMockSessions,
@@ -31,6 +32,9 @@ test('Mock Connector 可按轮询自动完成登录、绑定和一次性票据
assert.equal(authenticated.status, 'authenticated')
assert.equal(authenticated.hasCredential, true)
assert.ok(authenticated.huyaUid)
const credential = exportMockSessionCredential(created.sessionId)
assert.equal(credential.platformUid, authenticated.huyaUid)
assert.ok(credential.cookies.length >= 1)
const binding = startMockBinding(created.sessionId)
assert.equal(binding.status, 'qr_pending')
@@ -65,6 +69,12 @@ test('Mock Connector 支持控制状态、撤销会话和重置', () => {
assert.equal(authenticated.status, 'authenticated')
assert.equal(authenticated.hasCredential, true)
const ticket = createMockBrowserTicket({
sessionId: created.sessionId,
targetUrl: 'https://www.huya.com/no-binding',
})
assert.equal(consumeMockBrowserTicket(ticket.ticket).targetUrl, 'https://www.huya.com/no-binding')
const confirmed = setMockBindingStatus(created.sessionId, 'confirmed')
assert.equal(confirmed.status, 'confirmed')
assert.ok(confirmed.bindGameRole)
@@ -12,6 +12,7 @@ import type {
ConnectorInjectionPayload,
ConnectorLoginSession,
} from '../semi-auto/huya-connector-client.js'
import type { HuyaStoredCredential } from '../semi-auto/huya-native/huya-native-connector.js'
import { createHttpError } from '../../utils/http.js'
const LOGIN_QR_TTL_MS = 5 * 60 * 1000
@@ -203,6 +204,42 @@ export function getMockLoginSession(sessionIdInput: unknown): ConnectorLoginSess
return loginView(session)
}
export function exportMockSessionCredential(sessionIdInput: unknown): HuyaStoredCredential {
const session = requireSession(sessionIdInput)
if (session.loginStatus !== 'authenticated' || !session.hasCredential) {
throw createHttpError('Mock 登录尚未完成,不能导出 CK', {
statusCode: 409,
errorCode: 'mock_semi_auto_credential_not_ready',
})
}
return {
platformUid: session.huyaUid,
nickname: session.nickname,
cookies: [
{
name: 'mock_huya_uid',
value: session.huyaUid,
domain: '.huya.com',
path: '/',
secure: true,
httpOnly: false,
hostOnly: false,
expiresAt: null,
},
{
name: 'mock_huya_session',
value: session.sessionId,
domain: '.huya.com',
path: '/',
secure: true,
httpOnly: true,
hostOnly: false,
expiresAt: null,
},
],
}
}
export function startMockBinding(sessionIdInput: unknown): ConnectorBindingSession {
const session = requireSession(sessionIdInput)
if (session.loginStatus !== 'authenticated') {
@@ -259,10 +296,10 @@ export function createMockBrowserTicket(input: {
targetUrl?: unknown
}): ConnectorBrowserTicket {
const session = requireSession(input.sessionId)
if (session.bindingStatus !== 'confirmed') {
throw createHttpError('Mock 角色尚未绑定,不能生成浏览器票据', {
if (session.loginStatus !== 'authenticated' || !session.hasCredential) {
throw createHttpError('Mock 登录尚未完成,不能生成浏览器票据', {
statusCode: 409,
errorCode: 'mock_semi_auto_binding_required',
errorCode: 'mock_semi_auto_login_required',
})
}
const ticket = `mock-ticket-${crypto.randomUUID()}`
@@ -45,6 +45,44 @@ test('创建登录会话按真实 Connector 协议发送蛇形字段', async ()
}
})
test('外部虎牙 Connector 支持导出账号级 CK', async () => {
const originalFetch = globalThis.fetch
let requestUrl = ''
globalThis.fetch = (async (url: string | URL | Request) => {
requestUrl = String(url)
return new Response(
JSON.stringify({
platformUid: '1686664168',
nickname: '外部虎牙账号',
cookies: [
{
name: 'udb_cred',
value: 'external-cred',
domain: '.huya.com',
path: '/',
secure: true,
httpOnly: true,
hostOnly: false,
expiresAt: null,
},
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof fetch
try {
const credential = await connector.exportHuyaSessionCredential('session-1', 'external')
assert.equal(
requestUrl,
'http://connector.test/api/internal/huya-semi-auto/sessions/session-1/credential',
)
assert.equal(credential.platformUid, '1686664168')
assert.equal(credential.cookies[0]?.value, 'external-cred')
} finally {
globalThis.fetch = originalFetch
}
})
test('重新扫码换绑会向 Connector 传递 force_rebind', async () => {
const originalFetch = globalThis.fetch
let requestBody: Record<string, unknown> = {}
@@ -6,6 +6,7 @@ import {
devMockConnectorBaseUrl,
} from '../dev-mock/connector-endpoint.js'
import { isDevMockEnabled } from '../dev-mock/dev-mock-enabled.js'
import { exportMockSessionCredential } from '../dev-mock/mock-semi-auto-connector.js'
import {
HUYA_NATIVE_TICKET_PREFIX,
huyaNativeConnector,
@@ -276,17 +277,17 @@ export async function createHuyaBrowserTicket(
)
}
export function exportHuyaSessionCredential(
export async function exportHuyaSessionCredential(
sessionId: string,
mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(),
): HuyaStoredCredential {
if (mode !== 'embedded') {
throw createHttpError('当前虎牙 Connector 模式不支持直接导出 CK', {
statusCode: 409,
errorCode: 'semi_auto_credential_export_unsupported',
})
}
return huyaNativeConnector.exportSessionCredential(sessionId)
): Promise<HuyaStoredCredential> {
if (mode === 'embedded') return huyaNativeConnector.exportSessionCredential(sessionId)
if (mode === 'mock') return exportMockSessionCredential(sessionId)
return callConnector<HuyaStoredCredential>(
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}/credential`,
{},
mode,
)
}
export function inferSemiAutoConnectorModeFromTicket(ticket: string): SemiAutoConnectorMode {
@@ -168,25 +168,15 @@ function displayStatus(task: TaskRow) {
if (task.credential_status === 'invalid' || task.credential_status === 'error') {
return { key: 'credential_expired', label: '登录已失效,需重新授权' }
}
if (
task.execution_status === 'failed' ||
task.login_status === 'failed' ||
task.binding_status === 'failed'
) {
if (task.execution_status === 'failed' || task.login_status === 'failed') {
return { key: 'failed', label: '需客服处理' }
}
if (task.binding_status === 'confirmed') {
return { key: 'waiting_operator', label: '等待客服处理' }
}
if (task.binding_status === 'role_ready') {
return { key: 'binding_confirm', label: '待确认绑定' }
}
if (task.login_status === 'authenticated') {
return { key: 'waiting_binding', label: '等待扫码绑定' }
return { key: 'waiting_operator', label: 'CK 已获取,等待客服处理' }
}
if (task.login_status === 'scanned') return { key: 'login_scanned', label: '已扫码,确认中' }
if (task.login_status === 'qr_pending') return { key: 'waiting_login', label: '等待客户登录' }
return { key: 'waiting_customer', label: '等待客户打开链接' }
return { key: 'waiting_customer', label: '等待客户授权' }
}
function mapTask(task: TaskRow) {
@@ -418,7 +408,6 @@ async function resetTaskForCustomerLogin(
UPDATE semi_auto_tasks
SET connector_session_id = '',
login_status = 'waiting_login',
binding_status = 'not_started',
execution_status = 'waiting_customer',
customer_link_opened_at = NULL,
auth_started_at = NULL,
@@ -437,8 +426,6 @@ async function resetTaskForCustomerLogin(
...(input.actorId ? { actorId: input.actorId } : {}),
fromLoginStatus: task.login_status,
toLoginStatus: 'waiting_login',
fromBindingStatus: task.binding_status,
toBindingStatus: 'not_started',
fromExecutionStatus: task.execution_status,
toExecutionStatus: 'waiting_customer',
detail: { reason: input.reason },
@@ -673,25 +660,20 @@ 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.platform === 'weibo' || task.binding_status === 'confirmed'
? 'waiting_operator'
: 'waiting_customer'
: task.execution_status
mapped.status === 'authenticated' ? 'waiting_operator' : task.execution_status
const storedCredential =
mapped.status === 'authenticated' && mapped.hasCredential
? task.platform === 'weibo'
? exportWeiboSessionCredential(session.sessionId, task.connector_mode)
: task.connector_mode === 'embedded'
? exportHuyaSessionCredential(session.sessionId, task.connector_mode)
: null
: await exportHuyaSessionCredential(session.sessionId, task.connector_mode)
: null
if (mapped.status === 'authenticated' && !storedCredential) {
throw createHttpError('平台登录成功但未能获取账号 CK,请联系客服处理', {
statusCode: 502,
errorCode: 'semi_auto_credential_export_failed',
})
}
await withTransaction(async (client) => {
const credentialId = storedCredential
? await upsertSemiAutoCredentialWithClient(client, {
@@ -704,17 +686,12 @@ async function updateLoginTask(
`
UPDATE semi_auto_tasks
SET login_status = $2,
credential_id = COALESCE($8, credential_id),
credential_id = COALESCE($7, credential_id),
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 $7 ELSE '' END,
last_error = CASE WHEN $2 IN ('failed', 'expired') THEN $6 ELSE '' END,
updated_at = NOW()
WHERE id = $1
`,
@@ -724,7 +701,6 @@ async function updateLoginTask(
mapped.uid,
mapped.nickname,
nextExecutionStatus,
autoConfirmWeiboBinding,
mapped.message,
credentialId,
],
@@ -741,22 +717,6 @@ 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: '微博登录成功,授权已完成',
},
})
}
})
}
@@ -893,7 +853,6 @@ export async function startPublicLogin(orderNoInput: unknown, taskIdInput: unkno
UPDATE semi_auto_tasks
SET connector_session_id = $2,
login_status = 'qr_pending',
binding_status = 'not_started',
execution_status = 'waiting_customer',
auth_started_at = COALESCE(auth_started_at, NOW()),
last_error = '',
@@ -1130,16 +1089,11 @@ export async function loginPublicWeiboSms(
SET connector_session_id = $2,
credential_id = COALESCE($5, credential_id),
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
@@ -1155,23 +1109,6 @@ export async function loginPublicWeiboSms(
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) {
@@ -1303,15 +1240,8 @@ export async function getAdminSemiAutoTask(taskIdInput: unknown) {
}
}
export async function exportAdminSemiAutoCredential(taskIdInput: unknown, session: AdminSession) {
export async function exportAdminSemiAutoCredential(taskIdInput: unknown) {
const task = await loadTask(normalizeId(taskIdInput))
const adminUserId = Number(session.userId || 0)
if (task.assigned_admin_user_id && Number(task.assigned_admin_user_id) !== adminUserId) {
throw createHttpError('该任务已被其他客服领取', {
statusCode: 409,
errorCode: 'semi_auto_task_claimed',
})
}
if (!task.credential_id) {
throw createHttpError('当前任务尚未保存账号 CK', {
statusCode: 404,
@@ -1392,10 +1322,6 @@ export async function syncAdminSemiAutoTask(taskIdInput: unknown) {
await updateLoginTask(task, login, 'connector')
task = await loadTask(taskId)
}
if (task.login_status === 'authenticated' && task.binding_status !== 'confirmed') {
const binding = await getBindingForTask(task, task.connector_session_id)
await updateBindingTask(task, binding, 'connector')
}
return getAdminSemiAutoTask(taskId)
}
@@ -1450,10 +1376,10 @@ export async function createAdminBrowserSession(
errorCode: 'semi_auto_task_claimed',
})
}
if (!task.connector_session_id || task.binding_status !== 'confirmed') {
throw createHttpError('客户尚未完成登录和绑定', {
if (!task.connector_session_id || task.login_status !== 'authenticated') {
throw createHttpError('客户尚未完成登录,或登录会话已失效', {
statusCode: 409,
errorCode: 'semi_auto_binding_required',
errorCode: 'semi_auto_login_required',
})
}
const config = safeParseJson(task.semi_auto_config_json)
@@ -1591,10 +1517,15 @@ export async function completeAdminSemiAutoTask(
errorCode: 'semi_auto_task_cancelled',
})
}
if (!task.connector_session_id || task.binding_status !== 'confirmed') {
throw createHttpError('客户尚未完成登录和绑定,不能完成履约', {
if (
task.login_status !== 'authenticated' ||
!task.credential_id ||
task.credential_status === 'invalid' ||
task.credential_status === 'error'
) {
throw createHttpError('客户账号 CK 尚未就绪或已失效,不能完成履约', {
statusCode: 409,
errorCode: 'semi_auto_binding_required',
errorCode: 'semi_auto_credential_required',
})
}
if (task.assigned_admin_user_id && Number(task.assigned_admin_user_id) !== adminUserId) {
@@ -17,6 +17,7 @@ test(
const { closeDb, query } = await import('../../db/client.js')
const {
ensureSemiAutoTasksForOrder,
exportAdminSemiAutoCredential,
listAdminSemiAutoTasks,
listPublicSemiAutoTasks,
requestAdminSemiAutoCustomerReauth,
@@ -117,7 +118,7 @@ test(
last_checked_at, last_check_error, created_at, updated_at
) VALUES (
'huya', $1, '集成测试虎牙', '{"cookies":[]}',
'qr', 'invalid', NOW(), NOW(), 'CK 已失效', NOW(), NOW()
'qr', 'valid', NOW(), NOW(), '', NOW(), NOW()
) RETURNING id
`,
[`it-huya-${suffix}`],
@@ -129,16 +130,21 @@ test(
SET credential_id = $2,
connector_session_id = $3,
login_status = 'authenticated',
binding_status = 'confirmed',
binding_status = 'not_started',
execution_status = 'waiting_operator',
customer_link_opened_at = NOW(),
authenticated_at = NOW(),
bound_at = NOW(),
updated_at = NOW()
WHERE id = $1
`,
[taskId, credentialId, `it-huya-session-${suffix}`],
)
const authenticatedList = await listAdminSemiAutoTasks({ keyword: orderNo })
assert.equal(authenticatedList.items[0]?.loginStatus, 'authenticated')
assert.equal(authenticatedList.items[0]?.displayStatus.label, 'CK 已获取,等待客服处理')
const exported = await exportAdminSemiAutoCredential(taskId)
assert.equal(exported.credentialId, credentialId)
assert.equal(exported.platform, 'huya')
const reauthorized = await requestAdminSemiAutoCustomerReauth(taskId, {
userId: 1,
@@ -186,6 +192,7 @@ test(
let orderId = 0
let configId = 0
let adminId = 0
let credentialId = 0
let triggerCreated = false
try {
const admin = await query<{ id: number }>(
@@ -260,19 +267,33 @@ test(
const ensured = await ensureSemiAutoTasksForOrder(orderNo)
const taskId = Number(ensured.createdIds[0] || 0)
assert.ok(taskId > 0)
const credential = await query<{ id: number }>(
`
INSERT INTO semi_auto_credentials (
platform, platform_uid, platform_nickname, credential_payload_json,
credential_source, credential_status, credential_updated_at,
last_checked_at, last_check_error, created_at, updated_at
) VALUES (
'huya', $1, '半自动原子性账号', '{"cookies":[]}',
'qr', 'valid', NOW(), NOW(), '', NOW(), NOW()
) RETURNING id
`,
[`it-atomic-huya-${suffix}`],
)
credentialId = Number(credential.rows[0]?.id || 0)
assert.ok(credentialId > 0)
await query(
`
UPDATE semi_auto_tasks
SET connector_session_id = $2,
SET credential_id = $2,
login_status = 'authenticated',
binding_status = 'confirmed',
binding_status = 'not_started',
execution_status = 'waiting_operator',
authenticated_at = NOW(),
bound_at = NOW(),
updated_at = NOW()
WHERE id = $1
`,
[taskId, `it-session-${suffix}`],
[taskId, credentialId],
)
await query(
@@ -390,6 +411,9 @@ test(
if (configId > 0) {
await query('DELETE FROM manual_fulfillment_configs WHERE id = $1', [configId])
}
if (credentialId > 0) {
await query('DELETE FROM semi_auto_credentials WHERE id = $1', [credentialId])
}
if (adminId > 0) {
await query('DELETE FROM admin_users WHERE id = $1', [adminId])
}
@@ -48,7 +48,6 @@ import {
} from '@/services/admin/semi-auto'
import type { SemiAutoTask } from '@/types/admin/semi-auto'
import type { UploadedFile } from '@/types/worker-platform'
import { hasAdminPermission } from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
const { Text } = Typography
@@ -290,24 +289,19 @@ export default function AdminSemiAutoTasksPage() {
}
function credentialMenu(task: SemiAutoTask) {
const canExport = hasAdminPermission('semi_auto.credential.export')
return {
items: [
...(canExport
? [
{
key: 'copy',
icon: <KeyOutlined />,
label: '复制最终 CK',
disabled: !task.credentialReady || credentialExportMutation.isPending,
},
]
: []),
{
key: 'copy',
icon: <KeyOutlined />,
label: '复制最终 CK',
disabled: !task.credentialReady || credentialExportMutation.isPending,
},
{
key: 'inject',
icon: <ChromeOutlined />,
label: '注入浏览器',
disabled: task.bindingStatus !== 'confirmed' || injectionMutation.isPending,
disabled: !taskCanUseCredential(task) || injectionMutation.isPending,
},
],
onClick: ({ key }: { key: string }) => {
@@ -438,20 +432,19 @@ export default function AdminSemiAutoTasksPage() {
),
},
{
title: '授权 / 绑定角色',
key: 'binding',
title: '账号凭证',
key: 'credential',
width: 190,
render: (_, task) =>
task.bindGameRole ? (
<Space direction="vertical" size={2}>
<Text strong>{task.bindGameRole}</Text>
<Text type="secondary">
{[task.bindGameAccount, task.bindGameChannel].filter(Boolean).join(' · ') || '-'}
</Text>
</Space>
) : (
<Text type="secondary">{task.platform === 'weibo' ? '未授权' : '未绑定'}</Text>
),
render: (_, task) => (
<Space direction="vertical" size={2}>
<Text strong>{task.platformNickname || task.huyaUid || '未获取账号'}</Text>
<Text type="secondary">
{task.credentialSource
? `${task.credentialSource} · ${formatAdminDateTime(task.credentialUpdatedAt)}`
: '尚无 CK'}
</Text>
</Space>
),
},
{
title: '客服',
@@ -497,7 +490,7 @@ export default function AdminSemiAutoTasksPage() {
size="small"
type="primary"
icon={<CheckCircleOutlined />}
disabled={task.bindingStatus !== 'confirmed'}
disabled={!taskCanUseCredential(task)}
onClick={() => openComplete(task)}
>
完成
@@ -649,23 +642,12 @@ export default function AdminSemiAutoTasksPage() {
</Text>
</Space>
</Descriptions.Item>
<Descriptions.Item label={detail.task.platform === 'weibo' ? '微博昵称' : '游戏账号'}>
{detail.task.bindGameAccount || '-'}
<Descriptions.Item label="平台昵称">
{detail.task.platformNickname || '-'}
</Descriptions.Item>
<Descriptions.Item label="登录状态">
{detail.task.loginStatus === 'authenticated' ? 'CK 已获取' : '等待客户登录'}
</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>
@@ -800,3 +782,12 @@ function taskNeedsCustomerReauth(task: SemiAutoTask) {
task.loginStatus === 'expired'
)
}
function taskCanUseCredential(task: SemiAutoTask) {
return (
task.loginStatus === 'authenticated' &&
task.credentialReady &&
task.credentialStatus !== 'invalid' &&
task.credentialStatus !== 'error'
)
}
@@ -820,7 +820,7 @@ function resolveTaskListBlocker(item: AdminTaskListItem): TaskListBlocker {
level: 'normal',
detail: item.roleConfirmedAt
? '客户已提交角色信息,等待平台履约收口。'
: '等待客户打开链接、绑定或提交信息。',
: '等待客户授权、绑定或提交信息。',
showDetail: true,
}
}
@@ -72,7 +72,6 @@ type ConfigFormValues = {
pageTitle?: string
pageDescription?: string
loginInstructions?: string
bindingInstructions?: string
}
}
@@ -694,9 +693,6 @@ export function ManualSkuConfigPanel() {
<Form.Item name={['semiAutoConfig', 'loginInstructions']} label="登录指引">
<Input.TextArea rows={2} maxLength={1000} showCount />
</Form.Item>
<Form.Item name={['semiAutoConfig', 'bindingInstructions']} label="绑定指引">
<Input.TextArea rows={2} maxLength={1000} showCount />
</Form.Item>
</>
) : null
}
@@ -5,44 +5,27 @@ import {
ReloadOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons'
import { App, Button, Image, Input, Select, Space, Spin, Tabs } from 'antd'
import { App, Button, Image, Input, Spin, Tabs } from 'antd'
import QRCode from 'qrcode'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
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,
CustomerSemiAutoTask,
} from '@/types/customer-fulfillment'
import type { CustomerLoginInfo, CustomerSemiAutoTask } from '@/types/customer-fulfillment'
import { formatAdminDateTime } from '@/utils/admin-time'
import { runWeiboYidunChallenge } from '@/utils/weibo-yidun'
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 }>()
@@ -52,22 +35,14 @@ export default function CustomerFulfillmentPage() {
const [tasks, setTasks] = useState<CustomerSemiAutoTask[]>([])
const [selectedTaskId, setSelectedTaskId] = useState(0)
const [loginInfo, setLoginInfo] = useState<CustomerLoginInfo | null>(null)
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)
const selectedTask = useMemo(
() => tasks.find((task) => task.id === selectedTaskId) || tasks[0] || null,
@@ -131,27 +106,11 @@ export default function CustomerFulfillmentPage() {
if (!selectedTask) return
if (!['waiting_login', 'failed', 'expired'].includes(selectedTask.loginStatus)) return
setLoginInfo(null)
setBindingInfo(null)
setSmsCode('')
setSmsMfaRequired(false)
setSmsCountdown(0)
}, [selectedTask?.id, selectedTask?.loginStatus])
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)
@@ -199,86 +158,6 @@ export default function CustomerFulfillmentPage() {
return () => window.clearInterval(timer)
}, [orderNo, selectedTask?.id, selectedTask?.loginStatus, selectedTask?.platform])
useEffect(() => {
if (!selectedTask || !orderNo || selectedTask.platform === 'weibo') return
if (
selectedTask.loginStatus !== 'authenticated' ||
selectedTask.bindingStatus === 'confirmed'
) {
return
}
if (selectedTask.bindingStatus === 'not_started' && !startingRef.current) {
startingRef.current = true
void startCustomerBinding(orderNo, selectedTask.id)
.then((response) => {
setTasks((current) =>
current.map((task) => (task.id === response.data.task.id ? response.data.task : task)),
)
setBindingInfo(response.data.binding)
})
.catch((error) => {
message.error(error instanceof Error ? error.message : '创建绑定二维码失败')
})
.finally(() => {
startingRef.current = false
})
return
}
if (!['qr_pending', 'scanned'].includes(selectedTask.bindingStatus)) return
const tick = async () => {
try {
const response = await pollCustomerBinding(orderNo, selectedTask.id)
if (response.data.task) {
setTasks((current) =>
current.map((task) => (task.id === response.data.task.id ? response.data.task : task)),
)
}
if (response.data.binding) setBindingInfo(response.data.binding)
} catch {
// 同上,下一轮继续。
}
}
const timer = window.setInterval(() => {
void tick()
}, POLL_INTERVAL_MS)
return () => window.clearInterval(timer)
}, [message, orderNo, selectedTask?.bindingStatus, selectedTask?.id, selectedTask?.loginStatus])
useEffect(() => {
if (
!selectedTask ||
!orderNo ||
selectedTask.platform === 'weibo' ||
bindingInfo ||
hydratingBindingRef.current
)
return
if (
selectedTask.loginStatus !== 'authenticated' ||
selectedTask.bindingStatus !== 'role_ready'
) {
return
}
hydratingBindingRef.current = true
void pollCustomerBinding(orderNo, selectedTask.id)
.then((response) => {
setTasks((current) =>
current.map((task) => (task.id === response.data.task.id ? response.data.task : task)),
)
if (response.data.binding) setBindingInfo(response.data.binding)
})
.catch(() => undefined)
.finally(() => {
hydratingBindingRef.current = false
})
}, [
bindingInfo,
orderNo,
selectedTask?.bindingStatus,
selectedTask?.id,
selectedTask?.loginStatus,
])
async function handleStartLogin() {
if (!selectedTask) return
setLoading(true)
@@ -291,7 +170,6 @@ export default function CustomerFulfillmentPage() {
current.map((task) => (task.id === response.data.task.id ? response.data.task : task)),
)
setLoginInfo(response.data.login)
setBindingInfo(null)
} catch (error) {
message.error(error instanceof Error ? error.message : '创建登录二维码失败')
} finally {
@@ -302,13 +180,13 @@ export default function CustomerFulfillmentPage() {
async function handleSendWeiboSms() {
if (!selectedTask) return
const phone = mobile.trim()
if (!isValidWeiboMobile(countryCode, phone)) {
if (!isValidWeiboMobile(phone)) {
message.warning('请输入正确的手机号')
return
}
setSmsSending(true)
try {
let response = await sendCustomerWeiboSmsCode(orderNo, selectedTask.id, phone, countryCode)
let response = await sendCustomerWeiboSmsCode(orderNo, selectedTask.id, phone, '86')
if (response.data.mfaRequired) {
setSmsMfaRequired(true)
if (!response.data.mfaId || !response.data.captchaId) {
@@ -319,7 +197,7 @@ export default function CustomerFulfillmentPage() {
orderNo,
selectedTask.id,
phone,
countryCode,
'86',
response.data.mfaId,
)
if (response.data.mfaRequired) {
@@ -339,7 +217,7 @@ export default function CustomerFulfillmentPage() {
async function handleLoginWeiboSms() {
if (!selectedTask) return
const phone = mobile.trim()
if (!isValidWeiboMobile(countryCode, phone)) {
if (!isValidWeiboMobile(phone)) {
message.warning('请输入正确的手机号')
return
}
@@ -352,13 +230,12 @@ export default function CustomerFulfillmentPage() {
const response = await loginCustomerWeiboSms(orderNo, selectedTask.id, {
mobile: phone,
code: smsCode.trim(),
countryCode,
countryCode: '86',
})
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 || ''
@@ -373,57 +250,11 @@ export default function CustomerFulfillmentPage() {
}
}
async function handleConfirmBinding() {
if (!selectedTask) return
setBindingActionLoading(true)
try {
const response = await confirmCustomerBinding(orderNo, selectedTask.id)
setTasks((current) =>
current.map((task) => (task.id === response.data.task.id ? response.data.task : task)),
)
setBindingInfo(response.data.binding)
message.success('绑定已确认,等待客服处理')
} catch (error) {
message.error(error instanceof Error ? error.message : '确认绑定失败')
} finally {
setBindingActionLoading(false)
}
}
async function handleStartRebind() {
if (!selectedTask) return
setBindingActionLoading(true)
try {
const response = await startCustomerBinding(orderNo, selectedTask.id, {
forceRebind: true,
})
setTasks((current) =>
current.map((task) => (task.id === response.data.task.id ? response.data.task : task)),
)
setBindingInfo(response.data.binding)
} catch (error) {
message.error(error instanceof Error ? error.message : '创建换绑二维码失败')
} finally {
setBindingActionLoading(false)
}
}
const config = selectedTask?.semiAutoConfig || {}
const title = config.pageTitle || (isWeibo ? '微博账号授权' : '账号授权与角色绑定')
const displayTitle = isWeibo ? '微博账号登录授权' : title
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 || '')
const bindingRole = bindingInfo?.bindGameRole || selectedTask?.bindGameRole || ''
const bindingAccount = bindingInfo?.bindGameAccount || selectedTask?.bindGameAccount || ''
const bindingChannel = bindingInfo?.bindGameChannel || selectedTask?.bindGameChannel || ''
const roleChoiceRequired =
selectedTask?.bindingStatus === 'role_ready' &&
bindingInfo?.hasExistingBinding === true &&
bindingInfo?.rebindRequested !== true
if (loading) {
return (
@@ -452,7 +283,7 @@ export default function CustomerFulfillmentPage() {
<CustomerAuthFrame
platform={selectedTask.platform}
eyebrow={isWeibo ? '微博账号授权' : '虎牙账号授权'}
title={title}
title={displayTitle}
>
<CustomerTaskFacts task={selectedTask} />
<div className="customer-auth-state is-success">
@@ -481,7 +312,7 @@ export default function CustomerFulfillmentPage() {
<CustomerAuthFrame
platform={selectedTask?.platform}
eyebrow={isWeibo ? '微博账号授权' : '虎牙账号授权'}
title={title}
title={displayTitle}
>
<div className="customer-auth-content-stack">
{tasks.length > 1 ? (
@@ -495,7 +326,6 @@ export default function CustomerFulfillmentPage() {
onClick={() => {
setSelectedTaskId(task.id)
setLoginInfo(null)
setBindingInfo(null)
}}
>
{task.productName || task.skuName} × {task.quantity}
@@ -511,20 +341,6 @@ export default function CustomerFulfillmentPage() {
selectedTask?.loginStatus === 'failed' ||
selectedTask?.loginStatus === 'expired' ? (
<section className="customer-auth-section">
<div className="customer-auth-section-heading">
<span className="customer-auth-section-icon">
<QrcodeOutlined />
</span>
<div>
<h2>{isWeibo ? '登录微博账号' : '登录平台账号'}</h2>
<p>
{config.loginInstructions ||
(isWeibo
? '使用微博绑定手机号接收验证码,或使用微博 App 扫码登录。'
: '使用虎牙 App 扫码登录,请确认扫码账号属于本人。')}
</p>
</div>
</div>
{isWeibo ? (
<Tabs
className="customer-auth-tabs"
@@ -536,46 +352,23 @@ export default function CustomerFulfillmentPage() {
label: '手机号登录',
children: (
<div className="customer-auth-form">
<div className="customer-auth-inline-input">
<Select
value={countryCode}
onChange={(value) => setCountryCode(String(value))}
className="customer-auth-country-code"
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>
<span className="customer-auth-option-hint">
+{option.data.value}
</span>
</Space>
)}
filterOption={(input, option) =>
String(
(option as { name?: string } | undefined)?.name || '',
).includes(input) || String(option?.value || '').includes(input)
}
showSearch
popupMatchSelectWidth={240}
/>
<div className="customer-auth-field customer-auth-phone-field">
<span className="customer-auth-country-prefix">+86</span>
<Input
value={mobile}
onChange={(event) => setMobile(event.target.value)}
placeholder="请输入微博绑定手机号"
maxLength={countryCode === '86' ? 11 : 15}
maxLength={11}
inputMode="numeric"
/>
<Button
className="customer-auth-send-code"
type="primary"
loading={smsSending}
disabled={smsCountdown > 0}
onClick={() => void handleSendWeiboSms()}
>
{smsCountdown > 0 ? `${smsCountdown}s 后重发` : '发送验证码'}
{smsCountdown > 0 ? `${smsCountdown}s` : '发送验证码'}
</Button>
</div>
{smsMfaRequired ? (
@@ -583,16 +376,18 @@ export default function CustomerFulfillmentPage() {
请在安全验证弹窗中完成验证,通过后会继续发送短信验证码。
</div>
) : null}
<div className="customer-auth-inline-input is-code">
<div className="customer-auth-field customer-auth-code-field">
<Input
value={smsCode}
onChange={(event) => setSmsCode(event.target.value)}
placeholder="请输入短信验证码"
maxLength={8}
prefix={<SafetyCertificateOutlined />}
onPressEnter={() => void handleLoginWeiboSms()}
/>
<Button
type="primary"
className="customer-auth-code-submit"
loading={smsLogging}
onClick={() => void handleLoginWeiboSms()}
>
@@ -628,6 +423,9 @@ export default function CustomerFulfillmentPage() {
/>
) : (
<div className="customer-auth-form">
<p className="customer-auth-form-copy">
{config.loginInstructions || '使用虎牙 App 扫码登录,请确认扫码账号属于本人。'}
</p>
<Button type="primary" size="large" block onClick={() => void handleStartLogin()}>
获取登录二维码
</Button>
@@ -679,90 +477,14 @@ export default function CustomerFulfillmentPage() {
</section>
) : null}
{bindingQrVisible ? (
<section className="customer-auth-section">
<div className="customer-auth-section-heading">
<span className="customer-auth-section-icon">
<SafetyCertificateOutlined />
</span>
<div>
<h2>
{selectedTask.bindingStatus === 'role_ready' ? '确认绑定角色' : '扫码绑定角色'}
</h2>
<p>
{selectedTask.bindingStatus === 'role_ready'
? '当前账号已有绑定角色,请确认是否继续使用;如需更换,请重新扫码换绑。'
: '请使用虎牙 App 扫码完成游戏角色绑定。'}
</p>
</div>
</div>
<div className="customer-auth-qr">
{bindingQrPending && bindingInfo?.qrImage ? (
<div className="customer-auth-qr-image">
<Image src={bindingInfo.qrImage} alt="游戏角色绑定二维码" width={232} />
</div>
) : bindingQrPending ? (
<div className="customer-auth-qr-placeholder">
<Spin />
<span>正在生成绑定二维码...</span>
</div>
) : null}
<p>
{bindingInfo?.message ||
config.bindingInstructions ||
'扫码完成后,请回到本页面确认角色。'}
</p>
{bindingRole ? (
<div className="customer-auth-role-card">
<span>{roleChoiceRequired ? '当前已绑定角色' : '已识别角色'}</span>
<strong>{bindingRole}</strong>
<small>{[bindingAccount, bindingChannel].filter(Boolean).join(' · ')}</small>
</div>
) : null}
{selectedTask.bindingStatus === 'role_ready' ? (
<div className="customer-auth-button-stack">
<Button
type="primary"
size="large"
block
loading={bindingActionLoading}
onClick={() => void handleConfirmBinding()}
>
{roleChoiceRequired
? '使用当前角色'
: bindingInfo?.rebindRequested
? '确认换绑角色'
: '确认绑定角色'}
</Button>
{roleChoiceRequired ? (
<Button
className="customer-auth-secondary-button"
size="large"
block
loading={bindingActionLoading}
onClick={() => void handleStartRebind()}
>
换绑其他角色
</Button>
) : null}
</div>
) : null}
</div>
</section>
) : null}
{selectedTask?.bindingStatus === 'confirmed' &&
{selectedTask?.loginStatus === 'authenticated' &&
selectedTask.executionStatus !== 'completed' ? (
<div className="customer-auth-state is-waiting">
<div className="customer-auth-state-icon">
<LoadingOutlined spin />
</div>
<h2>{isWeibo ? '微博账号已授权' : '账号角色已绑定'}</h2>
<p>
{isWeibo
? '客服正在使用本次授权完成微博发帖,页面会自动更新。'
: '客服正在处理本订单,页面会自动更新。'}
</p>
<h2>客服正在安排</h2>
<p>请耐心等待</p>
<Button
className="customer-auth-secondary-button"
icon={<ReloadOutlined />}
@@ -792,16 +514,19 @@ function CustomerAuthFrame(input: {
<div className="customer-auth-shell">
<header className="customer-auth-hero">
<div className="customer-auth-brand">
<span className="customer-auth-logo">
<QrcodeOutlined />
</span>
<div>
<span className="customer-auth-eyebrow">{input.eyebrow}</span>
{input.platform !== 'weibo' ? (
<span className="customer-auth-eyebrow">{input.eyebrow}</span>
) : null}
<h1>{input.title}</h1>
</div>
</div>
</header>
<div className="customer-auth-warning">温馨提示:请使用本人账号完成授权</div>
<div className="customer-auth-warning">
{input.platform === 'weibo'
? '温馨提示:未成年人禁止下单'
: '温馨提示:请使用本人账号完成授权'}
</div>
<main className="customer-auth-main">{input.children}</main>
</div>
</div>
@@ -814,9 +539,10 @@ function CustomerTaskFacts(input: { task: CustomerSemiAutoTask | null }) {
const statusTone =
task.displayStatus.key === 'credential_expired' || task.displayStatus.key === 'failed'
? 'is-danger'
: task.bindingStatus === 'confirmed'
: task.loginStatus === 'authenticated'
? 'is-success'
: 'is-pending'
const showAccountDetails = ['authenticated', 'scanned'].includes(task.loginStatus)
return (
<div className="customer-auth-facts">
<div>
@@ -833,30 +559,18 @@ function CustomerTaskFacts(input: { task: CustomerSemiAutoTask | null }) {
<span>平台</span>
<strong>{task.platformLabel || task.platform}</strong>
</div>
{task.huyaUid ? (
{showAccountDetails && task.huyaUid ? (
<div>
<span>平台账号 ID</span>
<strong>{task.huyaUid}</strong>
</div>
) : null}
{task.platform === 'weibo' && task.platformNickname ? (
{showAccountDetails && task.platform === 'weibo' && task.platformNickname ? (
<div>
<span>账号昵称</span>
<strong>{task.platformNickname}</strong>
</div>
) : null}
{task.platform === 'huya' && task.bindGameRole ? (
<div>
<span>绑定角色</span>
<strong>{task.bindGameRole}</strong>
</div>
) : null}
{task.platform === 'huya' && task.bindGameChannel ? (
<div>
<span>区服 / 渠道</span>
<strong>{task.bindGameChannel}</strong>
</div>
) : null}
<div>
<span>状态</span>
<strong className={`customer-auth-status-pill ${statusTone}`}>
@@ -875,9 +589,8 @@ function CustomerPrivacyNote() {
)
}
function isValidWeiboMobile(countryCode: string, value: string) {
function isValidWeiboMobile(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
return /^1\d{10}$/.test(digits)
}
@@ -1,9 +1,5 @@
import { apiGet, apiPost } from '@/lib/http'
import type {
CustomerBindingInfo,
CustomerLoginInfo,
CustomerSemiAutoTask,
} from '@/types/customer-fulfillment'
import type { CustomerLoginInfo, CustomerSemiAutoTask } from '@/types/customer-fulfillment'
export function fetchCustomerFulfillment(orderNo: string) {
return apiGet<CustomerSemiAutoTask[]>(
@@ -31,30 +27,6 @@ export function pollCustomerLogin(orderNo: string, taskId: number) {
)
}
export function startCustomerBinding(
orderNo: string,
taskId: number,
options: { forceRebind?: boolean } = {},
) {
return apiPost<{ task: CustomerSemiAutoTask; binding: CustomerBindingInfo }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/binding/start`,
options.forceRebind ? { forceRebind: true } : {},
)
}
export function pollCustomerBinding(orderNo: string, taskId: number) {
return apiGet<{ task: CustomerSemiAutoTask; binding: CustomerBindingInfo | null }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/binding/status`,
)
}
export function confirmCustomerBinding(orderNo: string, taskId: number) {
return apiPost<{ task: CustomerSemiAutoTask; binding: CustomerBindingInfo }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/binding/confirm`,
{},
)
}
export type CustomerWeiboCountryCode = {
code: string
name: string
+228 -89
View File
@@ -1453,11 +1453,6 @@
background: #202124;
}
.customer-auth-page.is-weibo {
--auth-accent: #ff5c9a;
--auth-accent-hover: #ff78aa;
}
.customer-auth-shell {
width: min(760px, 100%);
min-width: 0;
@@ -1658,6 +1653,21 @@
line-height: 1.75;
}
.customer-auth-section-copy h2 {
margin: 0 0 8px;
color: #fff;
font-size: 23px;
line-height: 1.35;
font-weight: 700;
}
.customer-auth-section-copy p {
margin: 0;
color: var(--auth-muted);
font-size: 15px;
line-height: 1.75;
}
.customer-auth-tabs {
margin-top: 22px;
}
@@ -1667,24 +1677,32 @@
}
.customer-auth-tabs .ant-tabs-nav::before {
border-bottom-color: var(--auth-line);
display: none;
}
.customer-auth-tabs .ant-tabs-nav-list {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 5px;
padding: 5px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 11px;
background: rgba(4, 15, 37, 0.72);
}
.customer-auth-tabs .ant-tabs-tab {
padding: 12px 30px;
justify-content: center;
margin: 0 !important;
padding: 11px 18px;
color: var(--auth-muted);
background: rgba(255, 255, 255, 0.025);
border: 1px solid var(--auth-line) !important;
border-radius: 4px 4px 0 0;
}
.customer-auth-tabs .ant-tabs-tab + .ant-tabs-tab {
margin-left: 4px;
background: transparent;
border: 0 !important;
border-radius: 7px;
}
.customer-auth-tabs .ant-tabs-tab.ant-tabs-tab-active {
border-color: rgba(255, 255, 255, 0.18) !important;
background: rgba(255, 255, 255, 0.09);
background: var(--auth-accent);
}
.customer-auth-tabs .ant-tabs-tab-btn {
@@ -1692,8 +1710,13 @@
font-size: 16px;
}
.customer-auth-tabs .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
color: #111827 !important;
font-weight: 700;
}
.customer-auth-tabs .ant-tabs-ink-bar {
background: var(--auth-accent);
display: none;
}
.customer-auth-form {
@@ -1703,73 +1726,129 @@
padding-top: 18px;
}
.customer-auth-inline-input {
.customer-auth-field {
display: grid;
grid-template-columns: 104px minmax(0, 1fr) 126px;
align-items: center;
min-height: 50px;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 5px;
background: rgba(6, 18, 43, 0.7);
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 10px;
background: rgba(13, 29, 61, 0.72);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.customer-auth-inline-input.is-code {
grid-template-columns: minmax(0, 1fr) 126px;
.customer-auth-field:focus-within {
border-color: var(--auth-accent);
box-shadow: 0 0 0 3px rgba(255, 138, 0, 0.12);
}
.customer-auth-inline-input > * {
.customer-auth-field > * {
min-width: 0;
}
.customer-auth-inline-input .ant-select-selector,
.customer-auth-inline-input .ant-input,
.customer-auth-inline-input .ant-input-affix-wrapper {
.customer-auth-phone-field {
grid-template-columns: 58px minmax(0, 1fr) 112px;
padding: 4px;
}
.customer-auth-code-field {
grid-template-columns: minmax(0, 1fr) 122px;
gap: 5px;
padding: 5px;
}
.customer-auth-field .ant-select-selector,
.customer-auth-field .ant-input,
.customer-auth-field .ant-input-affix-wrapper {
color: #f8fafc !important;
border: 0 !important;
border-radius: 0 !important;
background: rgba(6, 18, 43, 0.7) !important;
background: transparent !important;
box-shadow: none !important;
}
.customer-auth-inline-input .ant-select-selector {
.customer-auth-field .ant-select-selector {
height: 48px !important;
padding-inline: 14px !important;
align-items: center;
}
.customer-auth-inline-input .ant-select-selection-item {
color: #f8fafc !important;
.customer-auth-field .ant-select {
height: 48px;
background: transparent !important;
}
.customer-auth-inline-input .ant-input {
.customer-auth-field .ant-select-selection-item {
color: #f8fafc !important;
line-height: 46px !important;
}
.customer-auth-field .ant-select-arrow {
color: #c4cede !important;
}
.customer-auth-country-prefix {
display: grid;
place-items: center;
align-self: stretch;
color: #f8fafc;
font-size: 14px;
font-weight: 600;
border-right: 1px solid var(--auth-line);
}
.customer-auth-field .ant-input-affix-wrapper {
height: 48px;
padding-inline: 16px;
padding-inline: 15px;
}
.customer-auth-field .ant-input {
height: 48px;
padding-inline: 10px;
font-size: 15px;
}
.customer-auth-inline-input .ant-input::placeholder {
.customer-auth-field .ant-input-affix-wrapper .ant-input {
padding-inline: 0;
}
.customer-auth-field .ant-input::placeholder {
color: #7f8ca5;
}
.customer-auth-inline-input .ant-select,
.customer-auth-inline-input .ant-input {
border-right: 1px solid var(--auth-line) !important;
.customer-auth-field .ant-input-prefix {
margin-inline-end: 9px;
color: var(--auth-accent);
}
.customer-auth-inline-input .ant-btn {
height: 48px;
color: #fff;
border: 0;
border-radius: 0;
background: rgba(48, 75, 124, 0.55);
}
.customer-auth-inline-input .ant-btn-primary {
.customer-auth-send-code {
height: 40px !important;
padding-inline: 10px !important;
color: #111827;
background: var(--auth-accent);
border: 0 !important;
border-radius: 7px !important;
background: var(--auth-accent) !important;
font-size: 14px !important;
font-weight: 700;
}
.customer-auth-option-hint {
color: var(--auth-muted);
.customer-auth-send-code:hover {
background: var(--auth-accent-hover) !important;
}
.customer-auth-code-submit {
height: 40px !important;
color: #111827;
border: 0 !important;
border-radius: 7px !important;
background: var(--auth-accent) !important;
font-weight: 700;
}
.customer-auth-code-submit:hover {
background: var(--auth-accent-hover) !important;
}
.customer-auth-note {
@@ -1983,34 +2062,31 @@
@media (max-width: 640px) {
.customer-auth-page {
height: 100dvh;
min-height: 0;
overflow: hidden;
padding: 0;
background: var(--auth-panel);
}
.customer-auth-shell {
width: 100%;
min-height: 100vh;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
border: 0;
border-radius: 0;
box-shadow: none;
}
.customer-auth-hero {
min-height: 150px;
padding: 26px 22px;
min-height: 116px;
flex: none;
padding: 20px 22px;
background-position: 58% center;
}
.customer-auth-logo {
width: 54px;
height: 54px;
font-size: 29px;
}
.customer-auth-brand {
gap: 14px;
}
.customer-auth-eyebrow {
font-size: 13px;
}
@@ -2020,8 +2096,20 @@
}
.customer-auth-warning {
padding: 16px 14px;
font-size: 17px;
flex: none;
padding: 12px 14px;
font-size: 16px;
}
.customer-auth-main {
flex: 1;
min-height: 0;
overflow: hidden;
}
.customer-auth-content-stack {
height: 100%;
min-height: 0;
}
.customer-auth-facts {
@@ -2031,55 +2119,104 @@
.customer-auth-facts > div {
grid-template-columns: 92px minmax(0, 1fr);
gap: 12px;
min-height: 68px;
padding-block: 14px;
min-height: 52px;
padding-block: 8px;
}
.customer-auth-facts > div > span {
font-size: 14px;
font-size: 13px;
}
.customer-auth-facts > div > strong {
font-size: 16px;
font-size: 15px;
line-height: 1.35;
}
.customer-auth-section {
padding: 24px 18px 27px;
padding: 14px 16px 10px;
}
.customer-auth-section-heading h2,
.customer-auth-section-copy h2,
.customer-auth-state h2 {
font-size: 20px;
}
.customer-auth-section-heading p,
.customer-auth-section-copy p,
.customer-auth-state p,
.customer-auth-form-copy {
font-size: 14px;
font-size: 13px;
line-height: 1.5;
}
.customer-auth-section-icon {
width: 38px;
height: 38px;
font-size: 20px;
.customer-auth-tabs {
margin-top: 0;
}
.customer-auth-inline-input,
.customer-auth-inline-input.is-code {
grid-template-columns: 94px minmax(0, 1fr);
.customer-auth-tabs .ant-tabs-nav {
margin-bottom: 10px;
}
.customer-auth-inline-input > .ant-btn {
grid-column: 1 / -1;
border-top: 1px solid var(--auth-line);
.customer-auth-tabs .ant-tabs-nav-list {
gap: 4px;
padding: 4px;
}
.customer-auth-inline-input.is-code {
grid-template-columns: minmax(0, 1fr);
.customer-auth-tabs .ant-tabs-tab {
padding: 9px 14px;
}
.customer-auth-inline-input.is-code > .ant-btn {
grid-column: auto;
.customer-auth-tabs .ant-tabs-tab-btn {
font-size: 15px;
}
.customer-auth-form {
gap: 10px;
padding-top: 0;
}
.customer-auth-field {
min-height: 44px;
border-radius: 9px;
}
.customer-auth-phone-field {
grid-template-columns: 50px minmax(0, 1fr) 104px;
}
.customer-auth-code-field {
grid-template-columns: minmax(0, 1fr) 104px;
}
.customer-auth-field .ant-select,
.customer-auth-field .ant-select-selector,
.customer-auth-field .ant-input,
.customer-auth-field .ant-input-affix-wrapper {
height: 44px;
}
.customer-auth-field .ant-select-selection-item {
line-height: 42px !important;
}
.customer-auth-field .ant-input-affix-wrapper {
padding-inline: 12px;
}
.customer-auth-code-field {
padding: 4px;
}
.customer-auth-code-submit {
height: 36px !important;
}
.customer-auth-send-code {
height: 36px !important;
}
.customer-auth-form-hint {
font-size: 10px;
line-height: 1.35;
}
.customer-auth-qr-image,
@@ -2089,7 +2226,9 @@
}
.customer-auth-privacy {
margin: 20px 18px 24px;
font-size: 13px;
margin: 8px 16px 12px;
padding: 8px 12px;
font-size: 12px;
line-height: 1.45;
}
}
@@ -436,7 +436,6 @@ export interface ManualFulfillmentConfig {
pageTitle?: string
pageDescription?: string
loginInstructions?: string
bindingInstructions?: string
browserTargets?: Array<{ key: string; label: string; url: string }>
}
note?: string
@@ -13,7 +13,6 @@ export type SemiAutoTask = {
platform: SemiAutoPlatform
platformLabel: string
loginStatus: string
bindingStatus: string
executionStatus: string
connectorMode: 'embedded' | 'external' | 'mock'
displayStatus: SemiAutoDisplayStatus
@@ -24,9 +23,6 @@ export type SemiAutoTask = {
credentialUpdatedAt: string | null
huyaUid: string
platformNickname: string
bindGameAccount: string
bindGameRole: string
bindGameChannel: string
targetQuantity: number
completedQuantity: number
assignedAdminUserId: number | null
@@ -54,7 +50,6 @@ export type SemiAutoTask = {
pageTitle?: string
pageDescription?: string
loginInstructions?: string
bindingInstructions?: string
browserTargets?: Array<{ key: string; label: string; url: string }>
}
}
@@ -8,21 +8,16 @@ export type CustomerSemiAutoTask = {
skuCode: string
quantity: number
loginStatus: string
bindingStatus: string
executionStatus: string
displayStatus: { key: string; label: string }
huyaUid: string
platformNickname: string
bindGameAccount: string
bindGameRole: string
bindGameChannel: string
completedAt: string | null
completionNote: string
semiAutoConfig: {
pageTitle?: string
pageDescription?: string
loginInstructions?: string
bindingInstructions?: string
}
}
@@ -33,15 +28,3 @@ export type CustomerLoginInfo = {
expiresAt: string
message: string
}
export type CustomerBindingInfo = {
status: string
qrImage: string
qrExpiresAt: string
bindGameAccount: string
bindGameRole: string
bindGameChannel: string
hasExistingBinding?: boolean
rebindRequested?: boolean
message: string
}
+3 -2
View File
@@ -30,9 +30,10 @@ updated_at: 2026-11-08
- 半自动迁移已收敛为单个 `161_semi_auto_fulfillment.sql`,不得在已应用 main `158–160`
的库上重新引入 `153–156` 或 `162–165`。
- `161` 一次新增账号级 Cookie JSON 凭证表、`semi_auto_tasks.credential_id`、
最终 Connector 来源约束和 `semi_auto.credential.export` 权限;
最终 Connector 来源约束和兼容保留的 `semi_auto.credential.export` 权限记录;
该权限不再参与 CK 导出判定,导出由后台登录、禁止缓存和审计控制;
不迁移或清理订单、退款、资金流水等 A 级事实。
- 迁移完成后至少验证 `semi_auto_credentials` 表、任务外键、索引和导出权限存在;
- 迁移完成后至少验证 `semi_auto_credentials` 表、任务外键和索引存在;
用测试库完成 `001 → 161` 空库迁移,并在隔离库验证 `151 → 161`。
- 该增量仍包含迁移,普通停机发布不能使用 `deploy/ubuntu-deploy.sh` 在旧后端运行时直接换库;
必须沿用本手册的停写、最终备份、隔离恢复验证、迁移、新后端健康检查和验收流程。
@@ -43,14 +43,14 @@ updated_at: 2026-11-13
每一层只能回答一个问题:
| 层 | 唯一问题 | 唯一真源 |
| --- | --- | --- |
| 订单接入 | 平台实际发生了什么 | `orders` / `order_items` / 原始报文 |
| 商品身份 | 业务上这是什么商品 | `internal_skus` |
| 履约路由 | 这个商品默认由谁履约 | `sku_channel_routes` |
| 履约执行 | 当前实际执行到哪一步 | `fulfillments` / `fulfillment_attempts` |
| 财务商品 | 收入与成本归到哪个标准商品 | `product_catalog` / `channel_product_mappings` |
| 财务核算 | 应该记多少钱 | `cost_snapshots` / `sales_daily_facts` / `journal_*` |
| 层 | 唯一问题 | 唯一真源 |
| -------- | -------------------------- | ---------------------------------------------------- |
| 订单接入 | 平台实际发生了什么 | `orders` / `order_items` / 原始报文 |
| 商品身份 | 业务上这是什么商品 | `internal_skus` |
| 履约路由 | 这个商品默认由谁履约 | `sku_channel_routes` |
| 履约执行 | 当前实际执行到哪一步 | `fulfillments` / `fulfillment_attempts` |
| 财务商品 | 收入与成本归到哪个标准商品 | `product_catalog` / `channel_product_mappings` |
| 财务核算 | 应该记多少钱 | `cost_snapshots` / `sales_daily_facts` / `journal_*` |
任何页面、接口、任务和报表都必须从各自真源读取,不得根据另一层的字段反推。
@@ -276,7 +276,8 @@ fulfillment_issues
```text
manual fulfillment
→ semi_auto_tasks
→ 平台登录 / 角色绑定 / 浏览器辅助操作
→ 平台登录 / 获取账号 CK
→ 客服使用 CK 在平台侧手动处理
→ confirmManualFulfillment
```
@@ -285,32 +286,37 @@ manual fulfillment
- `fulfillments.channel` 仍为 `manual`;成本、销售与总账口径不变。
- 是否启用半自动履约由 `manual_fulfillment_configs.semi_auto_*` 显式配置,不能按商品名或平台前缀猜测。
- 半自动任务必须绑定 `order_item_id`、`fulfillments.id` 和人工履约配置,不得伪造接单平台工单。
- 登录状态、绑定状态、执行状态必须正交保存于 `semi_auto_tasks`;不得写入 `material_json` 作为正式状态。
- 当前阶段半自动平台接入的唯一职责是让客户完成平台登录并取得账号 CK。角色绑定、换绑、
角色校验和绑定失败处置属于后续业务流程,不进入当前客户页、主状态机或完成门禁。
- 登录状态和执行状态保存于 `semi_auto_tasks`,不得写入 `material_json` 作为正式状态。
`binding_status`、`bind_game_*` 和 `bound_at` 仅作历史兼容字段保留,当前登录、客服处理和完成主流程
不读取、不写入、不作为任何门禁。
- 平台 CK 是账号级凭证,唯一持久化真源为 `semi_auto_credentials`。Cookie 集合按平台 UID 唯一保存,
以 Cookie JSON 写入 `credential_payload_json`,并记录来源、更新时间、健康状态和最近检查错误。
以未加密 Cookie JSON 写入 `credential_payload_json`,并记录来源、更新时间、健康状态和最近检查错误。
`semi_auto_tasks.credential_id` 只引用账号凭证,任务表不保存 CK 明文。
- Connector 会话仍是进程内短期缓存,但不再是唯一真源。进程重启、会话过期或实例切换后,
执行器必须从账号级密文凭证恢复会话;恢复前校验平台 UID,恢复失败时更新凭证健康状态并要求重新登录。
支持恢复的执行器必须从账号级 Cookie JSON 恢复会话;恢复前校验平台 UID,
恢复失败时更新凭证健康状态并要求重新登录。
- 平台明确返回 CK 失效或账号不一致时,当前任务必须退出“已授权”状态,清空当前 Connector 会话并回到
`waiting_customer / waiting_login / not_started`;账号级凭证和历史事件保留用于审计。客服可对该任务
执行“重新发送授权”,重置当前任务并复制客户 `/c/<平台订单号>` 链接,客户重新登录后再绑定新凭证。
执行“重新发送授权”,重置当前任务并复制客户 `/c/<平台订单号>` 链接,客户重新登录后更新 CK。
- CK 不得进入普通任务 DTO、客户页面、任务事件、错误日志或审计明细。人工后台默认只展示平台 UID、
凭证来源、健康状态和更新时间;仅持有 `semi_auto.credential.export` 权限的角色可受控导出单个任务的
账号 Cookie,导出响应必须禁止缓存,审计只记录任务、凭证 ID 和平台,不记录 Cookie 内容。
凭证来源、健康状态和更新时间;已登录后台的管理员、运营或客服均可导出任务账号 CK,
不受 Connector 模式、任务领取人或额外权限限制。导出响应必须禁止缓存,审计只记录任务、
凭证 ID 和平台,不记录 Cookie 内容。
- 微博登录必须保留完整域名/路径 Cookie Jar,不能在跨域跳转后把所有同名 Cookie 合并成单个字符串。
浏览器注入只下发与目标 URL 匹配的 Cookie;账号级凭证保存完整集合,以便后续会话恢复和跨域重放。
浏览器注入只下发与目标 URL 匹配的 Cookie;账号级凭证保存完整集合,以便导出、会话恢复和跨域重放。
- 微博手机号登录首次发码返回 `mfa_1` 时,客户必须在浏览器完成网易易盾安全验证,再使用同一会话的
`mfa_id` 重发短信;服务端不尝试绕过或伪造人机验证。
- 半自动任务通过 `connector_mode` 显式记录 Connector 来源:
`embedded` 为 order_site 内置真实实现并作为默认值,`external` 为显式配置的远程真实实现,
`mock` 为 order_site 内置开发 Mock 且仅在非生产环境可用。开发 Mock 造单时默认选择 `embedded`,
只有需要完全脱离虎牙接口联调页面时才显式选择 `mock`。
只有需要完全脱离虎牙接口联调页面时才显式选择 `mock`。三种模式都必须能导出统一 Cookie JSON。
- 虎牙二维码登录有效期 `5` 分钟;登录成功后的 Connector 内存会话有效期 `24` 小时,
账号级 CK 由内部凭证表保存并通过健康检查判定有效性;角色绑定二维码 `5` 分钟;
浏览器一次性注入票据 `2` 分钟且消费后立即失效。重新登录前必须撤销旧 Connector 会话。
- 虎牙账号已经绑定游戏角色时,客户必须确认当前角色是否为本次需要的角色:选择“使用当前角色”时
不再调用虎牙换绑接口;选择“角色不对,重新扫码换绑”时重新生成绑定二维码,并在确认新角色时
调用换绑接口。未绑定账号和角色时直接走确认绑定。换绑冷却、未到可换绑时间等上游业务拒绝必须原样提示客户。
账号级 CK 由内部凭证表保存并通过健康检查判定有效性;浏览器一次性注入票据 `2` 分钟且消费后立即失效。
重新登录前必须撤销旧 Connector 会话。
- 虎牙客户登录成功后,无论账号此前是否已有角色,都立即保存 CK 并进入等待客服处理。
角色绑定、换绑和角色状态查询在后续独立流程实现,不能阻塞当前 CK 获取。
- 浏览器扩展复用 live-hub-py 的 `web/extension/huya-exchange-helper`,不新增第二个扩展;
扩展只允许消费一次性注入票据,页面不得直接读取 CK。
- 客服完成半自动任务必须与 `confirmManualFulfillment` 在同一数据库事务中提交:
@@ -361,25 +367,25 @@ finance_journal_* -- 总账凭证,可重建
## 四、单一真源矩阵
| 信息 | 唯一写入方 | 唯一真源 | 明确禁止 |
| --- | --- | --- | --- |
| 平台订单与订单项 | 订单接入服务 | `orders` / `order_items` | 财务或履约服务改写平台事实 |
| 平台原始报文 | 各平台适配器 | `order_source_snapshots` / 首次 `raw_payload_json` | 覆盖历史报文 |
| 中文内部 SKU | 商品身份服务 | `internal_skus` | 各业务表各自发明同义 SKU |
| 平台字段到内部 SKU | 映射服务 | `internal_sku_mappings` | 按名称猜测渠道 |
| 主渠道与兜底 | 路由服务 | `sku_channel_routes` | UI 根据商品前缀筛选 |
| 当前履约状态 | 履约状态服务 | `fulfillments` | 多服务直接更新订单项 |
| 执行尝试 | 履约尝试服务 | `fulfillment_attempts` | 用最大 ID 猜当前任务 |
| 当前异常 | 履约异常服务 | `fulfillment_issues` | 自由 JSON 字符串代替问题模型 |
| 半自动任务 | 半自动任务服务 | `semi_auto_tasks` / `semi_auto_task_events` | 把登录/绑定/执行状态写进 `material_json` |
| 客户平台 CK | 半自动凭证服务 | `semi_auto_credentials.credential_payload_json` | 写日志、普通接口返回或未审计导出 CK |
| 标准商品 | 商品主数据服务 | `product_catalog` | 用商品来源反推渠道 |
| 平台商品到财务商品 | 商品归一服务 | `channel_product_mappings` | 人工在多个页面重复维护 |
| 成本规则 | 成本中心 | `cost_versions` | 业务表直接改金额 |
| 成本事实 | 成本快照服务 | `cost_snapshots` | 改价覆盖历史 |
| 销售事实 | 事实服务 | `sales_daily_facts` | 报表直接从订单表现算另一套 |
| 总账 | 总账服务 | `finance_journal_*` | 业务端直接写凭证 |
| 页面投影 | 只读投影 | `v_*` / materialized view | 页面自行拼口径 |
| 信息 | 唯一写入方 | 唯一真源 | 明确禁止 |
| ------------------ | -------------- | -------------------------------------------------- | ---------------------------------------- |
| 平台订单与订单项 | 订单接入服务 | `orders` / `order_items` | 财务或履约服务改写平台事实 |
| 平台原始报文 | 各平台适配器 | `order_source_snapshots` / 首次 `raw_payload_json` | 覆盖历史报文 |
| 中文内部 SKU | 商品身份服务 | `internal_skus` | 各业务表各自发明同义 SKU |
| 平台字段到内部 SKU | 映射服务 | `internal_sku_mappings` | 按名称猜测渠道 |
| 主渠道与兜底 | 路由服务 | `sku_channel_routes` | UI 根据商品前缀筛选 |
| 当前履约状态 | 履约状态服务 | `fulfillments` | 多服务直接更新订单项 |
| 执行尝试 | 履约尝试服务 | `fulfillment_attempts` | 用最大 ID 猜当前任务 |
| 当前异常 | 履约异常服务 | `fulfillment_issues` | 自由 JSON 字符串代替问题模型 |
| 半自动任务 | 半自动任务服务 | `semi_auto_tasks` / `semi_auto_task_events` | 把登录/绑定/执行状态写进 `material_json` |
| 客户平台 CK | 半自动凭证服务 | `semi_auto_credentials.credential_payload_json` | 写日志、普通接口返回或未审计导出 CK |
| 标准商品 | 商品主数据服务 | `product_catalog` | 用商品来源反推渠道 |
| 平台商品到财务商品 | 商品归一服务 | `channel_product_mappings` | 人工在多个页面重复维护 |
| 成本规则 | 成本中心 | `cost_versions` | 业务表直接改金额 |
| 成本事实 | 成本快照服务 | `cost_snapshots` | 改价覆盖历史 |
| 销售事实 | 事实服务 | `sales_daily_facts` | 报表直接从订单表现算另一套 |
| 总账 | 总账服务 | `finance_journal_*` | 业务端直接写凭证 |
| 页面投影 | 只读投影 | `v_*` / materialized view | 页面自行拼口径 |
## 五、当前系统的问题证据
@@ -540,12 +546,12 @@ has_active_cost
客户页 `/c/<平台订单号>` 统一展示:
```text
平台登录 → 角色绑定 → 客服处理 → 完成
平台登录 → 获取 CK → 客服处理 → 完成
```
页面只能展示客户完成操作所需的最小信息,不得展示 CK、内部成本或其他订单。后台默认只展示平台 UID、
绑定角色、凭证来源、健康状态、更新时间和一次性注入入口;持有独立导出权限的角色可按任务受控查看
账号 CK,导出动作必须留审计且不得复用普通详情接口。
凭证来源、健康状态、更新时间、CK 导出和一次性注入入口。CK 导出不依赖角色绑定、任务领取人或
额外权限;导出动作必须留审计且不得复用普通详情接口。
## 八、存量数据处理方案
@@ -605,14 +611,14 @@ has_active_cost
## 九、目标迁移路线
| 阶段 | 状态 | 是否改生产数据 |
| --- | --- | --- |
| 0 口径冻结与文档收口 | 已完成 | 否 |
| 1 只读审计与领域约束 | 本地完成 | 否 |
| 2 建立目标身份与投影 | 本地完成 | 只新增结构 |
| 3 迁移路由与商品配置 | 本地完成,生产未执行 | 是,需备份 |
| 4 重建履约模型 | 本地完成,生产未执行 | 是,需备份 |
| 5 重建财务派生数据 | 本地完成,生产未执行 | 是,需备份与对账 |
| 阶段 | 状态 | 是否改生产数据 |
| -------------------------- | ------------------------ | ---------------- |
| 0 口径冻结与文档收口 | 已完成 | 否 |
| 1 只读审计与领域约束 | 本地完成 | 否 |
| 2 建立目标身份与投影 | 本地完成 | 只新增结构 |
| 3 迁移路由与商品配置 | 本地完成,生产未执行 | 是,需备份 |
| 4 重建履约模型 | 本地完成,生产未执行 | 是,需备份 |
| 5 重建财务派生数据 | 本地完成,生产未执行 | 是,需备份与对账 |
| 6 停止旧写路径并删除兼容层 | 履约侧已完成,全局未开始 | 是,需观察期结束 |
### 阶段 0:口径冻结与文档收口
@@ -745,7 +751,7 @@ assigned 且 carrier_status=missing 必须进入异常队列
2. 历史任务和工单补齐到 `fulfillment_attempts`,不再从订单项回调状态。
3. `transitionFulfillmentStateWithExecutor`、渠道分配和配置关联全部改写正式表。
4. `v_fulfillment_item_state` 只投影 `fulfillments + fulfillment_attempts +
fulfillment_issues + sku_channel_routes`。
fulfillment_issues + sku_channel_routes`。
5. 全部业务读 SQL 切到 `v_order_item_current`,禁止直接读取订单项旧状态列。
6. 状态事件、任务尝试、工单尝试和配置变化触发器统一围绕正式履约表工作。
7. 真实 PostgreSQL 集成测试覆盖状态写入、幂等、事件留痕、当前尝试和配置关联。
@@ -952,7 +958,8 @@ docs/archive/
截至 2026-11-13,生产基线为 `151`,集成分支开发基线为 `161`。迁移 `152` 保护订单来源快照,
迁移 `158–160` 收口寄卖登记人同步、寄卖平台与自动匹配设置;
迁移 `161` 一次建立人工渠道半自动任务、内置 Connector 默认值、账号级 Cookie JSON 凭证、
任务凭证引用、受控导出权限和最终约束。
任务凭证引用和最终约束。迁移中保留的 `semi_auto.credential.export` 权限仅作兼容记录,
不再作为 CK 导出门禁。
1. 生产升级只按 [生产升级操作手册](生产升级操作手册.md) 执行。
2. 升级前备份必须完成隔离恢复验证;恢复验证未通过时禁止迁移。
@@ -961,7 +968,6 @@ docs/archive/
4. 执行 `finance:rebuild-derived` 的 dry-run,核对 A 级数据守恒后,
再按窗口执行 `--execute --confirm=REBUILD_DERIVED`,并验证第二次重建幂等。
5. 观察一个完整财务期间,确认页面、履约、事实和总账持续对平。
6. 半自动履约继续完善微博手机号登录、易盾二次验证、完整域名 Cookie 保存、会话恢复和受控导出;
所有平台继续复用账号级凭证模型,不把平台专用 CK 状态写回订单或商品字段。
6. 半自动平台当前只负责客户登录和账号 CK 获取;继续完善微博手机号登录、易盾二次验证、
完整域名 Cookie 保存和会话恢复。角色绑定与换绑后续独立设计,不阻塞 CK 获取和客服处理。
7. 观察期结束后,再单独发布兼容表、旧脚本别名和 `legacy_*` 字段的删除迁移。