新增:半自动履约工作台与虎牙客户授权流程
- 新增 153 半自动履约迁移、后台任务接口与客户履约接口 - 接入虎牙 Connector 登录会话、绑定与一次性 CK 注入票据 - 新增后台半自动履约页面和客户履约入口 - 完善手工履约配置、成本确认事务与运行时连接器配置 - 同步权威文档、环境示例、部署脚本和迁移展示逻辑
This commit is contained in:
@@ -69,6 +69,11 @@ ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"dev-admin-123456","rol
|
||||
KAQUAN91_USER_ID=
|
||||
KAQUAN91_SECRET=
|
||||
|
||||
# 半自动履约 Connector(虎牙登录/绑定/CK 注入票据)
|
||||
SEMI_AUTO_CONNECTOR_BASE_URL=http://host.docker.internal:8800
|
||||
SEMI_AUTO_CONNECTOR_API_KEY=local-semi-auto-connector-key
|
||||
SEMI_AUTO_CONNECTOR_TIMEOUT_MS=15000
|
||||
|
||||
# 快手行业电子凭证
|
||||
KUASHOU_INDUSTRY_APP_KEY=
|
||||
KUASHOU_INDUSTRY_APP_SECRET=
|
||||
|
||||
@@ -134,6 +134,12 @@ CORS_ALLOWED_ORIGINS=https://order.khhao.com,https://worker.khhao.com
|
||||
KAQUAN91_USER_ID=
|
||||
KAQUAN91_SECRET=
|
||||
|
||||
# 半自动履约 Connector(虎牙登录/绑定/CK 注入票据)
|
||||
# 留空时后台仍可查看任务,但客户登录和绑定会返回未配置。
|
||||
SEMI_AUTO_CONNECTOR_BASE_URL=
|
||||
SEMI_AUTO_CONNECTOR_API_KEY=
|
||||
SEMI_AUTO_CONNECTOR_TIMEOUT_MS=15000
|
||||
|
||||
# 快手行业电子凭证
|
||||
KUASHOU_INDUSTRY_APP_KEY=
|
||||
KUASHOU_INDUSTRY_APP_SECRET=
|
||||
|
||||
@@ -175,6 +175,9 @@ cp .env.server.example .env
|
||||
| `ADMIN_SESSION_SECRET` | 后台登录态签名密钥 |
|
||||
| `ADMIN_DEFAULT_USERS_JSON` | 默认后台用户 |
|
||||
| `KAQUAN91_USER_ID` / `KAQUAN91_SECRET` | 91 卡券开放接口凭据 |
|
||||
| `SEMI_AUTO_CONNECTOR_BASE_URL` | 半自动履约 Connector 基址(虎牙登录/绑定/CK 注入票据) |
|
||||
| `SEMI_AUTO_CONNECTOR_API_KEY` | order_site 与 Connector 的内部鉴权密钥 |
|
||||
| `SEMI_AUTO_CONNECTOR_TIMEOUT_MS` | Connector 请求超时,默认 15000 毫秒 |
|
||||
|
||||
## 运行数据
|
||||
|
||||
@@ -226,6 +229,7 @@ bash deploy/ubuntu-deploy.sh
|
||||
生产入口示例:
|
||||
|
||||
- 前端与领取页:`https://你的域名/`
|
||||
- 客户半自动履约入口:`https://你的域名/c/<平台订单号>`
|
||||
- 健康检查:`https://你的域名/health`
|
||||
- API:`https://你的域名/api/v1/...`
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import affiliateDashRouter from './routes/affiliate-dash.js'
|
||||
import orderServiceRouter from './routes/order-service.js'
|
||||
import filesRouter from './routes/files.js'
|
||||
import claimsRouter from './routes/claims.js'
|
||||
import customerFulfillmentRouter from './routes/customer-fulfillment.js'
|
||||
import dcAuthRouter from './routes/dcauth.js'
|
||||
import douyinMashidaRouter from './routes/douyin-mashida.js'
|
||||
import kuaishouIndustryRouter from './routes/kuaishou-industry.js'
|
||||
@@ -14,6 +15,7 @@ import open91Router from './routes/open-91.js'
|
||||
import pddExtensionRouter from './routes/pdd-extension.js'
|
||||
import redeemRouter from './routes/redeem.js'
|
||||
import shortLinksRouter from './routes/short-links.js'
|
||||
import semiAutoInjectionsRouter from './routes/semi-auto-injections.js'
|
||||
import workerRouter from './routes/worker.js'
|
||||
import { accessLogMiddleware } from './middleware/access-log.js'
|
||||
import { createCorsMiddleware } from './middleware/cors.js'
|
||||
@@ -112,6 +114,8 @@ export function createApp({ startupState, isShutdownStarted, config }: CreateApp
|
||||
app.use('/api/v1/open/kuaishou-industry', kuaishouIndustryRouter)
|
||||
app.use('/api/v1/redeem', redeemRouter)
|
||||
app.use('/api/v1/claim', claimsRouter)
|
||||
app.use('/api/v1/customer-fulfillment', customerFulfillmentRouter)
|
||||
app.use('/api/v1/semi-auto/injections', semiAutoInjectionsRouter)
|
||||
app.use('/api/v1/o', orderServiceRouter)
|
||||
app.use('/api/v1/pdd/extension', pddExtensionRouter)
|
||||
app.use('/api/v1/files', filesRouter)
|
||||
|
||||
@@ -61,6 +61,12 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
||||
tokenTtlHours: 0,
|
||||
},
|
||||
|
||||
semiAuto: {
|
||||
connectorBaseUrl: '',
|
||||
connectorApiKey: '',
|
||||
connectorTimeoutMs: 15_000,
|
||||
},
|
||||
|
||||
admin: {
|
||||
sessionSecret: '',
|
||||
// 后台:24h 空闲过期,登录后最多 7 天必须重新登录。
|
||||
|
||||
@@ -51,6 +51,9 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||
stringEnv('STORAGE_OSS_SECRET_ACCESS_KEY', ['storage', 'oss', 'secretAccessKey']),
|
||||
stringEnv('STORAGE_OSS_REGION', ['storage', 'oss', 'region']),
|
||||
integerEnv('CLAIM_TOKEN_TTL_HOURS', ['orders', 'tokenTtlHours']),
|
||||
stringEnv('SEMI_AUTO_CONNECTOR_BASE_URL', ['semiAuto', 'connectorBaseUrl']),
|
||||
stringEnv('SEMI_AUTO_CONNECTOR_API_KEY', ['semiAuto', 'connectorApiKey']),
|
||||
integerEnv('SEMI_AUTO_CONNECTOR_TIMEOUT_MS', ['semiAuto', 'connectorTimeoutMs']),
|
||||
stringEnv('ADMIN_SESSION_SECRET', ['admin', 'sessionSecret']),
|
||||
// 兼容旧变量:固定 TTL 时代按“绝对上限”理解,空闲窗口仍用新默认值并被绝对上限钳制。
|
||||
integerEnv('ADMIN_SESSION_TTL_HOURS', ['admin', 'sessionAbsoluteTtlHours']),
|
||||
|
||||
@@ -77,6 +77,13 @@ export function validateRuntimeConfig(
|
||||
config.database?.slowQueryThresholdMs,
|
||||
{ min: 0 },
|
||||
)
|
||||
validateOptionalHttpUrl(issues, 'semiAuto.connectorBaseUrl', config.semiAuto?.connectorBaseUrl)
|
||||
requireOptionalInteger(
|
||||
issues,
|
||||
'semiAuto.connectorTimeoutMs',
|
||||
config.semiAuto?.connectorTimeoutMs,
|
||||
{ min: 1 },
|
||||
)
|
||||
requireInteger(issues, 'storage.maxUploadSizeMb', config.storage?.maxUploadSizeMb, {
|
||||
min: 1,
|
||||
max: 100,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
-- 153_semi_auto_fulfillment.sql
|
||||
--
|
||||
-- 人工履约渠道下的半自动任务:
|
||||
-- 1. 人工 SKU 配置可显式启用虎牙/微博等半自动平台;
|
||||
-- 2. 半自动任务绑定订单项和正式履约,不另建接单平台工单;
|
||||
-- 3. 登录、绑定、执行状态正交保存,客户链接只按订单号展示必要信息;
|
||||
-- 4. CK 不进入 order_site;由外部 Connector 加密托管,任务只保存平台账号快照。
|
||||
|
||||
ALTER TABLE manual_fulfillment_configs
|
||||
ADD COLUMN IF NOT EXISTS semi_auto_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS semi_auto_platform TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS semi_auto_config_json JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'chk_manual_fulfillment_configs_semi_auto_platform'
|
||||
) THEN
|
||||
ALTER TABLE manual_fulfillment_configs
|
||||
ADD CONSTRAINT chk_manual_fulfillment_configs_semi_auto_platform
|
||||
CHECK (semi_auto_platform IN ('', 'huya', 'weibo'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS semi_auto_tasks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
order_item_id BIGINT NOT NULL UNIQUE REFERENCES order_items(id) ON DELETE CASCADE,
|
||||
fulfillment_id BIGINT REFERENCES fulfillments(id) ON DELETE SET NULL,
|
||||
manual_fulfillment_config_id BIGINT
|
||||
REFERENCES manual_fulfillment_configs(id) ON DELETE SET NULL,
|
||||
platform TEXT NOT NULL CHECK (platform IN ('huya', 'weibo')),
|
||||
login_status TEXT NOT NULL DEFAULT 'waiting_login'
|
||||
CHECK (login_status IN (
|
||||
'waiting_login', 'qr_pending', 'scanned', 'authenticated', 'failed', 'expired'
|
||||
)),
|
||||
binding_status TEXT NOT NULL DEFAULT 'not_started'
|
||||
CHECK (binding_status IN (
|
||||
'not_started', 'qr_pending', 'scanned', 'role_ready', 'confirmed', 'failed', 'expired'
|
||||
)),
|
||||
execution_status TEXT NOT NULL DEFAULT 'waiting_customer'
|
||||
CHECK (execution_status IN (
|
||||
'waiting_customer', 'waiting_operator', 'processing', 'completed', 'failed', 'cancelled'
|
||||
)),
|
||||
connector_session_id TEXT NOT NULL DEFAULT '',
|
||||
huya_uid TEXT NOT NULL DEFAULT '',
|
||||
platform_nickname TEXT NOT NULL DEFAULT '',
|
||||
bind_game_account TEXT NOT NULL DEFAULT '',
|
||||
bind_game_role TEXT NOT NULL DEFAULT '',
|
||||
bind_game_channel TEXT NOT NULL DEFAULT '',
|
||||
target_quantity INTEGER NOT NULL DEFAULT 1 CHECK (target_quantity > 0),
|
||||
completed_quantity INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (completed_quantity >= 0 AND completed_quantity <= target_quantity),
|
||||
assigned_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
customer_link_opened_at TIMESTAMPTZ,
|
||||
auth_started_at TIMESTAMPTZ,
|
||||
authenticated_at TIMESTAMPTZ,
|
||||
binding_started_at TIMESTAMPTZ,
|
||||
bound_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
completion_note TEXT NOT NULL DEFAULT '',
|
||||
external_order_no TEXT NOT NULL DEFAULT '',
|
||||
completion_evidence_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_semi_auto_tasks_status
|
||||
ON semi_auto_tasks (execution_status, platform, updated_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_semi_auto_tasks_order
|
||||
ON semi_auto_tasks (order_id, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_semi_auto_tasks_operator
|
||||
ON semi_auto_tasks (assigned_admin_user_id, execution_status, updated_at DESC)
|
||||
WHERE assigned_admin_user_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_semi_auto_tasks_connector
|
||||
ON semi_auto_tasks (connector_session_id)
|
||||
WHERE connector_session_id <> '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS semi_auto_task_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
task_id BIGINT NOT NULL REFERENCES semi_auto_tasks(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
actor_type TEXT NOT NULL DEFAULT 'system'
|
||||
CHECK (actor_type IN ('system', 'customer', 'admin', 'connector')),
|
||||
actor_id TEXT NOT NULL DEFAULT '',
|
||||
from_login_status TEXT,
|
||||
to_login_status TEXT,
|
||||
from_binding_status TEXT,
|
||||
to_binding_status TEXT,
|
||||
from_execution_status TEXT,
|
||||
to_execution_status TEXT,
|
||||
detail_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_semi_auto_task_events_task
|
||||
ON semi_auto_task_events (task_id, id DESC);
|
||||
|
||||
COMMENT ON TABLE semi_auto_tasks IS
|
||||
'人工履约渠道下的半自动任务;绑定订单项和正式履约,不承载平台 CK';
|
||||
COMMENT ON COLUMN semi_auto_tasks.connector_session_id IS
|
||||
'外部平台 Connector 的短期登录/绑定会话 ID;CK 仅保存在 Connector 加密凭证域';
|
||||
COMMENT ON COLUMN semi_auto_tasks.completion_evidence_json IS
|
||||
'可选交付证据(截图 object_key、备注、外部单号等),不得保存 Cookie 或令牌';
|
||||
COMMENT ON COLUMN manual_fulfillment_configs.semi_auto_config_json IS
|
||||
'半自动履约展示与目标页配置;不得保存平台 CK 或运行时状态';
|
||||
@@ -16,6 +16,7 @@ import ordersRouter from './admin/orders.js'
|
||||
import notificationsRouter from './admin/notifications.js'
|
||||
import realtimeRouter from './admin/realtime.js'
|
||||
import rbacRouter from './admin/rbac.js'
|
||||
import semiAutoRouter from './admin/semi-auto.js'
|
||||
import platformConfigRouter from './admin/platform-config.js'
|
||||
import { requireAdminSession } from './admin/session.js'
|
||||
import tasksRouter from './admin/tasks.js'
|
||||
@@ -36,6 +37,7 @@ router.use(fulfillmentChannelRouter)
|
||||
router.use(fundRouter)
|
||||
router.use(usersRouter)
|
||||
router.use(rbacRouter)
|
||||
router.use(semiAutoRouter)
|
||||
router.use(auditLogsRouter)
|
||||
router.use(platformConfigRouter)
|
||||
router.use(kuaishouIndustryRouter)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
cancelAdminSemiAutoTask,
|
||||
claimAdminSemiAutoTask,
|
||||
completeAdminSemiAutoTask,
|
||||
consumeSemiAutoInjectionTicket,
|
||||
createAdminBrowserSession,
|
||||
getAdminSemiAutoTask,
|
||||
listAdminSemiAutoTasks,
|
||||
syncAdminSemiAutoTask,
|
||||
} from '../../services/semi-auto/semi-auto-task-service.js'
|
||||
import { bodyOf, sessionOf } from './request-helpers.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use('/semi-auto', requireAdminRoles(['admin', 'operator', 'support']))
|
||||
|
||||
router.get(
|
||||
'/semi-auto/tasks',
|
||||
createJsonHandler((req) => listAdminSemiAutoTasks(req.query as Record<string, unknown>), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取半自动履约任务失败',
|
||||
scope: '[admin/semi-auto/tasks]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/semi-auto/tasks/:taskId',
|
||||
createJsonHandler((req) => getAdminSemiAutoTask(req.params.taskId), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取半自动履约任务详情失败',
|
||||
scope: '[admin/semi-auto/tasks/:taskId]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/semi-auto/tasks/:taskId/claim',
|
||||
createJsonHandler((req) => claimAdminSemiAutoTask(req.params.taskId, sessionOf(req)), {
|
||||
successMessage: '任务已领取',
|
||||
errorMessage: '领取半自动履约任务失败',
|
||||
scope: '[admin/semi-auto/tasks/:taskId/claim]',
|
||||
audit: (req) => ({
|
||||
action: 'semi_auto_task_claimed',
|
||||
targetType: 'semi_auto_task',
|
||||
targetId: String(req.params.taskId || ''),
|
||||
data: {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/semi-auto/tasks/:taskId/sync',
|
||||
createJsonHandler((req) => syncAdminSemiAutoTask(req.params.taskId), {
|
||||
successMessage: '状态已刷新',
|
||||
errorMessage: '刷新半自动履约状态失败',
|
||||
scope: '[admin/semi-auto/tasks/:taskId/sync]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/semi-auto/tasks/:taskId/browser-session',
|
||||
createJsonHandler(
|
||||
(req) => createAdminBrowserSession(req.params.taskId, bodyOf(req).targetUrl, sessionOf(req)),
|
||||
{
|
||||
successMessage: '注入票据已生成',
|
||||
errorMessage: '生成浏览器登录态失败',
|
||||
scope: '[admin/semi-auto/tasks/:taskId/browser-session]',
|
||||
audit: (req) => ({
|
||||
action: 'semi_auto_browser_session_created',
|
||||
targetType: 'semi_auto_task',
|
||||
targetId: String(req.params.taskId || ''),
|
||||
data: {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/semi-auto/injections/:ticket',
|
||||
createJsonHandler((req) => consumeSemiAutoInjectionTicket(req.params.ticket), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '消费浏览器注入票据失败',
|
||||
scope: '[admin/semi-auto/injections/:ticket]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/semi-auto/tasks/:taskId/complete',
|
||||
createJsonHandler(
|
||||
(req) => completeAdminSemiAutoTask(req.params.taskId, bodyOf(req), sessionOf(req)),
|
||||
{
|
||||
successMessage: '半自动履约已完成',
|
||||
errorMessage: '完成半自动履约失败',
|
||||
scope: '[admin/semi-auto/tasks/:taskId/complete]',
|
||||
audit: (req) => ({
|
||||
action: 'semi_auto_task_completed',
|
||||
targetType: 'semi_auto_task',
|
||||
targetId: String(req.params.taskId || ''),
|
||||
data: {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/semi-auto/tasks/:taskId/cancel',
|
||||
createJsonHandler(
|
||||
(req) => cancelAdminSemiAutoTask(req.params.taskId, bodyOf(req), sessionOf(req)),
|
||||
{
|
||||
successMessage: '半自动履约任务已取消',
|
||||
errorMessage: '取消半自动履约任务失败',
|
||||
scope: '[admin/semi-auto/tasks/:taskId/cancel]',
|
||||
audit: (req) => ({
|
||||
action: 'semi_auto_task_cancelled',
|
||||
targetType: 'semi_auto_task',
|
||||
targetId: String(req.params.taskId || ''),
|
||||
data: {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
confirmPublicBinding,
|
||||
listPublicSemiAutoTasks,
|
||||
markCustomerLinkOpened,
|
||||
pollPublicBinding,
|
||||
pollPublicLogin,
|
||||
startPublicBinding,
|
||||
startPublicLogin,
|
||||
} from '../services/semi-auto/semi-auto-task-service.js'
|
||||
import { createRateLimitMiddleware, getParamRateLimitKey } from '../middleware/rate-limit.js'
|
||||
import { createRouteHandler } from '../utils/http.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const readLimit = createRateLimitMiddleware({
|
||||
scope: 'customer-fulfillment:read',
|
||||
windowMs: 60_000,
|
||||
max: 240,
|
||||
key: getParamRateLimitKey('orderNo'),
|
||||
})
|
||||
const writeLimit = createRateLimitMiddleware({
|
||||
scope: 'customer-fulfillment:write',
|
||||
windowMs: 60_000,
|
||||
max: 60,
|
||||
key: getParamRateLimitKey('orderNo'),
|
||||
})
|
||||
|
||||
router.get(
|
||||
'/:orderNo',
|
||||
readLimit,
|
||||
createRouteHandler((req) => listPublicSemiAutoTasks(req.params.orderNo), {
|
||||
errorMessage: '读取订单履约信息失败',
|
||||
scope: '[customer-fulfillment/:orderNo]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:orderNo/tasks/:taskId/open',
|
||||
writeLimit,
|
||||
createRouteHandler((req) => markCustomerLinkOpened(req.params.orderNo, req.params.taskId), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '记录客户访问失败',
|
||||
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/open]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:orderNo/tasks/:taskId/login/start',
|
||||
writeLimit,
|
||||
createRouteHandler((req) => startPublicLogin(req.params.orderNo, req.params.taskId), {
|
||||
successMessage: '登录会话已创建',
|
||||
errorMessage: '开始平台登录失败',
|
||||
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/login/start]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/:orderNo/tasks/:taskId/login/status',
|
||||
readLimit,
|
||||
createRouteHandler((req) => pollPublicLogin(req.params.orderNo, req.params.taskId), {
|
||||
errorMessage: '刷新平台登录状态失败',
|
||||
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/login/status]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:orderNo/tasks/:taskId/binding/start',
|
||||
writeLimit,
|
||||
createRouteHandler((req) => startPublicBinding(req.params.orderNo, req.params.taskId), {
|
||||
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]',
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { consumeSemiAutoInjectionTicket } from '../services/semi-auto/semi-auto-task-service.js'
|
||||
import { createRateLimitMiddleware } from '../middleware/rate-limit.js'
|
||||
import { createRouteHandler } from '../utils/http.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get(
|
||||
'/:ticket',
|
||||
createRateLimitMiddleware({
|
||||
scope: 'semi-auto-injection-consume',
|
||||
windowMs: 60_000,
|
||||
max: 30,
|
||||
}),
|
||||
createRouteHandler((req) => consumeSemiAutoInjectionTicket(req.params.ticket), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '消费浏览器注入票据失败',
|
||||
scope: '[semi-auto-injections/:ticket]',
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -41,6 +41,8 @@ import { productSourceFilterSql } from './cost-center-mappers.js'
|
||||
|
||||
export const MANUAL_PLATFORM_KEYS = ['nanhang', 'lewan', 'rock', 'fund_cash', 'on_account'] as const
|
||||
export type ManualPlatformKey = (typeof MANUAL_PLATFORM_KEYS)[number]
|
||||
export const SEMI_AUTO_PLATFORM_KEYS = ['huya', 'weibo'] as const
|
||||
export type SemiAutoPlatform = (typeof SEMI_AUTO_PLATFORM_KEYS)[number]
|
||||
|
||||
/**
|
||||
* 积分换算率(分/积分),**按平台不同**:
|
||||
@@ -109,6 +111,9 @@ type ManualSkuRow = {
|
||||
config_canonical_product_id: number | null
|
||||
config_enabled: boolean | null
|
||||
config_channels_json: unknown
|
||||
config_semi_auto_enabled: boolean | null
|
||||
config_semi_auto_platform: string | null
|
||||
config_semi_auto_config_json: unknown
|
||||
}
|
||||
|
||||
type ManualChannelConfig = {
|
||||
@@ -344,7 +349,10 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
|
||||
cfg.cost_mode AS config_cost_mode,
|
||||
COALESCE(cfg.canonical_product_id, p.id) AS config_canonical_product_id,
|
||||
cfg.enabled AS config_enabled,
|
||||
cfg.channels_json AS config_channels_json
|
||||
cfg.channels_json AS config_channels_json,
|
||||
cfg.semi_auto_enabled AS config_semi_auto_enabled,
|
||||
cfg.semi_auto_platform AS config_semi_auto_platform,
|
||||
cfg.semi_auto_config_json AS config_semi_auto_config_json
|
||||
FROM product_catalog p
|
||||
JOIN internal_skus product_sku ON product_sku.id = p.internal_sku_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -458,6 +466,7 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
|
||||
)
|
||||
const enabled = payload.enabled === false ? false : true
|
||||
const note = optionalText(payload.note, 300)
|
||||
const semiAuto = normalizeSemiAutoConfig(payload)
|
||||
|
||||
// 消耗积分为 0 = 成本不固定,客服确认履约时手填。
|
||||
const costMode = primaryChannel.costMode
|
||||
@@ -546,7 +555,11 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
|
||||
`UPDATE manual_fulfillment_configs
|
||||
SET sku_key = $2, canonical_product_id = $3, platform_key = $4,
|
||||
credit_quantity = $5, cost_mode = $6, enabled = $7, note = $8,
|
||||
channels_json = $9::jsonb, updated_at = $10
|
||||
channels_json = $9::jsonb,
|
||||
semi_auto_enabled = $10,
|
||||
semi_auto_platform = $11,
|
||||
semi_auto_config_json = $12::jsonb,
|
||||
updated_at = $13
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[
|
||||
@@ -559,14 +572,22 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
|
||||
enabled,
|
||||
note,
|
||||
JSON.stringify(channels),
|
||||
semiAuto.enabled,
|
||||
semiAuto.platform,
|
||||
JSON.stringify(semiAuto.config),
|
||||
now,
|
||||
],
|
||||
)
|
||||
: await client.query(
|
||||
`INSERT INTO manual_fulfillment_configs (
|
||||
sku_key, canonical_product_id, platform_key, credit_quantity,
|
||||
cost_mode, enabled, note, channels_json, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $9)
|
||||
cost_mode, enabled, note, channels_json,
|
||||
semi_auto_enabled, semi_auto_platform, semi_auto_config_json,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8::jsonb,
|
||||
$9, $10, $11::jsonb, $12, $12
|
||||
)
|
||||
RETURNING *`,
|
||||
[
|
||||
skuKey,
|
||||
@@ -577,6 +598,9 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
|
||||
enabled,
|
||||
note,
|
||||
JSON.stringify(channels),
|
||||
semiAuto.enabled,
|
||||
semiAuto.platform,
|
||||
JSON.stringify(semiAuto.config),
|
||||
now,
|
||||
],
|
||||
)
|
||||
@@ -1046,6 +1070,9 @@ export async function getManualFulfillmentConfig(skuKey: string) {
|
||||
enabled: boolean
|
||||
note: string
|
||||
channels_json: unknown
|
||||
semi_auto_enabled: boolean
|
||||
semi_auto_platform: string
|
||||
semi_auto_config_json: unknown
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}>(
|
||||
@@ -1174,6 +1201,9 @@ function mapManualSku(row: ManualSkuRow, unitPrices: Record<ManualPlatformKey, n
|
||||
? Number(row.config_canonical_product_id)
|
||||
: null,
|
||||
enabled: row.config_enabled !== false,
|
||||
semiAutoEnabled: row.config_semi_auto_enabled === true,
|
||||
semiAutoPlatform: String(row.config_semi_auto_platform || ''),
|
||||
semiAutoConfig: normalizeStoredSemiAutoConfig(row.config_semi_auto_config_json),
|
||||
channels: mapConfiguredChannels(channels, unitPrices),
|
||||
}
|
||||
: null,
|
||||
@@ -1191,6 +1221,9 @@ function mapManualConfig(
|
||||
enabled: boolean
|
||||
note: string
|
||||
channels_json: unknown
|
||||
semi_auto_enabled: boolean
|
||||
semi_auto_platform: string
|
||||
semi_auto_config_json: unknown
|
||||
created_at: string
|
||||
updated_at: string
|
||||
},
|
||||
@@ -1211,6 +1244,9 @@ function mapManualConfig(
|
||||
costFen: calculateManualCostFen(creditQuantity, unitPriceFen),
|
||||
creditUnitPriceFen: unitPriceFen,
|
||||
enabled: row.enabled,
|
||||
semiAutoEnabled: row.semi_auto_enabled === true,
|
||||
semiAutoPlatform: String(row.semi_auto_platform || ''),
|
||||
semiAutoConfig: normalizeStoredSemiAutoConfig(row.semi_auto_config_json),
|
||||
channels: mapConfiguredChannels(channels, unitPrices),
|
||||
note: row.note || '',
|
||||
createdAt: row.created_at,
|
||||
@@ -1218,6 +1254,73 @@ function mapManualConfig(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSemiAutoConfig(payload: JsonObject): {
|
||||
enabled: boolean
|
||||
platform: SemiAutoPlatform | ''
|
||||
config: Record<string, unknown>
|
||||
} {
|
||||
const enabled = payload.semiAutoEnabled === true || payload.semi_auto_enabled === true
|
||||
const platform = String(payload.semiAutoPlatform ?? payload.semi_auto_platform ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!enabled) {
|
||||
return { enabled: false, platform: '', config: {} }
|
||||
}
|
||||
if (!(SEMI_AUTO_PLATFORM_KEYS as readonly string[]).includes(platform)) {
|
||||
throw createHttpError('半自动履约平台无效', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_config_semi_auto_platform_invalid',
|
||||
})
|
||||
}
|
||||
const rawConfig = payload.semiAutoConfig ?? payload.semi_auto_config
|
||||
const config =
|
||||
rawConfig && typeof rawConfig === 'object' && !Array.isArray(rawConfig)
|
||||
? (rawConfig as Record<string, unknown>)
|
||||
: {}
|
||||
return {
|
||||
enabled: true,
|
||||
platform: platform as SemiAutoPlatform,
|
||||
config: {
|
||||
pageTitle: String(config.pageTitle || '')
|
||||
.trim()
|
||||
.slice(0, 60),
|
||||
pageDescription: String(config.pageDescription || '')
|
||||
.trim()
|
||||
.slice(0, 500),
|
||||
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> =>
|
||||
Boolean(item && typeof item === 'object'),
|
||||
)
|
||||
.slice(0, 10)
|
||||
.map((item) => ({
|
||||
key: String(item.key || '')
|
||||
.trim()
|
||||
.slice(0, 40),
|
||||
label: String(item.label || '')
|
||||
.trim()
|
||||
.slice(0, 60),
|
||||
url: String(item.url || '')
|
||||
.trim()
|
||||
.slice(0, 1000),
|
||||
}))
|
||||
.filter((item) => item.key && item.url)
|
||||
: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStoredSemiAutoConfig(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function normalizePlatformKey(value: unknown): ManualPlatformKey {
|
||||
const text = String(value || '').trim()
|
||||
if (!MANUAL_PLATFORM_KEYS.includes(text as ManualPlatformKey)) {
|
||||
|
||||
@@ -66,10 +66,19 @@ test('人工确认必须单事务:扣款与快照同事务,且使用 with-cl
|
||||
const end = source.indexOf('async function resolveFundAccountId')
|
||||
const body = source.slice(start, end)
|
||||
|
||||
assert.ok(body.includes('withTransaction('), '人工确认必须包在事务里')
|
||||
const executeStart = body.indexOf('const execute = async (client: PoolClient)')
|
||||
const executeEnd = body.indexOf('const result = options.client', executeStart)
|
||||
const transactionalBody = body.slice(executeStart, executeEnd)
|
||||
assert.ok(executeStart >= 0 && executeEnd > executeStart, '人工确认必须抽出事务执行体')
|
||||
assert.ok(
|
||||
body.indexOf('withTransaction(') < body.indexOf('writeFundExpense(client'),
|
||||
'备用金扣款必须在同一事务内执行',
|
||||
body.includes(
|
||||
'options.client ? await execute(options.client) : await withTransaction(execute)',
|
||||
),
|
||||
'默认路径必须包在事务里,外部事务必须复用调用方 client',
|
||||
)
|
||||
assert.ok(
|
||||
transactionalBody.includes('writeFundExpense(client'),
|
||||
'备用金扣款必须在调用方事务内执行',
|
||||
)
|
||||
assert.ok(
|
||||
body.includes('consumePlatformCreditWithClient(client'),
|
||||
|
||||
@@ -65,10 +65,13 @@ type ManualConfiguredChannel = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
async function loadManualPlatformUnitPrices(): Promise<Record<ManualExecutionPlatformKey, number>> {
|
||||
const result = await query<{ profile_key: string; config_json: Record<string, unknown> }>(
|
||||
`SELECT profile_key, config_json FROM fulfillment_profiles WHERE profile_key LIKE 'manual_%'`,
|
||||
)
|
||||
async function loadManualPlatformUnitPrices(
|
||||
client?: Pick<PoolClient, 'query'>,
|
||||
): Promise<Record<ManualExecutionPlatformKey, number>> {
|
||||
const sql = `SELECT profile_key, config_json FROM fulfillment_profiles WHERE profile_key LIKE 'manual_%'`
|
||||
const result = client
|
||||
? await client.query<{ profile_key: string; config_json: Record<string, unknown> }>(sql)
|
||||
: await query<{ profile_key: string; config_json: Record<string, unknown> }>(sql)
|
||||
const prices: Record<ManualExecutionPlatformKey, number> = {
|
||||
nanhang: 1,
|
||||
lewan: 1,
|
||||
@@ -299,17 +302,26 @@ export async function listManualFulfillmentPending(
|
||||
* 固定成本(消耗积分 > 0):直接用配置值,零输入。
|
||||
* 成本不固定(消耗积分 = 0):客服必须填实际成本金额。
|
||||
*/
|
||||
export async function confirmManualFulfillment(payload: JsonObject, session: AdminSession) {
|
||||
export type ConfirmManualFulfillmentOptions = {
|
||||
/** 由调用方提供事务连接;此时调用方负责提交/回滚和提交后的报表刷新。 */
|
||||
client?: PoolClient
|
||||
}
|
||||
|
||||
export async function confirmManualFulfillment(
|
||||
payload: JsonObject,
|
||||
session: AdminSession,
|
||||
options: ConfirmManualFulfillmentOptions = {},
|
||||
) {
|
||||
const orderItemId = normalizeRequiredId(
|
||||
payload.orderItemId ?? payload.order_item_id,
|
||||
'请选择订单项',
|
||||
)
|
||||
const now = nowIso()
|
||||
const platformUnitPrices = await loadManualPlatformUnitPrices()
|
||||
|
||||
// 钱与成本必须同一事务:先锁订单项、复查未履约,再扣款、最后写快照。
|
||||
// 任何一步失败(余额不足、成本版本缺失、重复快照)都整体回滚,不会留下「扣了钱没记成本」。
|
||||
const result = await withTransaction(async (client) => {
|
||||
const execute = async (client: PoolClient) => {
|
||||
const now = nowIso()
|
||||
const platformUnitPrices = await loadManualPlatformUnitPrices(client)
|
||||
// 配置可能晚于订单创建:确认前按 SKU 再匹配一次(与定时 rematch 同口径),
|
||||
// 保证「配置好之后的新单」不需要人工再点一次「重新匹配」。
|
||||
await matchManualFulfillmentConfigWithClient(client, orderItemId)
|
||||
@@ -716,14 +728,26 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
onAccountBillId,
|
||||
costPending: onAccountCostPending,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await scheduleVoucherConsumeForOrderItems([orderItemId], 'order_received').catch(() => undefined)
|
||||
// 成本已落快照,立刻重算该业务日的事实与总账;失败不阻断确认(定时任务会兜底)。
|
||||
await refreshFactsForOrderItem(orderItemId)
|
||||
const result = options.client ? await execute(options.client) : await withTransaction(execute)
|
||||
if (!options.client) {
|
||||
await runManualFulfillmentPostCommit(orderItemId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 成本事务提交后的非关键旁路动作。
|
||||
*
|
||||
* 调用方若把 confirmManualFulfillment 纳入更大的事务,必须在提交后显式调用本函数。
|
||||
* 这里的失败只影响报表时效,不反写已经提交的成本事实。
|
||||
*/
|
||||
export async function runManualFulfillmentPostCommit(orderItemId: number): Promise<void> {
|
||||
await scheduleVoucherConsumeForOrderItems([orderItemId], 'order_received').catch(() => undefined)
|
||||
await refreshFactsForOrderItem(orderItemId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工确认后按「订单业务日期」重算销售事实与总账。
|
||||
*
|
||||
|
||||
@@ -191,8 +191,13 @@ export async function ensureCarrierForOrderItem(input: {
|
||||
}
|
||||
|
||||
if (channel === 'manual') {
|
||||
const config = await query<{ id: number; canonical_product_id: number | null }>(
|
||||
`SELECT id, canonical_product_id
|
||||
const config = await query<{
|
||||
id: number
|
||||
canonical_product_id: number | null
|
||||
semi_auto_enabled: boolean
|
||||
semi_auto_platform: string
|
||||
}>(
|
||||
`SELECT id, canonical_product_id, semi_auto_enabled, semi_auto_platform
|
||||
FROM manual_fulfillment_configs
|
||||
WHERE canonicalize_internal_sku(sku_key) = resolve_internal_sku($1, $2, $3)
|
||||
AND enabled = TRUE
|
||||
@@ -213,6 +218,14 @@ export async function ensureCarrierForOrderItem(input: {
|
||||
],
|
||||
onlyWhenCanonicalProductNull: true,
|
||||
})
|
||||
if (
|
||||
row.semi_auto_enabled === true &&
|
||||
['huya', 'weibo'].includes(String(row.semi_auto_platform || ''))
|
||||
) {
|
||||
const { ensureSemiAutoTasksForOrder } =
|
||||
await import('../semi-auto/semi-auto-task-service.js')
|
||||
await ensureSemiAutoTasksForOrder(order.platform_order_id)
|
||||
}
|
||||
return { ready: true, created: false, reason: 'manual_config_ready', message: '' }
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
|
||||
export type ConnectorLoginSession = {
|
||||
sessionId: string
|
||||
status: 'waiting_scan' | 'scanned' | 'authenticated' | 'expired' | 'failed'
|
||||
qrContent: string
|
||||
expiresAt: string
|
||||
huyaUid: string
|
||||
nickname: string
|
||||
hasCredential: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export type ConnectorBindingSession = {
|
||||
sessionId: string
|
||||
status:
|
||||
| 'not_started'
|
||||
| 'qr_pending'
|
||||
| 'scanned'
|
||||
| 'role_ready'
|
||||
| 'confirmed'
|
||||
| 'expired'
|
||||
| 'failed'
|
||||
qrImage: string
|
||||
qrExpiresAt: string
|
||||
bindGameAccount: string
|
||||
bindGameRole: string
|
||||
bindGameChannel: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type ConnectorBrowserTicket = {
|
||||
ticket: string
|
||||
expiresAt: string
|
||||
targetUrl: string
|
||||
}
|
||||
|
||||
export type ConnectorInjectionPayload = {
|
||||
platform: 'huya' | 'weibo'
|
||||
targetUrl: string
|
||||
expiresAt: string
|
||||
cookies: Array<{
|
||||
name: string
|
||||
value: string
|
||||
domain: string
|
||||
path: string
|
||||
secure: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
type ConnectorRequestOptions = {
|
||||
method?: 'GET' | 'POST' | 'DELETE'
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
function connectorBaseUrl() {
|
||||
return String(runtimeConfig.semiAuto?.connectorBaseUrl || '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function connectorApiKey() {
|
||||
return String(runtimeConfig.semiAuto?.connectorApiKey || '').trim()
|
||||
}
|
||||
|
||||
export function isSemiAutoConnectorConfigured() {
|
||||
return Boolean(connectorBaseUrl() && connectorApiKey())
|
||||
}
|
||||
|
||||
async function callConnector<T>(path: string, options: ConnectorRequestOptions = {}): Promise<T> {
|
||||
const baseUrl = connectorBaseUrl()
|
||||
const apiKey = connectorApiKey()
|
||||
if (!baseUrl || !apiKey) {
|
||||
throw createHttpError('半自动履约 Connector 尚未配置', {
|
||||
statusCode: 503,
|
||||
errorCode: 'semi_auto_connector_not_configured',
|
||||
})
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(1000, Number(runtimeConfig.semiAuto?.connectorTimeoutMs || 15_000))
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const requestInit: RequestInit = {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Semi-Auto-Key': apiKey,
|
||||
},
|
||||
signal: controller.signal,
|
||||
}
|
||||
if (options.body !== undefined) requestInit.body = JSON.stringify(options.body)
|
||||
const response = await fetch(`${baseUrl}${path}`, requestInit)
|
||||
const text = await response.text()
|
||||
let payload: unknown = null
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null
|
||||
} catch {
|
||||
payload = null
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail =
|
||||
payload && typeof payload === 'object'
|
||||
? String((payload as Record<string, unknown>).detail || '')
|
||||
: ''
|
||||
throw createHttpError(detail || `半自动履约 Connector 请求失败(${response.status})`, {
|
||||
statusCode: 502,
|
||||
errorCode: 'semi_auto_connector_request_failed',
|
||||
})
|
||||
}
|
||||
return payload as T
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'statusCode' in error) throw error
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw createHttpError('半自动履约 Connector 请求超时', {
|
||||
statusCode: 504,
|
||||
errorCode: 'semi_auto_connector_timeout',
|
||||
})
|
||||
}
|
||||
throw createHttpError(
|
||||
error instanceof Error ? error.message : '半自动履约 Connector 请求失败',
|
||||
{
|
||||
statusCode: 502,
|
||||
errorCode: 'semi_auto_connector_request_failed',
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
export async function createHuyaLoginSession(input: {
|
||||
taskId: number
|
||||
orderNo: string
|
||||
}): Promise<ConnectorLoginSession> {
|
||||
return callConnector<ConnectorLoginSession>('/api/internal/huya-semi-auto/sessions', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getHuyaLoginSession(sessionId: string): Promise<ConnectorLoginSession> {
|
||||
return callConnector<ConnectorLoginSession>(
|
||||
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function startHuyaBinding(sessionId: string): Promise<ConnectorBindingSession> {
|
||||
return callConnector<ConnectorBindingSession>(
|
||||
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}/binding`,
|
||||
{ method: 'POST', body: {} },
|
||||
)
|
||||
}
|
||||
|
||||
export async function getHuyaBinding(sessionId: string): Promise<ConnectorBindingSession> {
|
||||
return callConnector<ConnectorBindingSession>(
|
||||
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}/binding`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function confirmHuyaBinding(sessionId: string): Promise<ConnectorBindingSession> {
|
||||
return callConnector<ConnectorBindingSession>(
|
||||
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}/binding/confirm`,
|
||||
{ method: 'POST', body: {} },
|
||||
)
|
||||
}
|
||||
|
||||
export async function createHuyaBrowserTicket(input: {
|
||||
sessionId: string
|
||||
targetUrl?: string
|
||||
}): Promise<ConnectorBrowserTicket> {
|
||||
return callConnector<ConnectorBrowserTicket>(
|
||||
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(input.sessionId)}/browser-ticket`,
|
||||
{ method: 'POST', body: { targetUrl: input.targetUrl || '' } },
|
||||
)
|
||||
}
|
||||
|
||||
export async function consumeHuyaBrowserTicket(ticket: string): Promise<ConnectorInjectionPayload> {
|
||||
return callConnector<ConnectorInjectionPayload>(
|
||||
`/api/internal/huya-semi-auto/browser-tickets/${encodeURIComponent(ticket)}`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function revokeHuyaSession(sessionId: string): Promise<void> {
|
||||
await callConnector<{ ok: boolean }>(
|
||||
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* 半自动任务真实 PostgreSQL 集成测试。
|
||||
*
|
||||
* SEMI_AUTO_TASK_TEST_DATABASE_URL=postgres://... \
|
||||
* npx tsx --test src/services/semi-auto/semi-auto-task.integration.test.ts
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const TEST_DATABASE_URL = String(process.env.SEMI_AUTO_TASK_TEST_DATABASE_URL || '').trim()
|
||||
|
||||
test(
|
||||
'人工渠道半自动任务可由客户入口和后台列表幂等回填',
|
||||
{ skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false },
|
||||
async () => {
|
||||
process.env.DATABASE_URL = TEST_DATABASE_URL
|
||||
const { closeDb, query } = await import('../../db/client.js')
|
||||
const { ensureSemiAutoTasksForOrder, listAdminSemiAutoTasks, listPublicSemiAutoTasks } =
|
||||
await import('./semi-auto-task-service.js')
|
||||
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
|
||||
const orderNo = `IT-SEMI-AUTO-${suffix}`
|
||||
const skuKey = `半自动测试-${suffix}`
|
||||
let orderId = 0
|
||||
let configId = 0
|
||||
try {
|
||||
const order = await query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO orders (
|
||||
provider, platform, shop_id, shop_name, platform_order_id,
|
||||
order_status, pay_status, total_amount, paid_at, created_at, updated_at
|
||||
) VALUES (
|
||||
'91kaquan', 'kuaishou-91', $1, '半自动测试店', $2,
|
||||
'paid', 'paid', 1000, NOW(), NOW(), NOW()
|
||||
) RETURNING id
|
||||
`,
|
||||
[`semi-auto-shop-${suffix}`, orderNo],
|
||||
)
|
||||
orderId = Number(order.rows[0]?.id || 0)
|
||||
assert.ok(orderId > 0)
|
||||
const item = await query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO order_items (
|
||||
order_id, sku_code, sku_name, quantity, spec_json, item_snapshot_json,
|
||||
revenue_amount_fen, revenue_basis, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, 1, $4::jsonb, $4::jsonb,
|
||||
1000, 'order_total', NOW(), NOW()
|
||||
) RETURNING id
|
||||
`,
|
||||
[orderId, `semi-${suffix}`, skuKey, JSON.stringify({ skuNick: skuKey })],
|
||||
)
|
||||
const orderItemId = Number(item.rows[0]?.id || 0)
|
||||
assert.ok(orderItemId > 0)
|
||||
const fulfillment = await query<{ id: number }>(
|
||||
'SELECT id FROM fulfillments WHERE order_item_id = $1 LIMIT 1',
|
||||
[orderItemId],
|
||||
)
|
||||
const fulfillmentId = Number(fulfillment.rows[0]?.id || 0)
|
||||
assert.ok(fulfillmentId > 0)
|
||||
|
||||
const config = await query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO manual_fulfillment_configs (
|
||||
sku_key, platform_key, credit_quantity, cost_mode, enabled, note,
|
||||
channels_json, semi_auto_enabled, semi_auto_platform,
|
||||
semi_auto_config_json, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, 'fund_cash', 100, 'fixed', TRUE, '集成测试',
|
||||
'[{"platformKey":"fund_cash","creditQuantity":100,"costMode":"fixed","enabled":true}]'::jsonb,
|
||||
TRUE, 'huya', '{"pageTitle":"虎牙授权测试"}'::jsonb,
|
||||
NOW(), NOW()
|
||||
) RETURNING id
|
||||
`,
|
||||
[skuKey],
|
||||
)
|
||||
configId = Number(config.rows[0]?.id || 0)
|
||||
assert.ok(configId > 0)
|
||||
await query(
|
||||
`
|
||||
UPDATE fulfillments
|
||||
SET channel = 'manual', status = 'assigned', fulfillment_config_id = $2,
|
||||
source = 'manual_confirm', updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[fulfillmentId, configId],
|
||||
)
|
||||
|
||||
// 客服工作台先打开时也必须看到任务,不能依赖客户先访问 `/c/订单号`。
|
||||
const adminList = await listAdminSemiAutoTasks({ keyword: orderNo })
|
||||
assert.equal(adminList.pagination.total, 1)
|
||||
assert.equal(adminList.items[0]?.orderNo, orderNo)
|
||||
|
||||
const first = await ensureSemiAutoTasksForOrder(orderNo)
|
||||
const second = await ensureSemiAutoTasksForOrder(orderNo)
|
||||
assert.equal(first.createdIds.length, 0)
|
||||
assert.equal(second.createdIds.length, 0)
|
||||
|
||||
const tasks = await listPublicSemiAutoTasks(orderNo)
|
||||
assert.equal(tasks.length, 1)
|
||||
assert.equal(tasks[0]?.orderItemId, orderItemId)
|
||||
assert.equal(tasks[0]?.platform, 'huya')
|
||||
assert.equal(tasks[0]?.orderNo, orderNo)
|
||||
assert.equal(tasks[0]?.quantity, 1)
|
||||
assert.equal(tasks[0]?.semiAutoConfig.pageTitle, '虎牙授权测试')
|
||||
} 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])
|
||||
}
|
||||
await closeDb()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'半自动完成失败会连同财务确认一起回滚,成功后重复完成不重复扣成本',
|
||||
{ skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false },
|
||||
async () => {
|
||||
process.env.DATABASE_URL = TEST_DATABASE_URL
|
||||
const { closeDb, query } = await import('../../db/client.js')
|
||||
const { completeAdminSemiAutoTask, ensureSemiAutoTasksForOrder } =
|
||||
await import('./semi-auto-task-service.js')
|
||||
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
|
||||
const orderNo = `IT-SEMI-AUTO-ATOMIC-${suffix}`
|
||||
const skuKey = `半自动原子性-${suffix}`
|
||||
const triggerName = `trg_semi_auto_atomic_${suffix}`
|
||||
const functionName = `fn_semi_auto_atomic_${suffix}`
|
||||
let orderId = 0
|
||||
let configId = 0
|
||||
let adminId = 0
|
||||
let triggerCreated = false
|
||||
try {
|
||||
const admin = await query<{ id: number }>(
|
||||
`INSERT INTO admin_users (username, password_hash, role, status, created_at, updated_at)
|
||||
VALUES ($1, 'x', 'support', 'active', NOW(), NOW()) RETURNING id`,
|
||||
[`it_semi_auto_admin_${suffix}`],
|
||||
)
|
||||
adminId = Number(admin.rows[0]?.id || 0)
|
||||
assert.ok(adminId > 0)
|
||||
|
||||
const order = await query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO orders (
|
||||
provider, platform, shop_id, shop_name, platform_order_id,
|
||||
order_status, pay_status, total_amount, paid_at, created_at, updated_at
|
||||
) VALUES (
|
||||
'91kaquan', 'kuaishou-91', $1, '半自动原子性测试店', $2,
|
||||
'paid', 'paid', 1000, NOW(), NOW(), NOW()
|
||||
) RETURNING id
|
||||
`,
|
||||
[`semi-auto-atomic-shop-${suffix}`, orderNo],
|
||||
)
|
||||
orderId = Number(order.rows[0]?.id || 0)
|
||||
assert.ok(orderId > 0)
|
||||
const item = await query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO order_items (
|
||||
order_id, sku_code, sku_name, quantity, spec_json, item_snapshot_json,
|
||||
revenue_amount_fen, revenue_basis, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, 1, $4::jsonb, $4::jsonb,
|
||||
1000, 'order_total', NOW(), NOW()
|
||||
) RETURNING id
|
||||
`,
|
||||
[orderId, `semi-atomic-${suffix}`, skuKey, JSON.stringify({ skuNick: skuKey })],
|
||||
)
|
||||
const orderItemId = Number(item.rows[0]?.id || 0)
|
||||
assert.ok(orderItemId > 0)
|
||||
const fulfillment = await query<{ id: number }>(
|
||||
'SELECT id FROM fulfillments WHERE order_item_id = $1 LIMIT 1',
|
||||
[orderItemId],
|
||||
)
|
||||
const fulfillmentId = Number(fulfillment.rows[0]?.id || 0)
|
||||
assert.ok(fulfillmentId > 0)
|
||||
|
||||
const config = await query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO manual_fulfillment_configs (
|
||||
sku_key, platform_key, credit_quantity, cost_mode, enabled, note,
|
||||
channels_json, semi_auto_enabled, semi_auto_platform,
|
||||
semi_auto_config_json, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, 'on_account', 0, 'variable', TRUE, '原子性集成测试',
|
||||
'[{"platformKey":"on_account","creditQuantity":0,"costMode":"variable","enabled":true}]'::jsonb,
|
||||
TRUE, 'huya', '{}'::jsonb, NOW(), NOW()
|
||||
) RETURNING id
|
||||
`,
|
||||
[skuKey],
|
||||
)
|
||||
configId = Number(config.rows[0]?.id || 0)
|
||||
assert.ok(configId > 0)
|
||||
await query(
|
||||
`
|
||||
UPDATE fulfillments
|
||||
SET channel = 'manual', status = 'assigned', fulfillment_config_id = $2,
|
||||
source = 'manual_confirm', updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[fulfillmentId, configId],
|
||||
)
|
||||
|
||||
const ensured = await ensureSemiAutoTasksForOrder(orderNo)
|
||||
const taskId = Number(ensured.createdIds[0] || 0)
|
||||
assert.ok(taskId > 0)
|
||||
await query(
|
||||
`
|
||||
UPDATE semi_auto_tasks
|
||||
SET connector_session_id = $2,
|
||||
login_status = 'authenticated',
|
||||
binding_status = 'confirmed',
|
||||
execution_status = 'waiting_operator',
|
||||
authenticated_at = NOW(),
|
||||
bound_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[taskId, `it-session-${suffix}`],
|
||||
)
|
||||
|
||||
await query(
|
||||
`
|
||||
CREATE OR REPLACE FUNCTION ${functionName}() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.execution_status = 'completed' THEN
|
||||
RAISE EXCEPTION 'forced semi auto completion failure';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
`,
|
||||
)
|
||||
await query(
|
||||
`
|
||||
CREATE TRIGGER ${triggerName}
|
||||
BEFORE UPDATE OF execution_status ON semi_auto_tasks
|
||||
FOR EACH ROW EXECUTE FUNCTION ${functionName}()
|
||||
`,
|
||||
)
|
||||
triggerCreated = true
|
||||
|
||||
const session = {
|
||||
userId: adminId,
|
||||
role: 'support',
|
||||
displayName: '半自动原子性客服',
|
||||
} as never
|
||||
await assert.rejects(
|
||||
() =>
|
||||
completeAdminSemiAutoTask(
|
||||
taskId,
|
||||
{ costFen: 123, note: '触发任务更新失败以验证回滚' },
|
||||
session,
|
||||
),
|
||||
/forced semi auto completion failure/,
|
||||
)
|
||||
|
||||
const rolledBack = await query<{
|
||||
execution_status: string
|
||||
snapshot_count: number
|
||||
bill_count: number
|
||||
fulfillment_channel: string
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
task.execution_status,
|
||||
(SELECT COUNT(*)::int FROM cost_snapshots cs WHERE cs.order_item_id = $2) AS snapshot_count,
|
||||
(SELECT COUNT(*)::int FROM on_account_bills oab WHERE oab.order_item_id = $2) AS bill_count,
|
||||
f.channel AS fulfillment_channel
|
||||
FROM semi_auto_tasks task
|
||||
JOIN fulfillments f ON f.id = task.fulfillment_id
|
||||
WHERE task.id = $1
|
||||
`,
|
||||
[taskId, orderItemId],
|
||||
)
|
||||
assert.equal(rolledBack.rows[0]?.execution_status, 'waiting_operator')
|
||||
assert.equal(Number(rolledBack.rows[0]?.snapshot_count), 0)
|
||||
assert.equal(Number(rolledBack.rows[0]?.bill_count), 0)
|
||||
assert.equal(rolledBack.rows[0]?.fulfillment_channel, 'manual')
|
||||
|
||||
await query(`DROP TRIGGER ${triggerName} ON semi_auto_tasks`)
|
||||
triggerCreated = false
|
||||
await query(`DROP FUNCTION ${functionName}()`)
|
||||
|
||||
const completed = await completeAdminSemiAutoTask(
|
||||
taskId,
|
||||
{ costFen: 123, note: '事务重试成功' },
|
||||
session,
|
||||
)
|
||||
assert.equal(completed.task.executionStatus, 'completed')
|
||||
const afterCompletion = await query<{ snapshot_count: number; bill_count: number }>(
|
||||
`
|
||||
SELECT
|
||||
(SELECT COUNT(*)::int FROM cost_snapshots cs WHERE cs.order_item_id = $1) AS snapshot_count,
|
||||
(SELECT COUNT(*)::int FROM on_account_bills oab WHERE oab.order_item_id = $1) AS bill_count
|
||||
`,
|
||||
[orderItemId],
|
||||
)
|
||||
assert.equal(Number(afterCompletion.rows[0]?.snapshot_count), 1)
|
||||
assert.equal(Number(afterCompletion.rows[0]?.bill_count), 1)
|
||||
|
||||
await completeAdminSemiAutoTask(taskId, { costFen: 123 }, session)
|
||||
const afterDuplicate = await query<{ snapshot_count: number }>(
|
||||
'SELECT COUNT(*)::int AS snapshot_count FROM cost_snapshots WHERE order_item_id = $1',
|
||||
[orderItemId],
|
||||
)
|
||||
assert.equal(Number(afterDuplicate.rows[0]?.snapshot_count), 1)
|
||||
} finally {
|
||||
if (triggerCreated) {
|
||||
await query(`DROP TRIGGER IF EXISTS ${triggerName} ON semi_auto_tasks`)
|
||||
}
|
||||
await query(`DROP FUNCTION IF EXISTS ${functionName}()`)
|
||||
if (orderId > 0) {
|
||||
await query(
|
||||
'DELETE FROM cost_snapshots WHERE order_item_id IN (SELECT id FROM order_items WHERE order_id = $1)',
|
||||
[orderId],
|
||||
)
|
||||
await query(
|
||||
'DELETE FROM on_account_bills WHERE order_item_id IN (SELECT id FROM order_items WHERE order_id = $1)',
|
||||
[orderId],
|
||||
)
|
||||
await query(
|
||||
'DELETE FROM cost_exceptions WHERE subject_id IN (SELECT id FROM order_items WHERE order_id = $1)',
|
||||
[orderId],
|
||||
)
|
||||
await query('DELETE FROM orders WHERE id = $1', [orderId])
|
||||
}
|
||||
// 已过账总账凭证按财务规则不可物理删除;测试只清理可重建销售投影。
|
||||
await query(
|
||||
`DELETE FROM sales_daily_facts
|
||||
WHERE stat_date = (NOW() AT TIME ZONE 'Asia/Shanghai')::date`,
|
||||
)
|
||||
if (configId > 0) {
|
||||
await query('DELETE FROM manual_fulfillment_configs WHERE id = $1', [configId])
|
||||
}
|
||||
if (adminId > 0) {
|
||||
await query('DELETE FROM admin_users WHERE id = $1', [adminId])
|
||||
}
|
||||
await closeDb()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -61,6 +61,11 @@ export type RuntimeConfig = {
|
||||
claimBaseUrl: string
|
||||
tokenTtlHours: number
|
||||
}
|
||||
semiAuto: {
|
||||
connectorBaseUrl: string
|
||||
connectorApiKey: string
|
||||
connectorTimeoutMs: number
|
||||
}
|
||||
admin: {
|
||||
sessionSecret: string
|
||||
/** 后台空闲多久未访问即失效(每次访问滑动续期)。 */
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
import { getAdminRole, hasAdminSession } from '@/utils/admin-auth'
|
||||
import { isClaimPath, isH5ClaimPath } from '@/utils/claim-path'
|
||||
import { isCustomerFulfillmentPath } from '@/utils/customer-fulfillment-link'
|
||||
import {
|
||||
isLegacySupplementPath,
|
||||
isOrderServicePath,
|
||||
@@ -42,12 +43,16 @@ const AdminOrdersPage = lazy(() => import('@/pages/admin/AdminOrdersPage'))
|
||||
const AdminPlatformShopsPage = lazy(() => import('@/pages/admin/platform/AdminPlatformShopsPage'))
|
||||
const AdminTaskDetailPage = lazy(() => import('@/pages/admin/AdminTaskDetailPage'))
|
||||
const AdminTasksPage = lazy(() => import('@/pages/admin/AdminTasksPage'))
|
||||
const AdminSemiAutoTasksPage = lazy(() => import('@/pages/admin/AdminSemiAutoTasksPage'))
|
||||
const AdminUsersPage = lazy(() => import('@/pages/admin/AdminUsersPage'))
|
||||
const AdminDevMockPage = lazy(() => import('@/pages/admin/AdminDevMockPage'))
|
||||
const AdminWorkerPlatformPage = lazy(() => import('@/pages/admin/AdminWorkerPlatformPage'))
|
||||
const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage'))
|
||||
const H5ClaimEntryPage = lazy(() => import('@/pages/claim/H5ClaimEntryPage'))
|
||||
const OrderServicePage = lazy(() => import('@/pages/public/OrderServicePage'))
|
||||
const CustomerFulfillmentPage = lazy(
|
||||
() => import('@/pages/customer-fulfillment/CustomerFulfillmentPage'),
|
||||
)
|
||||
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
|
||||
const RedeemPage = lazy(() => import('@/pages/redeem/RedeemPage'))
|
||||
const WorkerHallPage = lazy(() => import('@/pages/worker/WorkerHallPage'))
|
||||
@@ -131,6 +136,10 @@ export default function App() {
|
||||
return <OrderServiceApplication />
|
||||
}
|
||||
|
||||
if (isCustomerFulfillmentPath(window.location.pathname)) {
|
||||
return <CustomerFulfillmentApplication />
|
||||
}
|
||||
|
||||
// 旧 /supplement 短期兼容:统一跳到 /o。
|
||||
if (isLegacySupplementPath(window.location.pathname)) {
|
||||
return <CollectRedirectApplication />
|
||||
@@ -158,6 +167,20 @@ function OrderServiceApplication() {
|
||||
)
|
||||
}
|
||||
|
||||
function CustomerFulfillmentApplication() {
|
||||
useClearStrayHash()
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<RouteLoading />}>
|
||||
<Routes>
|
||||
<Route path="/c/:orderNo" element={<CustomerFulfillmentPage />} />
|
||||
<Route path="*" element={<CustomerFulfillmentPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
function CollectRedirectApplication() {
|
||||
useEffect(() => {
|
||||
const orderNo = resolveLegacySupplementOrderNo(window.location.pathname)
|
||||
@@ -180,6 +203,7 @@ function AdminApplication() {
|
||||
<Route path="dashboard" element={<AdminDashboardPage />} />
|
||||
<Route path="tasks" element={<AdminTasksPage />} />
|
||||
<Route path="tasks/:taskId" element={<AdminTaskDetailPage />} />
|
||||
<Route path="semi-auto-tasks" element={<AdminSemiAutoTasksPage />} />
|
||||
</Route>
|
||||
<Route element={<RequireRole roles={['admin', 'operator', 'support', 'finance']} />}>
|
||||
<Route path="orders" element={<AdminOrdersPage />} />
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
OrderedListOutlined,
|
||||
RobotOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SettingOutlined,
|
||||
SoundOutlined,
|
||||
@@ -229,6 +230,11 @@ export default function AdminLayout() {
|
||||
label: '我的钱包',
|
||||
},
|
||||
{ key: '/admin/tasks', icon: <UnorderedListOutlined />, label: '任务' },
|
||||
{
|
||||
key: '/admin/semi-auto-tasks',
|
||||
icon: <RobotOutlined />,
|
||||
label: '半自动履约',
|
||||
},
|
||||
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '接单平台' },
|
||||
{
|
||||
key: '/admin/kuaishou-industry',
|
||||
@@ -246,6 +252,11 @@ export default function AdminLayout() {
|
||||
{ key: '/admin/orders', icon: <OrderedListOutlined />, label: '订单' },
|
||||
financeSubMenu,
|
||||
{ key: '/admin/tasks', icon: <UnorderedListOutlined />, label: '任务' },
|
||||
{
|
||||
key: '/admin/semi-auto-tasks',
|
||||
icon: <RobotOutlined />,
|
||||
label: '半自动履约',
|
||||
},
|
||||
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '接单平台' },
|
||||
{
|
||||
key: '/admin/kuaishou-industry',
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ChromeOutlined,
|
||||
CopyOutlined,
|
||||
ReloadOutlined,
|
||||
UserSwitchOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
Timeline,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import { uploadFile } from '@/services/files'
|
||||
import {
|
||||
cancelAdminSemiAutoTask,
|
||||
claimAdminSemiAutoTask,
|
||||
completeAdminSemiAutoTask,
|
||||
createAdminSemiAutoBrowserSession,
|
||||
fetchAdminSemiAutoTask,
|
||||
fetchAdminSemiAutoTasks,
|
||||
syncAdminSemiAutoTask,
|
||||
} from '@/services/admin/semi-auto'
|
||||
import type { SemiAutoTask } from '@/types/admin/semi-auto'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
waiting_customer: 'blue',
|
||||
waiting_login: 'blue',
|
||||
login_scanned: 'cyan',
|
||||
waiting_binding: 'gold',
|
||||
binding_confirm: 'orange',
|
||||
waiting_operator: 'volcano',
|
||||
processing: 'processing',
|
||||
completed: 'success',
|
||||
failed: 'error',
|
||||
cancelled: 'default',
|
||||
}
|
||||
|
||||
const PLATFORM_LABELS: Record<string, string> = {
|
||||
huya: '虎牙',
|
||||
weibo: '微博',
|
||||
}
|
||||
|
||||
type CompleteFormValues = {
|
||||
note?: string
|
||||
externalOrderNo?: string
|
||||
costYuan?: number
|
||||
}
|
||||
|
||||
export default function AdminSemiAutoTasksPage() {
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [platform, setPlatform] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [detailTaskId, setDetailTaskId] = useState<number | null>(null)
|
||||
const [completeTask, setCompleteTask] = useState<SemiAutoTask | null>(null)
|
||||
const [completeFiles, setCompleteFiles] = useState<UploadedFile[]>([])
|
||||
const [completeForm] = Form.useForm<CompleteFormValues>()
|
||||
|
||||
const params = useMemo(
|
||||
() => ({
|
||||
page,
|
||||
pageSize,
|
||||
keyword: keyword.trim() || undefined,
|
||||
platform: platform || undefined,
|
||||
status: status || undefined,
|
||||
}),
|
||||
[keyword, page, pageSize, platform, status],
|
||||
)
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ['admin-semi-auto-tasks', params],
|
||||
queryFn: () => fetchAdminSemiAutoTasks(params),
|
||||
refetchInterval: 3000,
|
||||
})
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ['admin-semi-auto-task', detailTaskId],
|
||||
queryFn: () => fetchAdminSemiAutoTask(Number(detailTaskId)),
|
||||
enabled: Boolean(detailTaskId),
|
||||
refetchInterval: detailTaskId ? 3000 : false,
|
||||
})
|
||||
const list = listQuery.data?.data?.items || []
|
||||
const pagination = listQuery.data?.data?.pagination
|
||||
const detail = detailQuery.data?.data
|
||||
|
||||
useEffect(() => {
|
||||
if (detailTaskId && listQuery.data && !list.some((item) => item.id === detailTaskId)) {
|
||||
setDetailTaskId(null)
|
||||
}
|
||||
}, [detailTaskId, list, listQuery.data])
|
||||
|
||||
useEffect(() => {
|
||||
const handleResult = (event: Event) => {
|
||||
const detail = (event as CustomEvent<string>).detail
|
||||
if (typeof detail !== 'string') return
|
||||
try {
|
||||
const result = JSON.parse(detail) as {
|
||||
ok?: boolean
|
||||
written?: number
|
||||
error?: string
|
||||
}
|
||||
if (!result || typeof result !== 'object') return
|
||||
if (result.ok) {
|
||||
message.success(`浏览器登录态已注入(${Number(result.written || 0)} 个 Cookie)`)
|
||||
} else {
|
||||
message.error(result.error || '浏览器登录态注入失败')
|
||||
}
|
||||
} catch {
|
||||
// 忽略其他页面事件
|
||||
}
|
||||
}
|
||||
window.addEventListener('huya-exchange-result', handleResult)
|
||||
return () => window.removeEventListener('huya-exchange-result', handleResult)
|
||||
}, [message])
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: (taskId: number) => syncAdminSemiAutoTask(taskId),
|
||||
onSuccess: () => {
|
||||
message.success('状态已刷新')
|
||||
void refreshAll()
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '刷新失败'),
|
||||
})
|
||||
|
||||
const claimMutation = useMutation({
|
||||
mutationFn: (taskId: number) => claimAdminSemiAutoTask(taskId),
|
||||
onSuccess: () => {
|
||||
message.success('任务已领取')
|
||||
void refreshAll()
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '领取失败'),
|
||||
})
|
||||
|
||||
const injectionMutation = useMutation({
|
||||
mutationFn: (task: SemiAutoTask) =>
|
||||
createAdminSemiAutoBrowserSession(task.id, firstTargetUrl(task)),
|
||||
onSuccess: (response) => {
|
||||
const ticket = String(response.data?.ticket || '').trim()
|
||||
if (!ticket) {
|
||||
message.error('注入票据生成失败')
|
||||
return
|
||||
}
|
||||
window.postMessage({ type: 'ORDER_SITE_SEMI_AUTO_INJECT', ticket }, window.location.origin)
|
||||
message.info('正在通过虎牙履约助手注入登录态,请稍候')
|
||||
void refreshAll()
|
||||
},
|
||||
onError: (error) =>
|
||||
message.error(error instanceof Error ? error.message : '打开浏览器登录态失败'),
|
||||
})
|
||||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: async (values: CompleteFormValues) => {
|
||||
if (!completeTask) throw new Error('任务不存在')
|
||||
const costFen =
|
||||
values.costYuan !== undefined && values.costYuan !== null
|
||||
? Math.round(Number(values.costYuan) * 100)
|
||||
: undefined
|
||||
return completeAdminSemiAutoTask(completeTask.id, {
|
||||
note: values.note || '',
|
||||
externalOrderNo: values.externalOrderNo || '',
|
||||
...(costFen !== undefined ? { costFen } : {}),
|
||||
screenshots: completeFiles.map((file) => ({ ...file })),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('半自动履约已完成')
|
||||
setCompleteTask(null)
|
||||
setCompleteFiles([])
|
||||
completeForm.resetFields()
|
||||
void refreshAll()
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '完成履约失败'),
|
||||
})
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: ({ taskId, reason }: { taskId: number; reason: string }) =>
|
||||
cancelAdminSemiAutoTask(taskId, reason),
|
||||
onSuccess: () => {
|
||||
message.success('任务已取消')
|
||||
void refreshAll()
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '取消失败'),
|
||||
})
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-semi-auto-tasks'] }),
|
||||
detailTaskId
|
||||
? queryClient.invalidateQueries({ queryKey: ['admin-semi-auto-task', detailTaskId] })
|
||||
: Promise.resolve(),
|
||||
])
|
||||
}
|
||||
|
||||
function copyCustomerLink(task: SemiAutoTask) {
|
||||
const url = `${window.location.origin}/c/${encodeURIComponent(task.orderNo)}`
|
||||
void navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => message.success('客户授权链接已复制'))
|
||||
.catch(() => message.error('复制失败,请手动复制'))
|
||||
}
|
||||
|
||||
function openComplete(task: SemiAutoTask) {
|
||||
setCompleteTask(task)
|
||||
setCompleteFiles([])
|
||||
completeForm.resetFields()
|
||||
}
|
||||
|
||||
function confirmCancel(task: SemiAutoTask) {
|
||||
let reason = ''
|
||||
modal.confirm({
|
||||
title: '取消半自动履约任务',
|
||||
content: (
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="请填写取消原因(可选)"
|
||||
onChange={(event) => {
|
||||
reason = event.target.value
|
||||
}}
|
||||
/>
|
||||
),
|
||||
okText: '确认取消',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '返回',
|
||||
onOk: () => cancelMutation.mutateAsync({ taskId: task.id, reason }),
|
||||
})
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<SemiAutoTask> = [
|
||||
{
|
||||
title: '订单 / 商品',
|
||||
key: 'order',
|
||||
width: 260,
|
||||
render: (_, task) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Text strong>{task.orderNo}</Text>
|
||||
<Text>{task.productName || task.skuName || task.skuCode}</Text>
|
||||
<Text type="secondary">SKU:{task.skuCode || '-'}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '平台',
|
||||
dataIndex: 'platform',
|
||||
width: 90,
|
||||
render: (value: string) => <Tag color="purple">{PLATFORM_LABELS[value] || value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '客户登录',
|
||||
key: 'login',
|
||||
width: 150,
|
||||
render: (_, task) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Tag>{task.displayStatus.label}</Tag>
|
||||
<Text type="secondary">{task.huyaUid ? `UID ${task.huyaUid}` : '未获取 UID'}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '绑定角色',
|
||||
key: 'binding',
|
||||
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">未绑定</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '客服',
|
||||
dataIndex: 'assignedAdminName',
|
||||
width: 110,
|
||||
render: (value: string) => value || '未领取',
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 155,
|
||||
render: (value: string) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 360,
|
||||
render: (_, task) => (
|
||||
<Space wrap>
|
||||
<Button size="small" onClick={() => setDetailTaskId(task.id)}>
|
||||
详情
|
||||
</Button>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyCustomerLink(task)}>
|
||||
客户链接
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={syncMutation.isPending}
|
||||
onClick={() => syncMutation.mutate(task.id)}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
{!task.assignedAdminUserId ? (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UserSwitchOutlined />}
|
||||
loading={claimMutation.isPending}
|
||||
onClick={() => claimMutation.mutate(task.id)}
|
||||
>
|
||||
领取
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ChromeOutlined />}
|
||||
disabled={task.bindingStatus !== 'confirmed'}
|
||||
loading={injectionMutation.isPending}
|
||||
onClick={() => injectionMutation.mutate(task)}
|
||||
>
|
||||
注入浏览器
|
||||
</Button>
|
||||
{task.executionStatus !== 'completed' && task.executionStatus !== 'cancelled' ? (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<CheckCircleOutlined />}
|
||||
disabled={task.bindingStatus !== 'confirmed'}
|
||||
onClick={() => openComplete(task)}
|
||||
>
|
||||
完成
|
||||
</Button>
|
||||
<Button size="small" danger onClick={() => confirmCancel(task)}>
|
||||
取消
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="半自动履约"
|
||||
description="人工客服渠道下的平台账号授权、扫码绑定和浏览器辅助履约。"
|
||||
/>
|
||||
<Card size="small">
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="订单号 / 商品 / UID / 角色"
|
||||
value={keyword}
|
||||
onChange={(event) => {
|
||||
setKeyword(event.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
style={{ width: 280 }}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部平台"
|
||||
value={platform || undefined}
|
||||
onChange={(value) => {
|
||||
setPlatform(value || '')
|
||||
setPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ value: 'huya', label: '虎牙' },
|
||||
{ value: 'weibo', label: '微博' },
|
||||
]}
|
||||
style={{ width: 130 }}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部状态"
|
||||
value={status || undefined}
|
||||
onChange={(value) => {
|
||||
setStatus(value || '')
|
||||
setPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ value: 'waiting_customer', label: '等待客户' },
|
||||
{ value: 'waiting_operator', label: '等待客服' },
|
||||
{ value: 'processing', label: '处理中' },
|
||||
{ value: 'completed', label: '已完成' },
|
||||
{ value: 'failed', label: '失败' },
|
||||
{ value: 'cancelled', label: '已取消' },
|
||||
]}
|
||||
style={{ width: 130 }}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void listQuery.refetch()}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
<PercentTable<SemiAutoTask>
|
||||
rowKey="id"
|
||||
loading={listQuery.isLoading}
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
scroll={{ x: 1400 }}
|
||||
pagination={{
|
||||
current: pagination?.page || page,
|
||||
pageSize: pagination?.pageSize || pageSize,
|
||||
total: pagination?.total || 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
open={Boolean(detailTaskId)}
|
||||
width={720}
|
||||
title={detail?.task ? `半自动履约 · ${detail.task.orderNo}` : '半自动履约详情'}
|
||||
onClose={() => setDetailTaskId(null)}
|
||||
>
|
||||
{detailQuery.isLoading ? (
|
||||
<Text type="secondary">加载中...</Text>
|
||||
) : detail?.task ? (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Descriptions bordered size="small" column={2}>
|
||||
<Descriptions.Item label="商品" span={2}>
|
||||
{detail.task.productName}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="平台">
|
||||
{PLATFORM_LABELS[detail.task.platform] || detail.task.platform}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="当前状态">
|
||||
<Tag color={STATUS_COLORS[detail.task.displayStatus.key] || 'default'}>
|
||||
{detail.task.displayStatus.label}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="虎牙 UID">{detail.task.huyaUid || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="平台昵称">
|
||||
{detail.task.platformNickname || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="游戏账号">
|
||||
{detail.task.bindGameAccount || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="游戏角色">
|
||||
{detail.task.bindGameRole || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="区服" span={2}>
|
||||
{detail.task.bindGameChannel || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="客服">
|
||||
{detail.task.assignedAdminName || '未领取'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">
|
||||
{formatAdminDateTime(detail.task.updatedAt)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{detail.task.lastError ? (
|
||||
<Card size="small" title="最近错误">
|
||||
<Text type="danger">{detail.task.lastError}</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
<Card size="small" title="操作">
|
||||
<Space wrap>
|
||||
<Button icon={<CopyOutlined />} onClick={() => copyCustomerLink(detail.task)}>
|
||||
复制客户链接
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={syncMutation.isPending}
|
||||
onClick={() => syncMutation.mutate(detail.task.id)}
|
||||
>
|
||||
刷新客户状态
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ChromeOutlined />}
|
||||
disabled={detail.task.bindingStatus !== 'confirmed'}
|
||||
loading={injectionMutation.isPending}
|
||||
onClick={() => injectionMutation.mutate(detail.task)}
|
||||
>
|
||||
注入浏览器
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card size="small" title="时间线">
|
||||
<Timeline
|
||||
items={(detail.events || []).map((event) => ({
|
||||
color: event.eventType.includes('failed') ? 'red' : 'blue',
|
||||
children: (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Text strong>{event.eventType}</Text>
|
||||
<Text type="secondary">
|
||||
{formatAdminDateTime(event.createdAt)} · {event.actorType}
|
||||
{event.actorId ? ` ${event.actorId}` : ''}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
) : (
|
||||
<Text type="secondary">暂无任务详情</Text>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
open={Boolean(completeTask)}
|
||||
title="确认完成半自动履约"
|
||||
okText="确认完成"
|
||||
cancelText="取消"
|
||||
confirmLoading={completeMutation.isPending}
|
||||
onCancel={() => {
|
||||
setCompleteTask(null)
|
||||
setCompleteFiles([])
|
||||
completeForm.resetFields()
|
||||
}}
|
||||
onOk={() => {
|
||||
void completeForm.validateFields().then((values) => completeMutation.mutate(values))
|
||||
}}
|
||||
>
|
||||
<Form<CompleteFormValues> form={completeForm} layout="vertical">
|
||||
<Form.Item name="externalOrderNo" label="外部订单号 / 流水号(可选)">
|
||||
<Input maxLength={200} />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="备注(可选)">
|
||||
<Input.TextArea rows={3} maxLength={1000} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="costYuan"
|
||||
label="本次实际成本(元,可选)"
|
||||
extra="成本不固定的 SKU 可在这里填写;固定成本不需要填写。"
|
||||
>
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="完成截图(可选)">
|
||||
<ImageUpload
|
||||
value={completeFiles}
|
||||
onChange={setCompleteFiles}
|
||||
scene="semi-auto-evidence"
|
||||
scope="admin"
|
||||
maxCount={6}
|
||||
showPasteHint
|
||||
captureGlobalPaste
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function firstTargetUrl(task: SemiAutoTask) {
|
||||
const targets = Array.isArray(task.semiAutoConfig?.browserTargets)
|
||||
? task.semiAutoConfig.browserTargets
|
||||
: []
|
||||
return String(targets.find((item) => item?.url)?.url || '')
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Statistic,
|
||||
Tag,
|
||||
Typography,
|
||||
@@ -65,6 +66,14 @@ type ConfigFormValues = {
|
||||
creditQuantity: number
|
||||
}>
|
||||
note?: string
|
||||
semiAutoEnabled?: boolean
|
||||
semiAutoPlatform?: '' | 'huya' | 'weibo'
|
||||
semiAutoConfig?: {
|
||||
pageTitle?: string
|
||||
pageDescription?: string
|
||||
loginInstructions?: string
|
||||
bindingInstructions?: string
|
||||
}
|
||||
}
|
||||
|
||||
const PLATFORM_TAG_COLORS: Record<string, string> = {
|
||||
@@ -142,6 +151,9 @@ export function ManualSkuConfigPanel() {
|
||||
creditQuantity: Number(channel.creditQuantity || 0),
|
||||
})),
|
||||
note: values.note || '',
|
||||
semiAutoEnabled: values.semiAutoEnabled === true,
|
||||
semiAutoPlatform: values.semiAutoEnabled ? values.semiAutoPlatform || '' : '',
|
||||
semiAutoConfig: values.semiAutoConfig || {},
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
const summary = response.data.syncSummary
|
||||
@@ -201,6 +213,9 @@ export function ManualSkuConfigPanel() {
|
||||
creditQuantity: channel.creditQuantity,
|
||||
})),
|
||||
note: item.config?.note || '',
|
||||
semiAutoEnabled: item.config?.semiAutoEnabled === true,
|
||||
semiAutoPlatform: item.config?.semiAutoPlatform || '',
|
||||
semiAutoConfig: item.config?.semiAutoConfig || {},
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
@@ -212,6 +227,9 @@ export function ManualSkuConfigPanel() {
|
||||
skuKey: skuKey.trim(),
|
||||
channels: [{ platformKey: '', creditQuantity: 0 }],
|
||||
note: '',
|
||||
semiAutoEnabled: false,
|
||||
semiAutoPlatform: '',
|
||||
semiAutoConfig: {},
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
@@ -636,6 +654,54 @@ export function ManualSkuConfigPanel() {
|
||||
</Space>
|
||||
)}
|
||||
</Form.List>
|
||||
<Card size="small" title="半自动履约(可选)" style={{ marginTop: 14 }}>
|
||||
<Form.Item
|
||||
name="semiAutoEnabled"
|
||||
label="启用半自动履约"
|
||||
valuePropName="checked"
|
||||
extra="订单进入人工渠道后,客户可通过 /c/订单号 登录平台并完成绑定。"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{() =>
|
||||
form.getFieldValue('semiAutoEnabled') ? (
|
||||
<>
|
||||
<Form.Item
|
||||
name="semiAutoPlatform"
|
||||
label="目标平台"
|
||||
rules={[{ required: true, message: '请选择目标平台' }]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'huya', label: '虎牙' },
|
||||
{ value: 'weibo', label: '微博(预留)' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name={['semiAutoConfig', 'pageTitle']} label="客户页标题">
|
||||
<Input maxLength={60} placeholder="例如:虎牙账号授权" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name={['semiAutoConfig', 'pageDescription']} label="客户页说明">
|
||||
<Input maxLength={500} placeholder="展示在客户链接顶部" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<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
|
||||
}
|
||||
</Form.Item>
|
||||
</Card>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={300} showCount />
|
||||
</Form.Item>
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
LoadingOutlined,
|
||||
QrcodeOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Image,
|
||||
Result,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Steps,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import QRCode from 'qrcode'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams } from 'react-router'
|
||||
|
||||
import {
|
||||
confirmCustomerBinding,
|
||||
fetchCustomerFulfillment,
|
||||
markCustomerFulfillmentOpened,
|
||||
pollCustomerBinding,
|
||||
pollCustomerLogin,
|
||||
startCustomerBinding,
|
||||
startCustomerLogin,
|
||||
} from '@/services/customer-fulfillment'
|
||||
import type {
|
||||
CustomerBindingInfo,
|
||||
CustomerLoginInfo,
|
||||
CustomerSemiAutoTask,
|
||||
} from '@/types/customer-fulfillment'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
const { Text, Title, Paragraph } = Typography
|
||||
|
||||
const POLL_INTERVAL_MS = 2000
|
||||
|
||||
export default function CustomerFulfillmentPage() {
|
||||
const { message } = App.useApp()
|
||||
const { orderNo: routeOrderNo } = useParams<{ orderNo?: string }>()
|
||||
const orderNo = String(routeOrderNo || '').trim()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
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 startingRef = useRef(false)
|
||||
|
||||
const selectedTask = useMemo(
|
||||
() => tasks.find((task) => task.id === selectedTaskId) || tasks[0] || null,
|
||||
[selectedTaskId, tasks],
|
||||
)
|
||||
|
||||
const load = useCallback(
|
||||
async (silent = false) => {
|
||||
if (!orderNo) {
|
||||
setLoadError('订单号无效,请确认订单号是否正确')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (!silent) setLoading(true)
|
||||
try {
|
||||
const response = await fetchCustomerFulfillment(orderNo)
|
||||
const nextTasks = response.data || []
|
||||
setTasks(nextTasks)
|
||||
setLoadError('')
|
||||
setSelectedTaskId((current) => {
|
||||
if (nextTasks.some((task) => task.id === current)) return current
|
||||
return nextTasks[0]?.id || 0
|
||||
})
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
setTasks([])
|
||||
setLoadError(error instanceof Error ? error.message : '读取订单履约信息失败')
|
||||
}
|
||||
} finally {
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
},
|
||||
[orderNo],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTask) return
|
||||
void markCustomerFulfillmentOpened(orderNo, selectedTask.id).catch(() => undefined)
|
||||
}, [orderNo, selectedTask?.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loginInfo?.qrContent) {
|
||||
setLoginQrImage('')
|
||||
return
|
||||
}
|
||||
void QRCode.toDataURL(loginInfo.qrContent, {
|
||||
width: 300,
|
||||
margin: 2,
|
||||
color: { dark: '#111827', light: '#ffffff' },
|
||||
}).then(setLoginQrImage)
|
||||
}, [loginInfo?.qrContent])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTask || !orderNo) return
|
||||
if (!['qr_pending', 'scanned'].includes(selectedTask.loginStatus)) return
|
||||
const tick = async () => {
|
||||
try {
|
||||
const response = await pollCustomerLogin(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.login) setLoginInfo(response.data.login)
|
||||
} catch {
|
||||
// 短暂轮询失败不打断客户页面,下一轮继续。
|
||||
}
|
||||
}
|
||||
const timer = window.setInterval(() => {
|
||||
void tick()
|
||||
}, POLL_INTERVAL_MS)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [orderNo, selectedTask?.id, selectedTask?.loginStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTask || !orderNo) 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])
|
||||
|
||||
async function handleStartLogin() {
|
||||
if (!selectedTask) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await startCustomerLogin(orderNo, selectedTask.id)
|
||||
setTasks((current) =>
|
||||
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 {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmBinding() {
|
||||
if (!selectedTask) return
|
||||
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 : '确认绑定失败')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="customer-fulfillment-page customer-fulfillment-centered">
|
||||
<Spin indicator={<LoadingOutlined spin />} tip="正在读取订单履约信息..." />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadError || tasks.length === 0) {
|
||||
return (
|
||||
<div className="customer-fulfillment-page customer-fulfillment-centered">
|
||||
<Result
|
||||
status="warning"
|
||||
title="暂时无法打开订单"
|
||||
subTitle={loadError || '该订单没有需要客户授权或绑定的半自动履约任务。'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedTask?.executionStatus === 'completed') {
|
||||
return (
|
||||
<div className="customer-fulfillment-page">
|
||||
<Result
|
||||
status="success"
|
||||
icon={<CheckCircleFilled />}
|
||||
title="履约已完成"
|
||||
subTitle={
|
||||
selectedTask.completionNote
|
||||
? selectedTask.completionNote
|
||||
: '客服已完成本订单的充值或赠送操作,请到平台账号中确认。'
|
||||
}
|
||||
extra={
|
||||
selectedTask.completedAt
|
||||
? `完成时间:${formatAdminDateTime(selectedTask.completedAt)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const config = selectedTask?.semiAutoConfig || {}
|
||||
const title = config.pageTitle || '账号授权与角色绑定'
|
||||
const description =
|
||||
config.pageDescription || '请按页面指引完成平台登录和游戏角色绑定,客服随后会处理您的订单。'
|
||||
const currentStep = resolveStep(selectedTask, bindingInfo)
|
||||
const loginQrVisible =
|
||||
selectedTask && ['qr_pending', 'scanned'].includes(selectedTask.loginStatus)
|
||||
const bindingQrVisible =
|
||||
selectedTask?.loginStatus === 'authenticated' &&
|
||||
['qr_pending', 'scanned', 'role_ready'].includes(selectedTask.bindingStatus)
|
||||
|
||||
return (
|
||||
<div className="customer-fulfillment-page">
|
||||
<Space direction="vertical" size={18} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Title level={2} style={{ marginBottom: 4 }}>
|
||||
{title}
|
||||
</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
{description}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{tasks.length > 1 ? (
|
||||
<Card size="small" title="请选择需要处理的产品">
|
||||
<Space wrap>
|
||||
{tasks.map((task) => (
|
||||
<Button
|
||||
key={task.id}
|
||||
type={task.id === selectedTask?.id ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setSelectedTaskId(task.id)
|
||||
setLoginInfo(null)
|
||||
setBindingInfo(null)
|
||||
}}
|
||||
>
|
||||
{task.productName || task.skuName} × {task.quantity}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<Row gutter={[24, 20]}>
|
||||
<Col xs={24} lg={8}>
|
||||
<Space direction="vertical" size={10}>
|
||||
<Text type="secondary">订单号</Text>
|
||||
<Text strong style={{ fontSize: 18 }}>
|
||||
{selectedTask?.orderNo}
|
||||
</Text>
|
||||
<Text type="secondary">商品</Text>
|
||||
<Text strong>{selectedTask?.productName || selectedTask?.skuName}</Text>
|
||||
<Tag color="purple">{selectedTask?.platformLabel || selectedTask?.platform}</Tag>
|
||||
<Tag color="blue">{selectedTask?.displayStatus.label}</Tag>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} lg={16}>
|
||||
<Steps
|
||||
current={currentStep}
|
||||
items={[
|
||||
{ title: '登录平台', icon: <QrcodeOutlined /> },
|
||||
{ title: '扫码绑定', icon: <SafetyCertificateOutlined /> },
|
||||
{ title: '客服处理', icon: <LoadingOutlined /> },
|
||||
{ title: '完成' },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{selectedTask && selectedTask.executionStatus === 'failed' ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="当前流程需要客服处理"
|
||||
description={selectedTask.displayStatus.label}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{selectedTask?.loginStatus === 'waiting_login' ||
|
||||
selectedTask?.loginStatus === 'failed' ||
|
||||
selectedTask?.loginStatus === 'expired' ? (
|
||||
<Card title="第一步:登录平台账号">
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="请使用本人账号登录"
|
||||
description={
|
||||
config.loginInstructions ||
|
||||
'点击下方按钮后使用虎牙 App 扫码登录。登录仅用于本次订单履约,不会在页面展示账号 Cookie。'
|
||||
}
|
||||
/>
|
||||
<Button type="primary" size="large" onClick={() => void handleStartLogin()}>
|
||||
获取登录二维码
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{loginQrVisible ? (
|
||||
<Card title="使用虎牙 App 扫码登录">
|
||||
<Space direction="vertical" align="center" size={14} style={{ width: '100%' }}>
|
||||
{loginQrImage ? (
|
||||
<Image src={loginQrImage} alt="虎牙登录二维码" width={260} preview={false} />
|
||||
) : (
|
||||
<Spin tip="生成二维码..." />
|
||||
)}
|
||||
<Text type="secondary">{loginInfo?.message || '等待扫码'}</Text>
|
||||
{selectedTask.loginStatus === 'scanned' ? (
|
||||
<Tag color="cyan">已扫码,请在 App 确认</Tag>
|
||||
) : null}
|
||||
<Button onClick={() => void handleStartLogin()}>重新获取二维码</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{bindingQrVisible ? (
|
||||
<Card title="第二步:扫码绑定游戏角色">
|
||||
<Space direction="vertical" align="center" size={14} style={{ width: '100%' }}>
|
||||
{bindingInfo?.qrImage ? (
|
||||
<Image src={bindingInfo.qrImage} alt="游戏角色绑定二维码" width={260} />
|
||||
) : (
|
||||
<Spin tip="生成绑定二维码..." />
|
||||
)}
|
||||
<Text type="secondary">
|
||||
{bindingInfo?.message ||
|
||||
config.bindingInstructions ||
|
||||
'请使用虎牙 App 扫码完成游戏角色绑定。'}
|
||||
</Text>
|
||||
{bindingInfo?.bindGameRole ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
message={`已识别角色:${bindingInfo.bindGameRole}`}
|
||||
description={[bindingInfo.bindGameAccount, bindingInfo.bindGameChannel]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
/>
|
||||
) : null}
|
||||
{selectedTask.bindingStatus === 'role_ready' ? (
|
||||
<Button type="primary" onClick={() => void handleConfirmBinding()}>
|
||||
确认绑定角色
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{selectedTask?.bindingStatus === 'confirmed' &&
|
||||
selectedTask.executionStatus !== 'completed' ? (
|
||||
<Card>
|
||||
<Result
|
||||
status="info"
|
||||
title="账号已绑定,客服正在处理"
|
||||
subTitle="客服会使用本次授权完成充值或赠送,请稍后刷新页面查看结果。"
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveStep(task: CustomerSemiAutoTask | null, binding: CustomerBindingInfo | null) {
|
||||
if (!task) return 0
|
||||
if (task.executionStatus === 'completed') return 3
|
||||
if (task.bindingStatus === 'confirmed') return 2
|
||||
if (task.loginStatus === 'authenticated' || binding) return 1
|
||||
return 0
|
||||
}
|
||||
@@ -362,6 +362,9 @@ export function saveAdminManualConfig(payload: {
|
||||
canonicalProductId?: number | null
|
||||
enabled?: boolean
|
||||
note?: string
|
||||
semiAutoEnabled?: boolean
|
||||
semiAutoPlatform?: '' | 'huya' | 'weibo'
|
||||
semiAutoConfig?: Record<string, unknown>
|
||||
}) {
|
||||
return apiPost<ManualFulfillmentConfig>('/api/v1/admin/cost-center/manual-configs', payload)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type { SemiAutoTaskDetail, SemiAutoTaskListResult } from '@/types/admin/semi-auto'
|
||||
|
||||
export function fetchAdminSemiAutoTasks(params: Record<string, unknown> = {}) {
|
||||
return apiGet<SemiAutoTaskListResult>('/api/v1/admin/semi-auto/tasks', params)
|
||||
}
|
||||
|
||||
export function fetchAdminSemiAutoTask(taskId: number) {
|
||||
return apiGet<SemiAutoTaskDetail>(`/api/v1/admin/semi-auto/tasks/${taskId}`)
|
||||
}
|
||||
|
||||
export function claimAdminSemiAutoTask(taskId: number) {
|
||||
return apiPost<SemiAutoTaskDetail>(`/api/v1/admin/semi-auto/tasks/${taskId}/claim`, {})
|
||||
}
|
||||
|
||||
export function syncAdminSemiAutoTask(taskId: number) {
|
||||
return apiPost<SemiAutoTaskDetail>(`/api/v1/admin/semi-auto/tasks/${taskId}/sync`, {})
|
||||
}
|
||||
|
||||
export function createAdminSemiAutoBrowserSession(taskId: number, targetUrl?: string) {
|
||||
return apiPost<{ ticket: string; expiresAt: string; targetUrl: string }>(
|
||||
`/api/v1/admin/semi-auto/tasks/${taskId}/browser-session`,
|
||||
{ targetUrl: targetUrl || '' },
|
||||
)
|
||||
}
|
||||
|
||||
export function completeAdminSemiAutoTask(
|
||||
taskId: number,
|
||||
payload: {
|
||||
note?: string
|
||||
externalOrderNo?: string
|
||||
costFen?: number
|
||||
screenshots?: Array<Record<string, unknown>>
|
||||
},
|
||||
) {
|
||||
return apiPost<SemiAutoTaskDetail>(`/api/v1/admin/semi-auto/tasks/${taskId}/complete`, payload)
|
||||
}
|
||||
|
||||
export function cancelAdminSemiAutoTask(taskId: number, reason: string) {
|
||||
return apiPost<SemiAutoTaskDetail>(`/api/v1/admin/semi-auto/tasks/${taskId}/cancel`, { reason })
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
CustomerBindingInfo,
|
||||
CustomerLoginInfo,
|
||||
CustomerSemiAutoTask,
|
||||
} from '@/types/customer-fulfillment'
|
||||
|
||||
export function fetchCustomerFulfillment(orderNo: string) {
|
||||
return apiGet<CustomerSemiAutoTask[]>(
|
||||
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function markCustomerFulfillmentOpened(orderNo: string, taskId: number) {
|
||||
return apiPost<{ ok: boolean }>(
|
||||
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/open`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
export function startCustomerLogin(orderNo: string, taskId: number) {
|
||||
return apiPost<{ task: CustomerSemiAutoTask; login: CustomerLoginInfo }>(
|
||||
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/login/start`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
export function pollCustomerLogin(orderNo: string, taskId: number) {
|
||||
return apiGet<{ task: CustomerSemiAutoTask; login: CustomerLoginInfo | null }>(
|
||||
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/login/status`,
|
||||
)
|
||||
}
|
||||
|
||||
export function startCustomerBinding(orderNo: string, taskId: number) {
|
||||
return apiPost<{ task: CustomerSemiAutoTask; binding: CustomerBindingInfo }>(
|
||||
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/binding/start`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
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`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
@@ -1435,3 +1435,26 @@
|
||||
max-width: 116px;
|
||||
}
|
||||
}
|
||||
|
||||
.customer-fulfillment-page {
|
||||
min-height: 100vh;
|
||||
padding: clamp(18px, 3vw, 42px) clamp(14px, 4vw, 64px);
|
||||
background:
|
||||
radial-gradient(circle at 10% 0%, rgba(249, 115, 22, 0.14), transparent 30%),
|
||||
linear-gradient(180deg, #fffaf5 0%, #f8fafc 45%, #f1f5f9 100%);
|
||||
}
|
||||
|
||||
.customer-fulfillment-page > * {
|
||||
width: min(1080px, 100%);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.customer-fulfillment-centered {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.customer-fulfillment-centered .ant-result,
|
||||
.customer-fulfillment-centered .ant-spin-nested-loading {
|
||||
width: min(720px, 100%);
|
||||
}
|
||||
|
||||
@@ -430,6 +430,15 @@ export interface ManualFulfillmentConfig {
|
||||
/** 平台换算率(分/积分):南航/乐玩 1,ROCK 100(1 积分 = 1 元)。 */
|
||||
creditUnitPriceFen: number
|
||||
enabled: boolean
|
||||
semiAutoEnabled: boolean
|
||||
semiAutoPlatform: '' | 'huya' | 'weibo'
|
||||
semiAutoConfig: {
|
||||
pageTitle?: string
|
||||
pageDescription?: string
|
||||
loginInstructions?: string
|
||||
bindingInstructions?: string
|
||||
browserTargets?: Array<{ key: string; label: string; url: string }>
|
||||
}
|
||||
note?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
export type SemiAutoPlatform = 'huya' | 'weibo'
|
||||
|
||||
export type SemiAutoDisplayStatus = {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type SemiAutoTask = {
|
||||
id: number
|
||||
orderId: number
|
||||
orderItemId: number
|
||||
fulfillmentId: number | null
|
||||
platform: SemiAutoPlatform
|
||||
platformLabel: string
|
||||
loginStatus: string
|
||||
bindingStatus: string
|
||||
executionStatus: string
|
||||
displayStatus: SemiAutoDisplayStatus
|
||||
connectorReady: boolean
|
||||
huyaUid: string
|
||||
platformNickname: string
|
||||
bindGameAccount: string
|
||||
bindGameRole: string
|
||||
bindGameChannel: string
|
||||
targetQuantity: number
|
||||
completedQuantity: number
|
||||
assignedAdminUserId: number | null
|
||||
assignedAdminName: string
|
||||
customerLinkOpenedAt: string | null
|
||||
authStartedAt: string | null
|
||||
authenticatedAt: string | null
|
||||
bindingStartedAt: string | null
|
||||
boundAt: string | null
|
||||
completedAt: string | null
|
||||
completionNote: string
|
||||
externalOrderNo: string
|
||||
completionEvidence: Record<string, unknown>
|
||||
lastError: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
orderNo: string
|
||||
provider: string
|
||||
shopId: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
productName: string
|
||||
quantity: number
|
||||
semiAutoConfig: {
|
||||
pageTitle?: string
|
||||
pageDescription?: string
|
||||
loginInstructions?: string
|
||||
bindingInstructions?: string
|
||||
browserTargets?: Array<{ key: string; label: string; url: string }>
|
||||
}
|
||||
}
|
||||
|
||||
export type SemiAutoTaskEvent = {
|
||||
id: number
|
||||
eventType: string
|
||||
actorType: string
|
||||
actorId: string
|
||||
detail: Record<string, unknown>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type SemiAutoTaskListResult = {
|
||||
items: SemiAutoTask[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}
|
||||
|
||||
export type SemiAutoTaskDetail = {
|
||||
task: SemiAutoTask
|
||||
events: SemiAutoTaskEvent[]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export type CustomerSemiAutoTask = {
|
||||
id: number
|
||||
platform: 'huya' | 'weibo'
|
||||
platformLabel: string
|
||||
orderNo: string
|
||||
productName: string
|
||||
skuName: string
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export type CustomerLoginInfo = {
|
||||
status: string
|
||||
qrContent: string
|
||||
expiresAt: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type CustomerBindingInfo = {
|
||||
status: string
|
||||
qrImage: string
|
||||
qrExpiresAt: string
|
||||
bindGameAccount: string
|
||||
bindGameRole: string
|
||||
bindGameChannel: string
|
||||
message: string
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** 客户半自动履约入口:/c/:orderNo。 */
|
||||
export function isCustomerFulfillmentPath(pathname: unknown): boolean {
|
||||
const normalized = String(pathname || '').trim()
|
||||
if (!normalized.startsWith('/')) return false
|
||||
return normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean)[0] === 'c'
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { isCustomerFulfillmentPath } from '../src/utils/customer-fulfillment-link.ts'
|
||||
|
||||
test('客户半自动履约入口:/c 命名空间整段命中', () => {
|
||||
assert.equal(isCustomerFulfillmentPath('/c/263123'), true)
|
||||
assert.equal(isCustomerFulfillmentPath('/c'), true)
|
||||
assert.equal(isCustomerFulfillmentPath('/c/a/b'), true)
|
||||
})
|
||||
|
||||
test('客户半自动履约入口不误伤相似前缀和其他路径', () => {
|
||||
assert.equal(isCustomerFulfillmentPath('/claim/x'), false)
|
||||
assert.equal(isCustomerFulfillmentPath('/customer/x'), false)
|
||||
assert.equal(isCustomerFulfillmentPath('/admin/c/1'), false)
|
||||
assert.equal(isCustomerFulfillmentPath('c/1'), false)
|
||||
})
|
||||
+14
-4
@@ -322,10 +322,20 @@ wait_for_health() {
|
||||
print_applied_migrations() {
|
||||
log "已应用数据库迁移"
|
||||
|
||||
local sql migrations
|
||||
sql='SELECT COALESCE(filename, name) FROM schema_migrations ORDER BY COALESCE(filename, name);'
|
||||
|
||||
if migrations="$("${COMPOSE_CMD[@]}" exec -T postgres sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "$1"' sh "${sql}" 2>&1)"; then
|
||||
local migrations
|
||||
# 必须从后端真实 DATABASE_URL 读取,不能固定用 postgres 容器的 POSTGRES_DB:
|
||||
# 本地测试库可能通过 DATABASE_URL 指向独立数据库(例如 order_site_test)。
|
||||
if migrations="$("${COMPOSE_CMD[@]}" exec -T backend node --import tsx --input-type=module -e '
|
||||
const { query, closeDb } = await import("./src/db/client.ts");
|
||||
try {
|
||||
const result = await query(
|
||||
"SELECT COALESCE(filename, name) AS name FROM schema_migrations ORDER BY COALESCE(filename, name)"
|
||||
);
|
||||
console.log(result.rows.map((row) => row.name).join("\n"));
|
||||
} finally {
|
||||
await closeDb();
|
||||
}
|
||||
' 2>&1)"; then
|
||||
printf '%s\n' "${migrations:-"(无迁移记录)"}"
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -118,6 +118,9 @@ services:
|
||||
WORKSPACE_ROOT: /workspace
|
||||
# ADMIN_* 仅走 env_file,避免 JSON 经 ${} 注入时被 YAML 破坏
|
||||
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}
|
||||
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}
|
||||
logging: *json-log-rotation
|
||||
volumes:
|
||||
- ./apps/backend:/app
|
||||
|
||||
@@ -137,6 +137,9 @@ services:
|
||||
# ADMIN_SESSION_SECRET / ADMIN_DEFAULT_USERS_JSON 仅走 env_file。
|
||||
# 勿在此处用 ${ADMIN_DEFAULT_USERS_JSON} 再注入:JSON 会被 YAML 解析破坏,导致生产校验失败。
|
||||
KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120}
|
||||
SEMI_AUTO_CONNECTOR_BASE_URL: ${SEMI_AUTO_CONNECTOR_BASE_URL:-}
|
||||
SEMI_AUTO_CONNECTOR_API_KEY: ${SEMI_AUTO_CONNECTOR_API_KEY:-}
|
||||
SEMI_AUTO_CONNECTOR_TIMEOUT_MS: ${SEMI_AUTO_CONNECTOR_TIMEOUT_MS:-15000}
|
||||
logging: *json-log-rotation
|
||||
volumes:
|
||||
- ./apps/backend/data:/app/data
|
||||
|
||||
+56
-5
@@ -3,7 +3,7 @@ type: 权威架构与实施计划
|
||||
description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。
|
||||
tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移]
|
||||
status: authoritative
|
||||
version: 2.5
|
||||
version: 2.7
|
||||
updated_at: 2026-11-09
|
||||
---
|
||||
|
||||
@@ -12,7 +12,7 @@ updated_at: 2026-11-09
|
||||
> 状态:**唯一权威文档**。
|
||||
> 最后校准:2026-11-09。
|
||||
> 生产基线:迁移 `151`。
|
||||
> 开发基线:迁移 `152`(仅本地,尚未部署生产)。
|
||||
> 开发基线:迁移 `153`(仅本地,尚未部署生产)。
|
||||
>
|
||||
> 从本文件生效起,订单、内部 SKU、履约渠道、履约载体、标准商品、成本和财务相关实现,
|
||||
> 只能以本文件为业务口径。其他旧分析只作为历史资料,不再具备设计或验收效力。
|
||||
@@ -269,6 +269,36 @@ fulfillment_issues
|
||||
|
||||
同一订单项只能有一个当前尝试。历史尝试只追加,不覆盖。
|
||||
|
||||
### 3.4.1 人工渠道半自动履约
|
||||
|
||||
半自动履约不是新的履约渠道,而是 `manual` 渠道下的辅助执行域:
|
||||
|
||||
```text
|
||||
manual fulfillment
|
||||
→ semi_auto_tasks
|
||||
→ 平台登录 / 角色绑定 / 浏览器辅助操作
|
||||
→ confirmManualFulfillment
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `fulfillments.channel` 仍为 `manual`;成本、销售与总账口径不变。
|
||||
- 是否启用半自动履约由 `manual_fulfillment_configs.semi_auto_*` 显式配置,不能按商品名或平台前缀猜测。
|
||||
- 半自动任务必须绑定 `order_item_id`、`fulfillments.id` 和人工履约配置,不得伪造接单平台工单。
|
||||
- 登录状态、绑定状态、执行状态必须正交保存于 `semi_auto_tasks`;不得写入 `material_json` 作为正式状态。
|
||||
- 平台 CK 只能由外部 Connector 的短期凭证域托管;order_site 不保存、不返回、不记录原始 CK。
|
||||
第一版 Connector 为单进程内存会话,重启即失效;多实例部署必须迁移到 Redis 或加密凭证存储。
|
||||
- 虎牙二维码登录有效期 `5` 分钟;登录成功后的 CK 会话有效期 `24` 小时;角色绑定二维码 `5` 分钟;
|
||||
浏览器一次性注入票据 `2` 分钟且消费后立即失效。重新登录前必须撤销旧 Connector 会话。
|
||||
- 浏览器扩展复用 live-hub-py 的 `web/extension/huya-exchange-helper`,不新增第二个扩展;
|
||||
扩展只允许消费一次性注入票据,页面不得直接读取 CK。
|
||||
- 客服完成半自动任务必须与 `confirmManualFulfillment` 在同一数据库事务中提交:
|
||||
成本快照、钱包扣减、履约状态和 `semi_auto_tasks` 完成状态必须全部成功或全部回滚;
|
||||
事务提交后才执行报表刷新、Connector 会话撤销等旁路动作。
|
||||
- 后台“任务 → 半自动履约”列表读取前必须执行幂等任务回填,不能依赖客户先打开 `/c/<订单号>`。
|
||||
- 完成截图可选;操作人、操作时间、外部单号和成本确认必须留痕。
|
||||
- 客户统一入口为 `/c/<平台订单号>`,它是入口而非安全授权凭据;高价值或全自动场景必须增加二次验证,不能仅凭订单号自动履约。
|
||||
|
||||
### 3.5 商品与财务域
|
||||
|
||||
标准商品目标结构:
|
||||
@@ -320,6 +350,8 @@ finance_journal_* -- 总账凭证,可重建
|
||||
| 当前履约状态 | 履约状态服务 | `fulfillments` | 多服务直接更新订单项 |
|
||||
| 执行尝试 | 履约尝试服务 | `fulfillment_attempts` | 用最大 ID 猜当前任务 |
|
||||
| 当前异常 | 履约异常服务 | `fulfillment_issues` | 自由 JSON 字符串代替问题模型 |
|
||||
| 半自动任务 | 半自动任务服务 | `semi_auto_tasks` / `semi_auto_task_events` | 把登录/绑定/执行状态写进 `material_json` |
|
||||
| 客户平台 CK | 外部平台 Connector | Connector 短期凭证域 | order_site 或浏览器页面持久化原始 CK |
|
||||
| 标准商品 | 商品主数据服务 | `product_catalog` | 用商品来源反推渠道 |
|
||||
| 平台商品到财务商品 | 商品归一服务 | `channel_product_mappings` | 人工在多个页面重复维护 |
|
||||
| 成本规则 | 成本中心 | `cost_versions` | 业务表直接改金额 |
|
||||
@@ -476,6 +508,22 @@ current_status
|
||||
has_active_cost
|
||||
```
|
||||
|
||||
### 7.1 半自动履约入口
|
||||
|
||||
管理后台“任务 → 半自动履约”读取 `semi_auto_tasks`,平台作为扩展维度:
|
||||
|
||||
```text
|
||||
全部 / 虎牙 / 微博 / ...
|
||||
```
|
||||
|
||||
客户页 `/c/<平台订单号>` 统一展示:
|
||||
|
||||
```text
|
||||
平台登录 → 角色绑定 → 客服处理 → 完成
|
||||
```
|
||||
|
||||
页面只能展示客户完成操作所需的最小信息,不得展示 CK、内部成本或其他订单。后台不得展示原始 CK,只展示平台 UID、绑定角色、凭证有效性和一次性注入入口。
|
||||
|
||||
## 八、存量数据处理方案
|
||||
|
||||
### 8.1 已被其他渠道接管的 `man:` 商品
|
||||
@@ -878,14 +926,17 @@ docs/archive/
|
||||
|
||||
## 十三、当前下一步
|
||||
|
||||
本地阶段 0–6 已按硬切方案完成,生产仍停留在迁移 `146`。后续顺序固定为:
|
||||
截至 2026-11-09,生产基线为 `151`,本地开发基线为 `153`。迁移 `152` 保护订单来源快照,
|
||||
迁移 `153` 增加人工渠道半自动任务。后续顺序固定为:
|
||||
|
||||
1. 生产升级只按 [生产升级操作手册](生产升级操作手册.md) 执行。
|
||||
2. 升级前备份必须完成隔离恢复验证;恢复验证未通过时禁止迁移。
|
||||
3. `147 → 150` 作为同一硬切迁移组执行,迁移后刷新统计并完成商品、履约、
|
||||
3. `147 → 153` 按生产升级手册分批执行,迁移后刷新统计并完成商品、履约、
|
||||
财务三组严格审计;无法确定的数据进入未路由或待分类,不做猜测。
|
||||
4. 执行 `finance:rebuild-derived` 的 dry-run,核对 A 级数据守恒后,
|
||||
再按窗口执行 `--execute --confirm=REBUILD_DERIVED`,并验证第二次重建幂等。
|
||||
5. 观察一个完整财务期间,确认页面、履约、事实和总账持续对平。
|
||||
6. 观察期结束后,再单独发布兼容表、旧脚本别名和 `legacy_*` 字段的删除迁移。
|
||||
6. 半自动履约第一版先接入虎牙扫码登录、角色绑定、一次性浏览器注入和人工完成确认;
|
||||
微博只保留平台扩展位,不提前写死平台专用状态。
|
||||
7. 观察期结束后,再单独发布兼容表、旧脚本别名和 `legacy_*` 字段的删除迁移。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user