优化:补充半自动履约开发 Mock 与内置 Connector
This commit is contained in:
@@ -70,7 +70,7 @@ KAQUAN91_USER_ID=
|
|||||||
KAQUAN91_SECRET=
|
KAQUAN91_SECRET=
|
||||||
|
|
||||||
# 半自动履约 Connector(虎牙登录/绑定/CK 注入票据)
|
# 半自动履约 Connector(虎牙登录/绑定/CK 注入票据)
|
||||||
SEMI_AUTO_CONNECTOR_BASE_URL=http://host.docker.internal:8800
|
SEMI_AUTO_CONNECTOR_BASE_URL=http://127.0.0.1:3000/api/v1/dev-mock/connector
|
||||||
SEMI_AUTO_CONNECTOR_API_KEY=local-semi-auto-connector-key
|
SEMI_AUTO_CONNECTOR_API_KEY=local-semi-auto-connector-key
|
||||||
SEMI_AUTO_CONNECTOR_TIMEOUT_MS=15000
|
SEMI_AUTO_CONNECTOR_TIMEOUT_MS=15000
|
||||||
|
|
||||||
|
|||||||
@@ -179,6 +179,34 @@ cp .env.server.example .env
|
|||||||
| `SEMI_AUTO_CONNECTOR_API_KEY` | order_site 与 Connector 的内部鉴权密钥 |
|
| `SEMI_AUTO_CONNECTOR_API_KEY` | order_site 与 Connector 的内部鉴权密钥 |
|
||||||
| `SEMI_AUTO_CONNECTOR_TIMEOUT_MS` | Connector 请求超时,默认 15000 毫秒 |
|
| `SEMI_AUTO_CONNECTOR_TIMEOUT_MS` | Connector 请求超时,默认 15000 毫秒 |
|
||||||
|
|
||||||
|
### 本地半自动履约 Mock
|
||||||
|
|
||||||
|
`docker-compose.dev.yml` 默认把 Connector 指向 order_site 自带的开发 Mock:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:3000/api/v1/dev-mock/connector
|
||||||
|
```
|
||||||
|
|
||||||
|
本地验证流程:
|
||||||
|
|
||||||
|
1. 打开后台 `/admin/dev-mock`,在“半自动履约测试单”中生成订单。
|
||||||
|
2. 打开客户链接,点击获取登录二维码;Mock 会在两次轮询后自动模拟登录成功。
|
||||||
|
3. 页面自动创建绑定二维码,轮询后识别测试角色,点击确认绑定。
|
||||||
|
4. 打开后台“任务 → 半自动履约”,领取任务并生成浏览器注入票据。
|
||||||
|
5. 完成履约,核对任务、履约、成本快照和财务确认。
|
||||||
|
|
||||||
|
需要接真实 Connector 时,显式覆盖 `SEMI_AUTO_CONNECTOR_BASE_URL`,例如
|
||||||
|
`http://host.docker.internal:8800`。开发 Mock 路由在非开发环境返回 404。
|
||||||
|
|
||||||
|
半自动履约真实 PostgreSQL 集成测试需要独立库,避免写入生产 dump:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/backend
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/order_site_it npm run db:migrate
|
||||||
|
SEMI_AUTO_TASK_TEST_DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/order_site_it \
|
||||||
|
npx tsx --test src/services/semi-auto/semi-auto-task.integration.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
## 运行数据
|
## 运行数据
|
||||||
|
|
||||||
| 环境 | 路径 / Volume |
|
| 环境 | 路径 / Volume |
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import orderServiceRouter from './routes/order-service.js'
|
|||||||
import filesRouter from './routes/files.js'
|
import filesRouter from './routes/files.js'
|
||||||
import claimsRouter from './routes/claims.js'
|
import claimsRouter from './routes/claims.js'
|
||||||
import customerFulfillmentRouter from './routes/customer-fulfillment.js'
|
import customerFulfillmentRouter from './routes/customer-fulfillment.js'
|
||||||
|
import devMockConnectorRouter from './routes/dev-mock-connector.js'
|
||||||
import dcAuthRouter from './routes/dcauth.js'
|
import dcAuthRouter from './routes/dcauth.js'
|
||||||
import douyinMashidaRouter from './routes/douyin-mashida.js'
|
import douyinMashidaRouter from './routes/douyin-mashida.js'
|
||||||
import kuaishouIndustryRouter from './routes/kuaishou-industry.js'
|
import kuaishouIndustryRouter from './routes/kuaishou-industry.js'
|
||||||
@@ -54,6 +55,9 @@ export function createApp({ startupState, isShutdownStarted, config }: CreateApp
|
|||||||
)
|
)
|
||||||
app.use(express.urlencoded({ extended: true }))
|
app.use(express.urlencoded({ extended: true }))
|
||||||
|
|
||||||
|
// 本地开发用内置 Connector;生产环境由路由内部直接返回 404。
|
||||||
|
app.use('/api/v1/dev-mock/connector', devMockConnectorRouter)
|
||||||
|
|
||||||
app.get('/health', (_req, res) => {
|
app.get('/health', (_req, res) => {
|
||||||
res.json(
|
res.json(
|
||||||
buildSuccessPayload(
|
buildSuccessPayload(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
buildOpen91QueryMock,
|
buildOpen91QueryMock,
|
||||||
createAffiliateDashMockClaim,
|
createAffiliateDashMockClaim,
|
||||||
createKuaishouIndustryVoucherMockData,
|
createKuaishouIndustryVoucherMockData,
|
||||||
|
createSemiAutoMock,
|
||||||
getDevMockStatus,
|
getDevMockStatus,
|
||||||
isDevMockEnabled,
|
isDevMockEnabled,
|
||||||
} from '../../services/dev-mock/dev-mock-service.js'
|
} from '../../services/dev-mock/dev-mock-service.js'
|
||||||
@@ -75,6 +76,25 @@ router.post(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/dev-mock/semi-auto',
|
||||||
|
createJsonHandler((req) => createSemiAutoMock(req.body || {}), {
|
||||||
|
successMessage: '半自动履约 Mock 已生成',
|
||||||
|
errorMessage: '生成半自动履约 Mock 失败',
|
||||||
|
scope: '[admin/dev-mock/semi-auto]',
|
||||||
|
audit: (_req, data) => ({
|
||||||
|
action: 'dev_mock_create_semi_auto',
|
||||||
|
targetType: 'dev_mock',
|
||||||
|
targetId: String((data as { orderNo?: string })?.orderNo || ''),
|
||||||
|
data: {
|
||||||
|
platform: 'semi_auto',
|
||||||
|
orderNo: (data as { orderNo?: string })?.orderNo,
|
||||||
|
skuKey: (data as { skuKey?: string })?.skuKey,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/dev-mock/open91/query',
|
'/dev-mock/open91/query',
|
||||||
createJsonHandler((req) => buildOpen91QueryMock(req.body || {}), {
|
createJsonHandler((req) => buildOpen91QueryMock(req.body || {}), {
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import type { Request, Response } from 'express'
|
||||||
|
import { Router } from 'express'
|
||||||
|
|
||||||
|
import { runtimeConfig } from '../config/runtime.js'
|
||||||
|
import { isDevMockEnabled } from '../services/dev-mock/dev-mock-service.js'
|
||||||
|
import {
|
||||||
|
confirmMockBinding,
|
||||||
|
consumeMockBrowserTicket,
|
||||||
|
createMockBrowserTicket,
|
||||||
|
createMockLoginSession,
|
||||||
|
getMockBinding,
|
||||||
|
getMockLoginSession,
|
||||||
|
listMockSessions,
|
||||||
|
resetMockSessions,
|
||||||
|
revokeMockSession,
|
||||||
|
setMockBindingStatus,
|
||||||
|
setMockLoginStatus,
|
||||||
|
startMockBinding,
|
||||||
|
} from '../services/dev-mock/mock-semi-auto-connector.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
function connectorApiKey() {
|
||||||
|
return (
|
||||||
|
String(runtimeConfig.semiAuto?.connectorApiKey || '').trim() || 'local-semi-auto-connector-key'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyOf(req: Request): Record<string, unknown> {
|
||||||
|
return req.body && typeof req.body === 'object' && !Array.isArray(req.body)
|
||||||
|
? (req.body as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendDetail(res: Response, statusCode: number, detail: string) {
|
||||||
|
res.status(statusCode).json({ detail })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle(action: (req: Request) => unknown) {
|
||||||
|
return (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
res.json(action(req))
|
||||||
|
} catch (error) {
|
||||||
|
const statusCode =
|
||||||
|
error && typeof error === 'object' && 'statusCode' in error
|
||||||
|
? Number((error as { statusCode?: unknown }).statusCode) || 500
|
||||||
|
: 500
|
||||||
|
const detail =
|
||||||
|
error instanceof Error ? error.message : String(error || 'Mock Connector 请求失败')
|
||||||
|
sendDetail(res, statusCode, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
if (!isDevMockEnabled()) {
|
||||||
|
sendDetail(res, 404, '开发 Mock Connector 未启用')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const key = String(req.header('x-semi-auto-key') || '').trim()
|
||||||
|
if (!key || key !== connectorApiKey()) {
|
||||||
|
sendDetail(res, 401, '半自动履约 Connector 鉴权失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/api/internal/huya-semi-auto/sessions',
|
||||||
|
handle((req) => {
|
||||||
|
const body = bodyOf(req)
|
||||||
|
return createMockLoginSession({ taskId: body.taskId, orderNo: body.orderNo })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/api/internal/huya-semi-auto/sessions/:sessionId',
|
||||||
|
handle((req) => getMockLoginSession(req.params.sessionId)),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/api/internal/huya-semi-auto/sessions/:sessionId',
|
||||||
|
handle((req) => revokeMockSession(req.params.sessionId)),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/api/internal/huya-semi-auto/sessions/:sessionId/binding',
|
||||||
|
handle((req) => startMockBinding(req.params.sessionId)),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/api/internal/huya-semi-auto/sessions/:sessionId/binding',
|
||||||
|
handle((req) => getMockBinding(req.params.sessionId)),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/api/internal/huya-semi-auto/sessions/:sessionId/binding/confirm',
|
||||||
|
handle((req) => confirmMockBinding(req.params.sessionId)),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/api/internal/huya-semi-auto/sessions/:sessionId/browser-ticket',
|
||||||
|
handle((req) => {
|
||||||
|
const body = bodyOf(req)
|
||||||
|
return createMockBrowserTicket({
|
||||||
|
sessionId: req.params.sessionId,
|
||||||
|
targetUrl: body.targetUrl,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/api/internal/huya-semi-auto/browser-tickets/:ticket',
|
||||||
|
handle((req) => consumeMockBrowserTicket(req.params.ticket)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 测试辅助:不经过轮询直接推进状态,方便验证失败、过期和异常分支。
|
||||||
|
router.get(
|
||||||
|
'/__control/sessions',
|
||||||
|
handle(() => listMockSessions()),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/__control/sessions/:sessionId/login',
|
||||||
|
handle((req) => {
|
||||||
|
const body = bodyOf(req)
|
||||||
|
return setMockLoginStatus(req.params.sessionId, body.status)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/__control/sessions/:sessionId/binding',
|
||||||
|
handle((req) => {
|
||||||
|
const body = bodyOf(req)
|
||||||
|
return setMockBindingStatus(req.params.sessionId, body.status)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/__control/reset',
|
||||||
|
handle(() => resetMockSessions()),
|
||||||
|
)
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -8,22 +8,31 @@ import { createOrder, findOrderByPlatformOrderId } from '../../repositories/orde
|
|||||||
import { replaceOrderItems } from '../../repositories/order-item-repo.js'
|
import { replaceOrderItems } from '../../repositories/order-item-repo.js'
|
||||||
import { createTask, updateTask } from '../../repositories/task-repo.js'
|
import { createTask, updateTask } from '../../repositories/task-repo.js'
|
||||||
import { upsertKuaishouIndustryVoucher } from '../../repositories/kuaishou-industry-voucher-repo.js'
|
import { upsertKuaishouIndustryVoucher } from '../../repositories/kuaishou-industry-voucher-repo.js'
|
||||||
|
import { query, withTransaction } from '../../db/client.js'
|
||||||
import {
|
import {
|
||||||
getFulfillmentProfileByKey,
|
getFulfillmentProfileByKey,
|
||||||
upsertFulfillmentProfile,
|
upsertFulfillmentProfile,
|
||||||
} from '../../repositories/fulfillment-profile-repo.js'
|
} from '../../repositories/fulfillment-profile-repo.js'
|
||||||
import { buildOrderClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
|
import { buildOrderClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
|
||||||
import { assertValidClaimUid, normalizeClaimUid } from '../claim/claim-identity.js'
|
import { assertValidClaimUid, normalizeClaimUid } from '../claim/claim-identity.js'
|
||||||
|
import { saveManualFulfillmentConfig } from '../cost-center/manual-fulfillment-config-service.js'
|
||||||
|
import {
|
||||||
|
assignFulfillmentChannelsWithExecutor,
|
||||||
|
attachFulfillmentConfigurationsWithExecutor,
|
||||||
|
} from '../fulfillment/fulfillment-state-service.js'
|
||||||
|
import { containsCjk } from '../fulfillment/internal-sku-resolver.js'
|
||||||
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER, assertOpen91Config } from '../open-91/config.js'
|
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER, assertOpen91Config } from '../open-91/config.js'
|
||||||
import {
|
import {
|
||||||
getKuaishouIndustrySourceConfig,
|
getKuaishouIndustrySourceConfig,
|
||||||
listKuaishouIndustryShopConfigs,
|
listKuaishouIndustryShopConfigs,
|
||||||
} from '../platforms/kuaishou-industry/source-config-service.js'
|
} from '../platforms/kuaishou-industry/source-config-service.js'
|
||||||
import { isProductionLike } from '../../config/runtime-validation.js'
|
import { isProductionLike } from '../../config/runtime-validation.js'
|
||||||
|
import { runtimeConfig } from '../../config/runtime.js'
|
||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
import { randomId } from '../../utils/random.js'
|
import { randomId } from '../../utils/random.js'
|
||||||
import { TASK_STATUS } from '../../domain/task-status.js'
|
import { TASK_STATUS } from '../../domain/task-status.js'
|
||||||
|
import { ensureSemiAutoTasksForOrder } from '../semi-auto/semi-auto-task-service.js'
|
||||||
|
|
||||||
export type DevMockCreateResult = {
|
export type DevMockCreateResult = {
|
||||||
platform: 'affiliate_dash'
|
platform: 'affiliate_dash'
|
||||||
@@ -51,6 +60,21 @@ export type DevMockKuaishouIndustryVoucherResult = {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DevMockSemiAutoResult = {
|
||||||
|
platform: 'semi_auto'
|
||||||
|
orderId: number
|
||||||
|
orderNo: string
|
||||||
|
orderItemId: number
|
||||||
|
fulfillmentId: number
|
||||||
|
configId: number
|
||||||
|
taskId: number
|
||||||
|
skuKey: string
|
||||||
|
connectorMode: 'mock' | 'external'
|
||||||
|
customerPath: string
|
||||||
|
adminPath: string
|
||||||
|
tips: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export function isDevMockEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
export function isDevMockEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||||
if (String(env.ENABLE_DEV_MOCK || '').trim() === '1') {
|
if (String(env.ENABLE_DEV_MOCK || '').trim() === '1') {
|
||||||
return true
|
return true
|
||||||
@@ -87,12 +111,32 @@ export function getDevMockStatus() {
|
|||||||
description: '本站 claim + mock 绑定二维码/提交发货(不调真实 affiliate-dash 平台)',
|
description: '本站 claim + mock 绑定二维码/提交发货(不调真实 affiliate-dash 平台)',
|
||||||
steps: ['uid', 'bind', 'submitted', 'completed', 'failed'],
|
steps: ['uid', 'bind', 'submitted', 'completed', 'failed'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'semi_auto',
|
||||||
|
name: '半自动履约',
|
||||||
|
description:
|
||||||
|
'创建人工渠道半自动测试单;登录、绑定和浏览器票据由内置 Mock Connector 模拟',
|
||||||
|
steps: ['create_order', 'login', 'binding', 'browser_ticket', 'complete'],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
defaultUid: '10001',
|
defaultUid: '10001',
|
||||||
|
semiAuto: {
|
||||||
|
connectorMode: semiAutoConnectorMode(),
|
||||||
|
connectorConfigured: Boolean(
|
||||||
|
String(runtimeConfig.semiAuto?.connectorBaseUrl || '').trim() &&
|
||||||
|
String(runtimeConfig.semiAuto?.connectorApiKey || '').trim(),
|
||||||
|
),
|
||||||
|
platform: 'huya',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function semiAutoConnectorMode(): 'mock' | 'external' {
|
||||||
|
const baseUrl = String(runtimeConfig.semiAuto?.connectorBaseUrl || '').trim()
|
||||||
|
return baseUrl.includes('/dev-mock/connector') ? 'mock' : 'external'
|
||||||
|
}
|
||||||
|
|
||||||
type AffiliateDashMockStep = 'uid' | 'bind' | 'submitted' | 'completed' | 'failed'
|
type AffiliateDashMockStep = 'uid' | 'bind' | 'submitted' | 'completed' | 'failed'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -284,9 +328,188 @@ export async function createAffiliateDashMockClaim(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成电子凭证列表测试数据,不调用快手接口。
|
* 生成半自动履约测试单:人工 SKU 配置、订单、履约和半自动任务都走真实领域服务。
|
||||||
* 覆盖未使用、已核销、已销毁和发码失败等运营页面常见状态。
|
* 平台登录、绑定和浏览器票据由内置 Mock Connector 模拟。
|
||||||
*/
|
*/
|
||||||
|
export async function createSemiAutoMock(
|
||||||
|
input: {
|
||||||
|
orderNo?: unknown
|
||||||
|
skuName?: unknown
|
||||||
|
quantity?: unknown
|
||||||
|
pageTitle?: unknown
|
||||||
|
pageDescription?: unknown
|
||||||
|
browserTargetUrl?: unknown
|
||||||
|
} = {},
|
||||||
|
): Promise<DevMockSemiAutoResult> {
|
||||||
|
assertDevMockEnabled()
|
||||||
|
|
||||||
|
const orderNo = String(input.orderNo || `MOCKSA${Date.now()}`).trim()
|
||||||
|
const skuKey = normalizeMockSemiAutoSku(input.skuName)
|
||||||
|
const quantity = normalizeMockSemiAutoQuantity(input.quantity)
|
||||||
|
const pageTitle = String(input.pageTitle || '虎牙账号授权测试')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 60)
|
||||||
|
const pageDescription = String(
|
||||||
|
input.pageDescription || '开发 Mock:完成登录和角色绑定后,客服在后台完成履约。',
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.slice(0, 500)
|
||||||
|
const browserTargetUrl = String(input.browserTargetUrl || 'https://www.huya.com/')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 1000)
|
||||||
|
|
||||||
|
await assertOrderNoAvailable(orderNo)
|
||||||
|
|
||||||
|
const config = await saveManualFulfillmentConfig({
|
||||||
|
skuKey,
|
||||||
|
enabled: true,
|
||||||
|
note: '开发 Mock 半自动履约',
|
||||||
|
channels: [{ platformKey: 'fund_cash', creditQuantity: 100, enabled: true }],
|
||||||
|
semiAutoEnabled: true,
|
||||||
|
semiAutoPlatform: 'huya',
|
||||||
|
semiAutoConfig: {
|
||||||
|
pageTitle,
|
||||||
|
pageDescription,
|
||||||
|
loginInstructions: 'Mock 登录:点击获取二维码后,页面轮询两次会自动模拟登录成功。',
|
||||||
|
bindingInstructions: 'Mock 绑定:页面轮询两次后会自动识别角色,点击确认即可。',
|
||||||
|
browserTargets: [{ key: 'huya', label: '虎牙', url: browserTargetUrl }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const configId = Number(config.id || 0)
|
||||||
|
const canonicalProductId = config.canonicalProductId ? Number(config.canonicalProductId) : null
|
||||||
|
if (!configId) {
|
||||||
|
throw createHttpError('半自动履约 Mock 配置创建失败', {
|
||||||
|
statusCode: 500,
|
||||||
|
errorCode: 'dev_mock_semi_auto_config_failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = nowIso()
|
||||||
|
const order = await createBaseOrder({
|
||||||
|
orderNo,
|
||||||
|
productNo: skuKey,
|
||||||
|
productName: skuKey,
|
||||||
|
now,
|
||||||
|
platformTag: 'semi-auto',
|
||||||
|
totalAmount: 100 * quantity,
|
||||||
|
})
|
||||||
|
const [orderItem] = await replaceOrderItems(order.id, [
|
||||||
|
{
|
||||||
|
skuCode: skuKey,
|
||||||
|
skuName: skuKey,
|
||||||
|
quantity,
|
||||||
|
specJson: JSON.stringify({
|
||||||
|
source: 'dev_mock',
|
||||||
|
skuNick: skuKey,
|
||||||
|
semiAuto: { platform: 'huya', mock: true },
|
||||||
|
}),
|
||||||
|
itemSnapshotJson: JSON.stringify({
|
||||||
|
source: 'dev_mock',
|
||||||
|
skuKey,
|
||||||
|
platform: 'huya',
|
||||||
|
mock: true,
|
||||||
|
}),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const orderItemId = Number(orderItem?.id || 0)
|
||||||
|
if (!orderItemId) {
|
||||||
|
throw createHttpError('半自动履约 Mock 订单项创建失败', {
|
||||||
|
statusCode: 500,
|
||||||
|
errorCode: 'dev_mock_semi_auto_item_failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const fulfillmentResult = await query<{ id: number }>(
|
||||||
|
'SELECT id FROM fulfillments WHERE order_item_id = $1 LIMIT 1',
|
||||||
|
[orderItemId],
|
||||||
|
)
|
||||||
|
const fulfillmentId = Number(fulfillmentResult.rows[0]?.id || 0)
|
||||||
|
if (!fulfillmentId) {
|
||||||
|
throw createHttpError('半自动履约 Mock 履约单创建失败', {
|
||||||
|
statusCode: 500,
|
||||||
|
errorCode: 'dev_mock_semi_auto_fulfillment_failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await withTransaction(async (client) => {
|
||||||
|
await assignFulfillmentChannelsWithExecutor(client, {
|
||||||
|
assignments: [{ orderItemId, channel: 'manual', source: 'manual_confirm' }],
|
||||||
|
eventType: 'dev_mock_semi_auto_channel_assigned',
|
||||||
|
reason: 'dev_mock',
|
||||||
|
actor: { type: 'system' },
|
||||||
|
})
|
||||||
|
await attachFulfillmentConfigurationsWithExecutor(client, {
|
||||||
|
mappings: [
|
||||||
|
{
|
||||||
|
orderItemId,
|
||||||
|
configId,
|
||||||
|
...(canonicalProductId ? { canonicalProductId } : {}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expected: { status: 'assigned', channel: 'manual' },
|
||||||
|
onlyWhenConfigNull: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const ensured = await ensureSemiAutoTasksForOrder(orderNo)
|
||||||
|
let taskId = Number(ensured.createdIds[0] || 0)
|
||||||
|
if (!taskId) {
|
||||||
|
const existing = await query<{ id: number }>(
|
||||||
|
'SELECT id FROM semi_auto_tasks WHERE order_item_id = $1 LIMIT 1',
|
||||||
|
[orderItemId],
|
||||||
|
)
|
||||||
|
taskId = Number(existing.rows[0]?.id || 0)
|
||||||
|
}
|
||||||
|
if (!taskId) {
|
||||||
|
throw createHttpError('半自动履约 Mock 任务创建失败', {
|
||||||
|
statusCode: 500,
|
||||||
|
errorCode: 'dev_mock_semi_auto_task_failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectorMode = semiAutoConnectorMode()
|
||||||
|
return {
|
||||||
|
platform: 'semi_auto',
|
||||||
|
orderId: Number(order.id),
|
||||||
|
orderNo,
|
||||||
|
orderItemId,
|
||||||
|
fulfillmentId,
|
||||||
|
configId,
|
||||||
|
taskId,
|
||||||
|
skuKey,
|
||||||
|
connectorMode,
|
||||||
|
customerPath: `/c/${encodeURIComponent(orderNo)}`,
|
||||||
|
adminPath: `/admin/semi-auto-tasks?keyword=${encodeURIComponent(orderNo)}`,
|
||||||
|
tips: [
|
||||||
|
'打开客户页,点击“获取登录二维码”;Mock Connector 会在两次轮询后自动模拟登录成功。',
|
||||||
|
'登录后页面会自动创建绑定二维码,轮询两次后识别角色,点击“确认绑定角色”。',
|
||||||
|
'后台“任务 → 半自动履约”领取任务后生成浏览器票据;扩展消费票据后写入 Mock Cookie。',
|
||||||
|
'完成履约仍走真实确认流程,会写入成本快照和财务确认。',
|
||||||
|
connectorMode === 'mock'
|
||||||
|
? '当前使用内置 Mock Connector,不需要启动 live-hub-py。'
|
||||||
|
: '当前配置的是外部 Connector;请确认 Connector 已启动,否则登录和绑定会失败。',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMockSemiAutoSku(value: unknown) {
|
||||||
|
const raw = String(value || '').trim()
|
||||||
|
const withCjk = raw
|
||||||
|
? containsCjk(raw)
|
||||||
|
? raw
|
||||||
|
: `虎牙半自动-${raw}`
|
||||||
|
: `虎牙半自动测试-${Date.now()}`
|
||||||
|
return withCjk.slice(0, 48)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMockSemiAutoQuantity(value: unknown) {
|
||||||
|
const parsed = Math.trunc(Number(value || 1))
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 1) return 1
|
||||||
|
return Math.min(10, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
export async function createKuaishouIndustryVoucherMockData(
|
export async function createKuaishouIndustryVoucherMockData(
|
||||||
input: {
|
input: {
|
||||||
sellerId?: unknown
|
sellerId?: unknown
|
||||||
@@ -499,7 +722,9 @@ async function createBaseOrder(input: {
|
|||||||
productName: string
|
productName: string
|
||||||
now: string
|
now: string
|
||||||
platformTag: string
|
platformTag: string
|
||||||
|
totalAmount?: number
|
||||||
}) {
|
}) {
|
||||||
|
const totalAmount = input.totalAmount ?? 100
|
||||||
const order = await createOrder({
|
const order = await createOrder({
|
||||||
provider: OPEN_91_PROVIDER,
|
provider: OPEN_91_PROVIDER,
|
||||||
platform: OPEN_91_PLATFORM,
|
platform: OPEN_91_PLATFORM,
|
||||||
@@ -511,7 +736,7 @@ async function createBaseOrder(input: {
|
|||||||
buyerId: 'mock-buyer',
|
buyerId: 'mock-buyer',
|
||||||
buyerName: 'mock 领取客户',
|
buyerName: 'mock 领取客户',
|
||||||
receiverContact: '',
|
receiverContact: '',
|
||||||
totalAmount: 100,
|
totalAmount,
|
||||||
currency: 'CNY',
|
currency: 'CNY',
|
||||||
rawPayloadJson: JSON.stringify({
|
rawPayloadJson: JSON.stringify({
|
||||||
source: OPEN_91_PROVIDER,
|
source: OPEN_91_PROVIDER,
|
||||||
@@ -523,7 +748,7 @@ async function createBaseOrder(input: {
|
|||||||
productNo: input.productNo,
|
productNo: input.productNo,
|
||||||
productName: input.productName,
|
productName: input.productName,
|
||||||
buyNum: 1,
|
buyNum: 1,
|
||||||
maxAmount: '1.00',
|
maxAmount: (totalAmount / 100).toFixed(2),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
paidAt: input.now,
|
paidAt: input.now,
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
confirmMockBinding,
|
||||||
|
consumeMockBrowserTicket,
|
||||||
|
createMockBrowserTicket,
|
||||||
|
createMockLoginSession,
|
||||||
|
getMockBinding,
|
||||||
|
getMockLoginSession,
|
||||||
|
resetMockSessions,
|
||||||
|
revokeMockSession,
|
||||||
|
setMockBindingStatus,
|
||||||
|
setMockLoginStatus,
|
||||||
|
startMockBinding,
|
||||||
|
} from './mock-semi-auto-connector.js'
|
||||||
|
|
||||||
|
test.beforeEach(() => resetMockSessions())
|
||||||
|
|
||||||
|
test('Mock Connector 可按轮询自动完成登录、绑定和一次性票据消费', () => {
|
||||||
|
const created = createMockLoginSession({ taskId: 101, orderNo: 'MOCK-SA-1' })
|
||||||
|
assert.equal(created.status, 'waiting_scan')
|
||||||
|
assert.equal(created.hasCredential, false)
|
||||||
|
assert.match(created.qrContent, /^mock-huya-login:\/\//)
|
||||||
|
|
||||||
|
const scanned = getMockLoginSession(created.sessionId)
|
||||||
|
assert.equal(scanned.status, 'scanned')
|
||||||
|
assert.equal(scanned.hasCredential, false)
|
||||||
|
|
||||||
|
const authenticated = getMockLoginSession(created.sessionId)
|
||||||
|
assert.equal(authenticated.status, 'authenticated')
|
||||||
|
assert.equal(authenticated.hasCredential, true)
|
||||||
|
assert.ok(authenticated.huyaUid)
|
||||||
|
|
||||||
|
const binding = startMockBinding(created.sessionId)
|
||||||
|
assert.equal(binding.status, 'qr_pending')
|
||||||
|
assert.ok(binding.qrImage.startsWith('data:image/svg+xml;base64,'))
|
||||||
|
|
||||||
|
assert.equal(getMockBinding(created.sessionId).status, 'scanned')
|
||||||
|
const roleReady = getMockBinding(created.sessionId)
|
||||||
|
assert.equal(roleReady.status, 'role_ready')
|
||||||
|
assert.ok(roleReady.bindGameRole)
|
||||||
|
|
||||||
|
const confirmed = confirmMockBinding(created.sessionId)
|
||||||
|
assert.equal(confirmed.status, 'confirmed')
|
||||||
|
|
||||||
|
const ticket = createMockBrowserTicket({
|
||||||
|
sessionId: created.sessionId,
|
||||||
|
targetUrl: 'https://www.huya.com/mock',
|
||||||
|
})
|
||||||
|
assert.equal(ticket.targetUrl, 'https://www.huya.com/mock')
|
||||||
|
|
||||||
|
const payload = consumeMockBrowserTicket(ticket.ticket)
|
||||||
|
assert.equal(payload.platform, 'huya')
|
||||||
|
assert.ok(payload.cookies.length >= 1)
|
||||||
|
assert.equal(payload.cookies[0]?.domain, '.huya.com')
|
||||||
|
|
||||||
|
assert.throws(() => consumeMockBrowserTicket(ticket.ticket), /已被消费/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Mock Connector 支持控制状态、撤销会话和重置', () => {
|
||||||
|
const created = createMockLoginSession({ taskId: 202, orderNo: 'MOCK-SA-2' })
|
||||||
|
|
||||||
|
const authenticated = setMockLoginStatus(created.sessionId, 'authenticated')
|
||||||
|
assert.equal(authenticated.status, 'authenticated')
|
||||||
|
assert.equal(authenticated.hasCredential, true)
|
||||||
|
|
||||||
|
const confirmed = setMockBindingStatus(created.sessionId, 'confirmed')
|
||||||
|
assert.equal(confirmed.status, 'confirmed')
|
||||||
|
assert.ok(confirmed.bindGameRole)
|
||||||
|
|
||||||
|
revokeMockSession(created.sessionId)
|
||||||
|
assert.throws(() => getMockLoginSession(created.sessionId), /不存在或已失效/)
|
||||||
|
|
||||||
|
const second = createMockLoginSession({ taskId: 203, orderNo: 'MOCK-SA-3' })
|
||||||
|
const reset = resetMockSessions()
|
||||||
|
assert.equal(reset.cleared, 1)
|
||||||
|
assert.throws(() => getMockLoginSession(second.sessionId), /不存在或已失效/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
/**
|
||||||
|
* 开发环境内置的半自动履约 Connector。
|
||||||
|
*
|
||||||
|
* 只用于本地和测试环境:模拟虎牙登录、角色绑定、一次性浏览器注入票据,
|
||||||
|
* 不接触真实平台 Cookie。生产环境由 `isDevMockEnabled()` 拦截。
|
||||||
|
*/
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ConnectorBindingSession,
|
||||||
|
ConnectorBrowserTicket,
|
||||||
|
ConnectorInjectionPayload,
|
||||||
|
ConnectorLoginSession,
|
||||||
|
} from '../semi-auto/huya-connector-client.js'
|
||||||
|
import { createHttpError } from '../../utils/http.js'
|
||||||
|
|
||||||
|
const LOGIN_QR_TTL_MS = 5 * 60 * 1000
|
||||||
|
const CREDENTIAL_TTL_MS = 24 * 60 * 60 * 1000
|
||||||
|
const BINDING_QR_TTL_MS = 5 * 60 * 1000
|
||||||
|
const BROWSER_TICKET_TTL_MS = 2 * 60 * 1000
|
||||||
|
|
||||||
|
type MockLoginStatus = ConnectorLoginSession['status']
|
||||||
|
type MockBindingStatus = ConnectorBindingSession['status']
|
||||||
|
|
||||||
|
type MockTicket = {
|
||||||
|
ticket: string
|
||||||
|
targetUrl: string
|
||||||
|
expiresAtMs: number
|
||||||
|
consumed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockSession = {
|
||||||
|
sessionId: string
|
||||||
|
taskId: number
|
||||||
|
orderNo: string
|
||||||
|
createdAtMs: number
|
||||||
|
loginStatus: MockLoginStatus
|
||||||
|
loginPolls: number
|
||||||
|
loginExpiresAtMs: number
|
||||||
|
credentialExpiresAtMs: number
|
||||||
|
qrContent: string
|
||||||
|
huyaUid: string
|
||||||
|
nickname: string
|
||||||
|
hasCredential: boolean
|
||||||
|
bindingStatus: MockBindingStatus
|
||||||
|
bindingPolls: number
|
||||||
|
bindingQrExpiresAtMs: number
|
||||||
|
bindGameAccount: string
|
||||||
|
bindGameRole: string
|
||||||
|
bindGameChannel: string
|
||||||
|
message: string
|
||||||
|
tickets: Map<string, MockTicket>
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = new Map<string, MockSession>()
|
||||||
|
|
||||||
|
function buildMockUid(sessionId: string) {
|
||||||
|
return `mock-huya-${sessionId.slice(-8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMockBindingQr(sessionId: string) {
|
||||||
|
const svg = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320">
|
||||||
|
<rect width="320" height="320" fill="#ffffff"/>
|
||||||
|
<rect x="24" y="24" width="272" height="272" fill="none" stroke="#111827" stroke-width="6"/>
|
||||||
|
<text x="160" y="142" text-anchor="middle" font-family="Arial, sans-serif" font-size="28" fill="#111827">MOCK BIND</text>
|
||||||
|
<text x="160" y="182" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" fill="#4b5563">${escapeXml(sessionId.slice(-12))}</text>
|
||||||
|
<text x="160" y="218" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#6b7280">虎牙角色绑定测试二维码</text>
|
||||||
|
</svg>
|
||||||
|
`.trim()
|
||||||
|
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(value: string) {
|
||||||
|
return value.replace(/[<>&'"]/g, (char) => {
|
||||||
|
const entities: Record<string, string> = {
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'&': '&',
|
||||||
|
"'": ''',
|
||||||
|
'"': '"',
|
||||||
|
}
|
||||||
|
return entities[char] || char
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireSession(sessionIdInput: unknown): MockSession {
|
||||||
|
const sessionId = String(sessionIdInput || '').trim()
|
||||||
|
const session = sessions.get(sessionId)
|
||||||
|
if (!session) {
|
||||||
|
throw createHttpError('Mock Connector 会话不存在或已失效', {
|
||||||
|
statusCode: 404,
|
||||||
|
errorCode: 'mock_semi_auto_session_not_found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
expireSessionIfNeeded(session)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
function expireSessionIfNeeded(session: MockSession) {
|
||||||
|
const now = Date.now()
|
||||||
|
if (session.loginStatus === 'authenticated') {
|
||||||
|
if (session.credentialExpiresAtMs <= now) {
|
||||||
|
session.loginStatus = 'expired'
|
||||||
|
session.hasCredential = false
|
||||||
|
session.message = 'Mock 登录凭证已过期'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (session.loginExpiresAtMs <= now && session.loginStatus !== 'expired') {
|
||||||
|
session.loginStatus = 'expired'
|
||||||
|
session.hasCredential = false
|
||||||
|
session.message = 'Mock 登录二维码已过期'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginView(session: MockSession): ConnectorLoginSession {
|
||||||
|
return {
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
status: session.loginStatus,
|
||||||
|
qrContent: session.qrContent,
|
||||||
|
expiresAt: new Date(session.loginExpiresAtMs).toISOString(),
|
||||||
|
huyaUid: session.huyaUid,
|
||||||
|
nickname: session.nickname,
|
||||||
|
hasCredential: session.hasCredential,
|
||||||
|
message: session.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindingView(session: MockSession): ConnectorBindingSession {
|
||||||
|
return {
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
status: session.bindingStatus,
|
||||||
|
qrImage: session.bindingStatus === 'not_started' ? '' : buildMockBindingQr(session.sessionId),
|
||||||
|
qrExpiresAt: new Date(session.bindingQrExpiresAtMs).toISOString(),
|
||||||
|
bindGameAccount: session.bindGameAccount,
|
||||||
|
bindGameRole: session.bindGameRole,
|
||||||
|
bindGameChannel: session.bindGameChannel,
|
||||||
|
message: session.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAuthenticated(session: MockSession) {
|
||||||
|
session.loginStatus = 'authenticated'
|
||||||
|
session.hasCredential = true
|
||||||
|
session.huyaUid = buildMockUid(session.sessionId)
|
||||||
|
session.nickname = 'Mock虎牙用户'
|
||||||
|
session.credentialExpiresAtMs = Date.now() + CREDENTIAL_TTL_MS
|
||||||
|
session.message = 'Mock 登录成功'
|
||||||
|
}
|
||||||
|
|
||||||
|
function markRoleReady(session: MockSession) {
|
||||||
|
session.bindingStatus = 'role_ready'
|
||||||
|
session.bindGameAccount = `mock-account-${session.sessionId.slice(-6)}`
|
||||||
|
session.bindGameRole = 'Mock测试角色'
|
||||||
|
session.bindGameChannel = '虎牙'
|
||||||
|
session.message = 'Mock 已识别游戏角色,请确认绑定'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMockLoginSession(input: {
|
||||||
|
taskId?: unknown
|
||||||
|
orderNo?: unknown
|
||||||
|
}): ConnectorLoginSession {
|
||||||
|
const sessionId = crypto.randomUUID()
|
||||||
|
const now = Date.now()
|
||||||
|
const session: MockSession = {
|
||||||
|
sessionId,
|
||||||
|
taskId: Number(input.taskId || 0),
|
||||||
|
orderNo: String(input.orderNo || '').trim(),
|
||||||
|
createdAtMs: now,
|
||||||
|
loginStatus: 'waiting_scan',
|
||||||
|
loginPolls: 0,
|
||||||
|
loginExpiresAtMs: now + LOGIN_QR_TTL_MS,
|
||||||
|
credentialExpiresAtMs: 0,
|
||||||
|
qrContent: `mock-huya-login://${sessionId}`,
|
||||||
|
huyaUid: '',
|
||||||
|
nickname: '',
|
||||||
|
hasCredential: false,
|
||||||
|
bindingStatus: 'not_started',
|
||||||
|
bindingPolls: 0,
|
||||||
|
bindingQrExpiresAtMs: 0,
|
||||||
|
bindGameAccount: '',
|
||||||
|
bindGameRole: '',
|
||||||
|
bindGameChannel: '',
|
||||||
|
message: 'Mock 登录二维码已生成,轮询后会自动模拟扫码成功',
|
||||||
|
tickets: new Map(),
|
||||||
|
}
|
||||||
|
sessions.set(sessionId, session)
|
||||||
|
return loginView(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMockLoginSession(sessionIdInput: unknown): ConnectorLoginSession {
|
||||||
|
const session = requireSession(sessionIdInput)
|
||||||
|
if (session.loginStatus === 'waiting_scan' || session.loginStatus === 'scanned') {
|
||||||
|
session.loginPolls += 1
|
||||||
|
if (session.loginPolls >= 2) {
|
||||||
|
markAuthenticated(session)
|
||||||
|
} else if (session.loginPolls >= 1) {
|
||||||
|
session.loginStatus = 'scanned'
|
||||||
|
session.message = 'Mock 已扫码,等待确认登录'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return loginView(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startMockBinding(sessionIdInput: unknown): ConnectorBindingSession {
|
||||||
|
const session = requireSession(sessionIdInput)
|
||||||
|
if (session.loginStatus !== 'authenticated') {
|
||||||
|
throw createHttpError('Mock 登录尚未完成,不能开始绑定', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'mock_semi_auto_login_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const now = Date.now()
|
||||||
|
session.bindingStatus = 'qr_pending'
|
||||||
|
session.bindingPolls = 0
|
||||||
|
session.bindingQrExpiresAtMs = now + BINDING_QR_TTL_MS
|
||||||
|
session.bindGameAccount = ''
|
||||||
|
session.bindGameRole = ''
|
||||||
|
session.bindGameChannel = ''
|
||||||
|
session.message = 'Mock 绑定二维码已生成,轮询后会自动识别角色'
|
||||||
|
return bindingView(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMockBinding(sessionIdInput: unknown): ConnectorBindingSession {
|
||||||
|
const session = requireSession(sessionIdInput)
|
||||||
|
if (session.bindingStatus === 'qr_pending' || session.bindingStatus === 'scanned') {
|
||||||
|
if (session.bindingQrExpiresAtMs <= Date.now()) {
|
||||||
|
session.bindingStatus = 'expired'
|
||||||
|
session.message = 'Mock 绑定二维码已过期'
|
||||||
|
return bindingView(session)
|
||||||
|
}
|
||||||
|
session.bindingPolls += 1
|
||||||
|
if (session.bindingPolls >= 2) {
|
||||||
|
markRoleReady(session)
|
||||||
|
} else if (session.bindingPolls >= 1) {
|
||||||
|
session.bindingStatus = 'scanned'
|
||||||
|
session.message = 'Mock 已扫码,正在识别角色'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bindingView(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmMockBinding(sessionIdInput: unknown): ConnectorBindingSession {
|
||||||
|
const session = requireSession(sessionIdInput)
|
||||||
|
if (session.bindingStatus !== 'role_ready') {
|
||||||
|
throw createHttpError('Mock 角色尚未就绪,不能确认绑定', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'mock_semi_auto_binding_not_ready',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
session.bindingStatus = 'confirmed'
|
||||||
|
session.message = 'Mock 角色绑定已确认'
|
||||||
|
return bindingView(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMockBrowserTicket(input: {
|
||||||
|
sessionId?: unknown
|
||||||
|
targetUrl?: unknown
|
||||||
|
}): ConnectorBrowserTicket {
|
||||||
|
const session = requireSession(input.sessionId)
|
||||||
|
if (session.bindingStatus !== 'confirmed') {
|
||||||
|
throw createHttpError('Mock 角色尚未绑定,不能生成浏览器票据', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'mock_semi_auto_binding_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const ticket = `mock-ticket-${crypto.randomUUID()}`
|
||||||
|
const targetUrl = String(input.targetUrl || 'https://www.huya.com/').trim()
|
||||||
|
const expiresAtMs = Date.now() + BROWSER_TICKET_TTL_MS
|
||||||
|
session.tickets.set(ticket, { ticket, targetUrl, expiresAtMs, consumed: false })
|
||||||
|
return {
|
||||||
|
ticket,
|
||||||
|
expiresAt: new Date(expiresAtMs).toISOString(),
|
||||||
|
targetUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeMockBrowserTicket(ticketInput: unknown): ConnectorInjectionPayload {
|
||||||
|
const ticket = String(ticketInput || '').trim()
|
||||||
|
for (const session of sessions.values()) {
|
||||||
|
const item = session.tickets.get(ticket)
|
||||||
|
if (!item) continue
|
||||||
|
if (item.consumed) {
|
||||||
|
throw createHttpError('Mock 注入票据已被消费', {
|
||||||
|
statusCode: 410,
|
||||||
|
errorCode: 'mock_semi_auto_ticket_consumed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (item.expiresAtMs <= Date.now()) {
|
||||||
|
throw createHttpError('Mock 注入票据已过期', {
|
||||||
|
statusCode: 410,
|
||||||
|
errorCode: 'mock_semi_auto_ticket_expired',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
item.consumed = true
|
||||||
|
return {
|
||||||
|
platform: 'huya',
|
||||||
|
targetUrl: item.targetUrl,
|
||||||
|
expiresAt: new Date(item.expiresAtMs).toISOString(),
|
||||||
|
cookies: [
|
||||||
|
{
|
||||||
|
name: 'mock_huya_uid',
|
||||||
|
value: session.huyaUid || buildMockUid(session.sessionId),
|
||||||
|
domain: '.huya.com',
|
||||||
|
path: '/',
|
||||||
|
secure: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mock_huya_session',
|
||||||
|
value: session.sessionId,
|
||||||
|
domain: '.huya.com',
|
||||||
|
path: '/',
|
||||||
|
secure: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw createHttpError('Mock 注入票据不存在', {
|
||||||
|
statusCode: 404,
|
||||||
|
errorCode: 'mock_semi_auto_ticket_not_found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeMockSession(sessionIdInput: unknown) {
|
||||||
|
const sessionId = String(sessionIdInput || '').trim()
|
||||||
|
sessions.delete(sessionId)
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMockLoginStatus(
|
||||||
|
sessionIdInput: unknown,
|
||||||
|
statusInput: unknown,
|
||||||
|
): ConnectorLoginSession {
|
||||||
|
const session = requireSession(sessionIdInput)
|
||||||
|
const status = String(statusInput || '').trim() as MockLoginStatus
|
||||||
|
if (!['waiting_scan', 'scanned', 'authenticated', 'expired', 'failed'].includes(status)) {
|
||||||
|
throw createHttpError('Mock 登录状态无效', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'mock_semi_auto_login_status_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
session.loginStatus = status
|
||||||
|
if (status === 'authenticated') {
|
||||||
|
markAuthenticated(session)
|
||||||
|
} else {
|
||||||
|
session.hasCredential = false
|
||||||
|
session.message = `Mock 登录状态已切换为 ${status}`
|
||||||
|
}
|
||||||
|
return loginView(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMockBindingStatus(
|
||||||
|
sessionIdInput: unknown,
|
||||||
|
statusInput: unknown,
|
||||||
|
): ConnectorBindingSession {
|
||||||
|
const session = requireSession(sessionIdInput)
|
||||||
|
const status = String(statusInput || '').trim() as MockBindingStatus
|
||||||
|
if (
|
||||||
|
![
|
||||||
|
'not_started',
|
||||||
|
'qr_pending',
|
||||||
|
'scanned',
|
||||||
|
'role_ready',
|
||||||
|
'confirmed',
|
||||||
|
'expired',
|
||||||
|
'failed',
|
||||||
|
].includes(status)
|
||||||
|
) {
|
||||||
|
throw createHttpError('Mock 绑定状态无效', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'mock_semi_auto_binding_status_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
session.bindingStatus = status
|
||||||
|
if (status === 'role_ready' || status === 'confirmed') {
|
||||||
|
if (!session.bindGameRole) markRoleReady(session)
|
||||||
|
session.bindingStatus = status
|
||||||
|
} else {
|
||||||
|
session.bindGameAccount = ''
|
||||||
|
session.bindGameRole = ''
|
||||||
|
session.bindGameChannel = ''
|
||||||
|
}
|
||||||
|
session.message = `Mock 绑定状态已切换为 ${status}`
|
||||||
|
return bindingView(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMockSessions() {
|
||||||
|
return [...sessions.values()].map((session) => ({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
taskId: session.taskId,
|
||||||
|
orderNo: session.orderNo,
|
||||||
|
loginStatus: session.loginStatus,
|
||||||
|
bindingStatus: session.bindingStatus,
|
||||||
|
hasCredential: session.hasCredential,
|
||||||
|
ticketCount: session.tickets.size,
|
||||||
|
createdAt: new Date(session.createdAtMs).toISOString(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetMockSessions() {
|
||||||
|
const count = sessions.size
|
||||||
|
sessions.clear()
|
||||||
|
return { cleared: count }
|
||||||
|
}
|
||||||
@@ -339,3 +339,77 @@ test(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
test(
|
||||||
|
'开发 Mock 可生成半自动履约测试单并回填任务',
|
||||||
|
{ skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false },
|
||||||
|
async () => {
|
||||||
|
process.env.DATABASE_URL = TEST_DATABASE_URL
|
||||||
|
process.env.ENABLE_DEV_MOCK = '1'
|
||||||
|
const { closeDb, query } = await import('../../db/client.js')
|
||||||
|
const { createSemiAutoMock } = await import('../dev-mock/dev-mock-service.js')
|
||||||
|
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
|
||||||
|
const orderNo = `IT-MOCK-SA-${suffix}`
|
||||||
|
const skuKey = `半自动Mock-${suffix}`
|
||||||
|
let orderId = 0
|
||||||
|
let configId = 0
|
||||||
|
try {
|
||||||
|
const result = await createSemiAutoMock({
|
||||||
|
orderNo,
|
||||||
|
skuName: skuKey,
|
||||||
|
quantity: 1,
|
||||||
|
pageTitle: 'Mock 集成测试',
|
||||||
|
})
|
||||||
|
orderId = result.orderId
|
||||||
|
configId = result.configId
|
||||||
|
assert.ok(result.orderItemId > 0)
|
||||||
|
assert.ok(result.fulfillmentId > 0)
|
||||||
|
assert.ok(result.taskId > 0)
|
||||||
|
assert.ok(['mock', 'external'].includes(result.connectorMode))
|
||||||
|
assert.equal(result.customerPath, `/c/${encodeURIComponent(orderNo)}`)
|
||||||
|
|
||||||
|
const row = await query<{
|
||||||
|
channel: string
|
||||||
|
fulfillment_status: string
|
||||||
|
fulfillment_config_id: number
|
||||||
|
semi_auto_enabled: boolean
|
||||||
|
semi_auto_platform: string
|
||||||
|
login_status: string
|
||||||
|
binding_status: string
|
||||||
|
execution_status: string
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
f.channel,
|
||||||
|
f.status AS fulfillment_status,
|
||||||
|
f.fulfillment_config_id,
|
||||||
|
cfg.semi_auto_enabled,
|
||||||
|
cfg.semi_auto_platform,
|
||||||
|
task.login_status,
|
||||||
|
task.binding_status,
|
||||||
|
task.execution_status
|
||||||
|
FROM semi_auto_tasks task
|
||||||
|
JOIN fulfillments f ON f.id = task.fulfillment_id
|
||||||
|
JOIN manual_fulfillment_configs cfg ON cfg.id = task.manual_fulfillment_config_id
|
||||||
|
WHERE task.order_item_id = $1
|
||||||
|
`,
|
||||||
|
[result.orderItemId],
|
||||||
|
)
|
||||||
|
assert.equal(row.rows[0]?.channel, 'manual')
|
||||||
|
assert.equal(row.rows[0]?.fulfillment_status, 'assigned')
|
||||||
|
assert.equal(Number(row.rows[0]?.fulfillment_config_id), result.configId)
|
||||||
|
assert.equal(row.rows[0]?.semi_auto_enabled, true)
|
||||||
|
assert.equal(row.rows[0]?.semi_auto_platform, 'huya')
|
||||||
|
assert.equal(row.rows[0]?.login_status, 'waiting_login')
|
||||||
|
assert.equal(row.rows[0]?.binding_status, 'not_started')
|
||||||
|
assert.equal(row.rows[0]?.execution_status, 'waiting_customer')
|
||||||
|
} finally {
|
||||||
|
if (orderId > 0) await query('DELETE FROM orders WHERE id = $1', [orderId])
|
||||||
|
if (configId > 0) {
|
||||||
|
await query('DELETE FROM manual_fulfillment_configs WHERE id = $1', [configId])
|
||||||
|
}
|
||||||
|
delete process.env.ENABLE_DEV_MOCK
|
||||||
|
await closeDb()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
|
InputNumber,
|
||||||
Result,
|
Result,
|
||||||
Row,
|
Row,
|
||||||
Select,
|
Select,
|
||||||
@@ -23,10 +24,12 @@ import {
|
|||||||
buildOpen91QueryDevMock,
|
buildOpen91QueryDevMock,
|
||||||
createAffiliateDashDevMock,
|
createAffiliateDashDevMock,
|
||||||
createKuaishouIndustryVoucherDevMock,
|
createKuaishouIndustryVoucherDevMock,
|
||||||
|
createSemiAutoDevMock,
|
||||||
fetchDevMockStatus,
|
fetchDevMockStatus,
|
||||||
type DevMockCreateResult,
|
type DevMockCreateResult,
|
||||||
type DevMockKuaishouIndustryVoucherResult,
|
type DevMockKuaishouIndustryVoucherResult,
|
||||||
type DevMockOpen91QueryResult,
|
type DevMockOpen91QueryResult,
|
||||||
|
type DevMockSemiAutoResult,
|
||||||
} from '@/services/admin'
|
} from '@/services/admin'
|
||||||
|
|
||||||
export default function AdminDevMockPage() {
|
export default function AdminDevMockPage() {
|
||||||
@@ -35,6 +38,7 @@ export default function AdminDevMockPage() {
|
|||||||
const [lastVoucherCreate, setLastVoucherCreate] =
|
const [lastVoucherCreate, setLastVoucherCreate] =
|
||||||
useState<DevMockKuaishouIndustryVoucherResult | null>(null)
|
useState<DevMockKuaishouIndustryVoucherResult | null>(null)
|
||||||
const [lastQuery, setLastQuery] = useState<DevMockOpen91QueryResult | null>(null)
|
const [lastQuery, setLastQuery] = useState<DevMockOpen91QueryResult | null>(null)
|
||||||
|
const [lastSemiAuto, setLastSemiAuto] = useState<DevMockSemiAutoResult | null>(null)
|
||||||
|
|
||||||
const statusQuery = useQuery({
|
const statusQuery = useQuery({
|
||||||
queryKey: ['admin-dev-mock-status'],
|
queryKey: ['admin-dev-mock-status'],
|
||||||
@@ -78,6 +82,17 @@ export default function AdminDevMockPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const semiAutoMutation = useMutation({
|
||||||
|
mutationFn: createSemiAutoDevMock,
|
||||||
|
onSuccess: (response) => {
|
||||||
|
setLastSemiAuto(response.data)
|
||||||
|
message.success('半自动履约 Mock 已生成')
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
message.error(error instanceof Error ? error.message : '生成失败')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const defaultOrderNo = useMemo(() => lastCreate?.orderNo || '', [lastCreate?.orderNo])
|
const defaultOrderNo = useMemo(() => lastCreate?.orderNo || '', [lastCreate?.orderNo])
|
||||||
|
|
||||||
if (statusQuery.isLoading) {
|
if (statusQuery.isLoading) {
|
||||||
@@ -134,7 +149,17 @@ export default function AdminDevMockPage() {
|
|||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col xs={24} xl={12}>
|
<Col xs={24} xl={12}>
|
||||||
<Card title="2. 快手电子凭证测试数据" bordered={false}>
|
<Card title="2. 半自动履约测试单(Mock Connector)" bordered={false}>
|
||||||
|
<SemiAutoMockForm
|
||||||
|
loading={semiAutoMutation.isPending}
|
||||||
|
connectorMode={status?.semiAuto?.connectorMode}
|
||||||
|
onSubmit={(values) => semiAutoMutation.mutate(values)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} xl={12}>
|
||||||
|
<Card title="3. 快手电子凭证测试数据" bordered={false}>
|
||||||
<KuaishouIndustryVoucherMockForm
|
<KuaishouIndustryVoucherMockForm
|
||||||
loading={voucherMutation.isPending}
|
loading={voucherMutation.isPending}
|
||||||
onSubmit={(values) => voucherMutation.mutate(values)}
|
onSubmit={(values) => voucherMutation.mutate(values)}
|
||||||
@@ -143,7 +168,7 @@ export default function AdminDevMockPage() {
|
|||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col xs={24}>
|
<Col xs={24}>
|
||||||
<Card title="3. 模拟 91 查单(open-91/query)" bordered={false}>
|
<Card title="4. 模拟 91 查单(open-91/query)" bordered={false}>
|
||||||
<Form
|
<Form
|
||||||
layout="inline"
|
layout="inline"
|
||||||
initialValues={{ orderNo: defaultOrderNo, execute: '1' }}
|
initialValues={{ orderNo: defaultOrderNo, execute: '1' }}
|
||||||
@@ -260,6 +285,81 @@ export default function AdminDevMockPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{lastSemiAuto ? (
|
||||||
|
<Card
|
||||||
|
title="最近生成的半自动履约 Mock"
|
||||||
|
extra={<Link to={lastSemiAuto.adminPath}>打开后台任务</Link>}
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 12]}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Typography.Text type="secondary">订单号</Typography.Text>
|
||||||
|
<div>
|
||||||
|
<Typography.Text copyable strong>
|
||||||
|
{lastSemiAuto.orderNo}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Typography.Text type="secondary">内部 SKU / 任务</Typography.Text>
|
||||||
|
<div>
|
||||||
|
{lastSemiAuto.skuKey} · 任务 #{lastSemiAuto.taskId}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Typography.Text type="secondary">客户履约入口</Typography.Text>
|
||||||
|
<div>
|
||||||
|
<Typography.Link
|
||||||
|
href={`${window.location.origin}${lastSemiAuto.customerPath}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<LinkOutlined /> {window.location.origin}
|
||||||
|
{lastSemiAuto.customerPath}
|
||||||
|
</Typography.Link>
|
||||||
|
</div>
|
||||||
|
<Space style={{ marginTop: 8 }} wrap>
|
||||||
|
<Button
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={async () => {
|
||||||
|
await navigator.clipboard.writeText(
|
||||||
|
`${window.location.origin}${lastSemiAuto.customerPath}`,
|
||||||
|
)
|
||||||
|
message.success('已复制客户履约链接')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制客户链接
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
href={`${window.location.origin}${lastSemiAuto.customerPath}`}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
打开客户页
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Alert
|
||||||
|
type={lastSemiAuto.connectorMode === 'mock' ? 'success' : 'warning'}
|
||||||
|
showIcon
|
||||||
|
message={
|
||||||
|
lastSemiAuto.connectorMode === 'mock'
|
||||||
|
? '当前使用内置 Mock Connector'
|
||||||
|
: '当前使用外部 Connector'
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||||
|
{lastSemiAuto.tips.map((tip) => (
|
||||||
|
<li key={tip}>{tip}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{lastVoucherCreate ? (
|
{lastVoucherCreate ? (
|
||||||
<Card
|
<Card
|
||||||
title="最近生成的电子凭证 Mock"
|
title="最近生成的电子凭证 Mock"
|
||||||
@@ -400,3 +500,72 @@ function KuaishouIndustryVoucherMockForm({
|
|||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SemiAutoMockForm({
|
||||||
|
loading,
|
||||||
|
connectorMode,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
loading: boolean
|
||||||
|
connectorMode?: 'mock' | 'external'
|
||||||
|
onSubmit: (values: {
|
||||||
|
orderNo?: string
|
||||||
|
skuName?: string
|
||||||
|
quantity?: number
|
||||||
|
pageTitle?: string
|
||||||
|
browserTargetUrl?: string
|
||||||
|
}) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
quantity: 1,
|
||||||
|
pageTitle: '虎牙账号授权测试',
|
||||||
|
browserTargetUrl: 'https://www.huya.com/',
|
||||||
|
}}
|
||||||
|
onFinish={onSubmit}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="内部 SKU 名称(可空)"
|
||||||
|
name="skuName"
|
||||||
|
extra="留空自动生成“虎牙半自动测试-时间戳”;必须包含中文。"
|
||||||
|
>
|
||||||
|
<Input allowClear placeholder="虎牙半自动测试" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="订单号 orderNo(可空)" name="orderNo">
|
||||||
|
<Input allowClear placeholder="留空自动 MOCKSA…" />
|
||||||
|
</Form.Item>
|
||||||
|
<Space size={12} style={{ display: 'flex' }}>
|
||||||
|
<Form.Item label="数量" name="quantity" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={1} max={10} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="客户页标题" name="pageTitle" style={{ flex: 2 }}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Form.Item
|
||||||
|
label="客服浏览器目标页"
|
||||||
|
name="browserTargetUrl"
|
||||||
|
extra="客服点击“打开浏览器登录态”时,扩展会注入到此页面。"
|
||||||
|
>
|
||||||
|
<Input placeholder="https://www.huya.com/" />
|
||||||
|
</Form.Item>
|
||||||
|
<Alert
|
||||||
|
type={connectorMode === 'mock' ? 'success' : 'warning'}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
message={
|
||||||
|
connectorMode === 'mock'
|
||||||
|
? '内置 Mock Connector 已启用,登录和绑定会自动推进。'
|
||||||
|
: connectorMode === 'external'
|
||||||
|
? '当前配置为外部 Connector,请先确认外部服务已启动。'
|
||||||
|
: 'Connector 状态未知;生成后可在客户页验证。'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||||
|
生成半自动履约 Mock
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export type DevMockStatus = {
|
|||||||
description: string
|
description: string
|
||||||
steps: string[]
|
steps: string[]
|
||||||
}>
|
}>
|
||||||
|
semiAuto?: {
|
||||||
|
connectorMode: 'mock' | 'external'
|
||||||
|
connectorConfigured: boolean
|
||||||
|
platform: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DevMockCreateResult = {
|
export type DevMockCreateResult = {
|
||||||
@@ -45,6 +50,21 @@ export type DevMockKuaishouIndustryVoucherResult = {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DevMockSemiAutoResult = {
|
||||||
|
platform: 'semi_auto'
|
||||||
|
orderId: number
|
||||||
|
orderNo: string
|
||||||
|
orderItemId: number
|
||||||
|
fulfillmentId: number
|
||||||
|
configId: number
|
||||||
|
taskId: number
|
||||||
|
skuKey: string
|
||||||
|
connectorMode: 'mock' | 'external'
|
||||||
|
customerPath: string
|
||||||
|
adminPath: string
|
||||||
|
tips: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchDevMockStatus() {
|
export function fetchDevMockStatus() {
|
||||||
return apiGet<DevMockStatus>('/api/v1/admin/dev-mock/status')
|
return apiGet<DevMockStatus>('/api/v1/admin/dev-mock/status')
|
||||||
}
|
}
|
||||||
@@ -66,6 +86,17 @@ export function createKuaishouIndustryVoucherDevMock(payload: { sellerId?: strin
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createSemiAutoDevMock(payload: {
|
||||||
|
orderNo?: string
|
||||||
|
skuName?: string
|
||||||
|
quantity?: number
|
||||||
|
pageTitle?: string
|
||||||
|
pageDescription?: string
|
||||||
|
browserTargetUrl?: string
|
||||||
|
}) {
|
||||||
|
return apiPost<DevMockSemiAutoResult>('/api/v1/admin/dev-mock/semi-auto', payload)
|
||||||
|
}
|
||||||
|
|
||||||
export function buildOpen91QueryDevMock(payload: {
|
export function buildOpen91QueryDevMock(payload: {
|
||||||
orderNo: string
|
orderNo: string
|
||||||
execute?: boolean
|
execute?: boolean
|
||||||
|
|||||||
@@ -118,7 +118,8 @@ services:
|
|||||||
WORKSPACE_ROOT: /workspace
|
WORKSPACE_ROOT: /workspace
|
||||||
# ADMIN_* 仅走 env_file,避免 JSON 经 ${} 注入时被 YAML 破坏
|
# ADMIN_* 仅走 env_file,避免 JSON 经 ${} 注入时被 YAML 破坏
|
||||||
KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120}
|
KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120}
|
||||||
SEMI_AUTO_CONNECTOR_BASE_URL: ${SEMI_AUTO_CONNECTOR_BASE_URL:-http://host.docker.internal:8800}
|
# 默认使用 order_site 内置 Mock Connector;需要接外部 Connector 时显式覆盖。
|
||||||
|
SEMI_AUTO_CONNECTOR_BASE_URL: ${SEMI_AUTO_CONNECTOR_BASE_URL:-http://127.0.0.1:3000/api/v1/dev-mock/connector}
|
||||||
SEMI_AUTO_CONNECTOR_API_KEY: ${SEMI_AUTO_CONNECTOR_API_KEY:-local-semi-auto-connector-key}
|
SEMI_AUTO_CONNECTOR_API_KEY: ${SEMI_AUTO_CONNECTOR_API_KEY:-local-semi-auto-connector-key}
|
||||||
SEMI_AUTO_CONNECTOR_TIMEOUT_MS: ${SEMI_AUTO_CONNECTOR_TIMEOUT_MS:-15000}
|
SEMI_AUTO_CONNECTOR_TIMEOUT_MS: ${SEMI_AUTO_CONNECTOR_TIMEOUT_MS:-15000}
|
||||||
logging: *json-log-rotation
|
logging: *json-log-rotation
|
||||||
|
|||||||
Reference in New Issue
Block a user