diff --git a/.env.mac-docker.example b/.env.mac-docker.example index 86d11875..d3f1d3d3 100644 --- a/.env.mac-docker.example +++ b/.env.mac-docker.example @@ -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= diff --git a/.env.server.example b/.env.server.example index 7f0c4de0..cb4df52a 100644 --- a/.env.server.example +++ b/.env.server.example @@ -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= diff --git a/README.md b/README.md index 442afa75..b989f360 100644 --- a/README.md +++ b/README.md @@ -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/...` diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 13b2d67c..5b54702b 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -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) diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index a41d38fb..ff0eff4d 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -61,6 +61,12 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { tokenTtlHours: 0, }, + semiAuto: { + connectorBaseUrl: '', + connectorApiKey: '', + connectorTimeoutMs: 15_000, + }, + admin: { sessionSecret: '', // 后台:24h 空闲过期,登录后最多 7 天必须重新登录。 diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index 6410c2df..bc3ba203 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -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']), diff --git a/apps/backend/src/config/runtime-validation.ts b/apps/backend/src/config/runtime-validation.ts index 2688fb82..bca4ddc4 100644 --- a/apps/backend/src/config/runtime-validation.ts +++ b/apps/backend/src/config/runtime-validation.ts @@ -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, diff --git a/apps/backend/src/db/migrations/153_semi_auto_fulfillment.sql b/apps/backend/src/db/migrations/153_semi_auto_fulfillment.sql new file mode 100644 index 00000000..67b5270a --- /dev/null +++ b/apps/backend/src/db/migrations/153_semi_auto_fulfillment.sql @@ -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 或运行时状态'; diff --git a/apps/backend/src/routes/admin.ts b/apps/backend/src/routes/admin.ts index e59ecb5a..fe90009e 100644 --- a/apps/backend/src/routes/admin.ts +++ b/apps/backend/src/routes/admin.ts @@ -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) diff --git a/apps/backend/src/routes/admin/semi-auto.ts b/apps/backend/src/routes/admin/semi-auto.ts new file mode 100644 index 00000000..b7aaa160 --- /dev/null +++ b/apps/backend/src/routes/admin/semi-auto.ts @@ -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), { + 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 diff --git a/apps/backend/src/routes/customer-fulfillment.ts b/apps/backend/src/routes/customer-fulfillment.ts new file mode 100644 index 00000000..2f1c6ada --- /dev/null +++ b/apps/backend/src/routes/customer-fulfillment.ts @@ -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 diff --git a/apps/backend/src/routes/semi-auto-injections.ts b/apps/backend/src/routes/semi-auto-injections.ts new file mode 100644 index 00000000..c94ab2c5 --- /dev/null +++ b/apps/backend/src/routes/semi-auto-injections.ts @@ -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 diff --git a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts index dc4bdf72..be29c078 100644 --- a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts +++ b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts @@ -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 = {}) { 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 +} { + 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) + : {} + 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 => + 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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + return value as Record +} + function normalizePlatformKey(value: unknown): ManualPlatformKey { const text = String(value || '').trim() if (!MANUAL_PLATFORM_KEYS.includes(text as ManualPlatformKey)) { diff --git a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts index 3bfffe4b..7696577d 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts @@ -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'), diff --git a/apps/backend/src/services/finance/manual-fulfillment-service.ts b/apps/backend/src/services/finance/manual-fulfillment-service.ts index da462153..ce11b8fe 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-service.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-service.ts @@ -65,10 +65,13 @@ type ManualConfiguredChannel = { enabled: boolean } -async function loadManualPlatformUnitPrices(): Promise> { - const result = await query<{ profile_key: string; config_json: Record }>( - `SELECT profile_key, config_json FROM fulfillment_profiles WHERE profile_key LIKE 'manual_%'`, - ) +async function loadManualPlatformUnitPrices( + client?: Pick, +): Promise> { + 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 }>(sql) + : await query<{ profile_key: string; config_json: Record }>(sql) const prices: Record = { 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 { + await scheduleVoucherConsumeForOrderItems([orderItemId], 'order_received').catch(() => undefined) + await refreshFactsForOrderItem(orderItemId) +} + /** * 人工确认后按「订单业务日期」重算销售事实与总账。 * diff --git a/apps/backend/src/services/fulfillment/carrier-creation-service.ts b/apps/backend/src/services/fulfillment/carrier-creation-service.ts index bc1a790e..c188636b 100644 --- a/apps/backend/src/services/fulfillment/carrier-creation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-creation-service.ts @@ -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 { diff --git a/apps/backend/src/services/semi-auto/huya-connector-client.ts b/apps/backend/src/services/semi-auto/huya-connector-client.ts new file mode 100644 index 00000000..356183d9 --- /dev/null +++ b/apps/backend/src/services/semi-auto/huya-connector-client.ts @@ -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(path: string, options: ConnectorRequestOptions = {}): Promise { + 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).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 { + return callConnector('/api/internal/huya-semi-auto/sessions', { + method: 'POST', + body: input, + }) +} + +export async function getHuyaLoginSession(sessionId: string): Promise { + return callConnector( + `/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}`, + ) +} + +export async function startHuyaBinding(sessionId: string): Promise { + return callConnector( + `/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}/binding`, + { method: 'POST', body: {} }, + ) +} + +export async function getHuyaBinding(sessionId: string): Promise { + return callConnector( + `/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}/binding`, + ) +} + +export async function confirmHuyaBinding(sessionId: string): Promise { + return callConnector( + `/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}/binding/confirm`, + { method: 'POST', body: {} }, + ) +} + +export async function createHuyaBrowserTicket(input: { + sessionId: string + targetUrl?: string +}): Promise { + return callConnector( + `/api/internal/huya-semi-auto/sessions/${encodeURIComponent(input.sessionId)}/browser-ticket`, + { method: 'POST', body: { targetUrl: input.targetUrl || '' } }, + ) +} + +export async function consumeHuyaBrowserTicket(ticket: string): Promise { + return callConnector( + `/api/internal/huya-semi-auto/browser-tickets/${encodeURIComponent(ticket)}`, + ) +} + +export async function revokeHuyaSession(sessionId: string): Promise { + await callConnector<{ ok: boolean }>( + `/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}`, + { method: 'DELETE' }, + ) +} diff --git a/apps/backend/src/services/semi-auto/semi-auto-task-service.ts b/apps/backend/src/services/semi-auto/semi-auto-task-service.ts new file mode 100644 index 00000000..f4277696 --- /dev/null +++ b/apps/backend/src/services/semi-auto/semi-auto-task-service.ts @@ -0,0 +1,1144 @@ +import type { PoolClient } from 'pg' + +import { query, withTransaction } from '../../db/client.js' +import type { AdminSession } from '../admin/admin-auth-service.js' +import { + confirmManualFulfillment, + runManualFulfillmentPostCommit, +} from '../finance/manual-fulfillment-service.js' +import { getFileAssetByObjectKey } from '../../repositories/file-asset-repo.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { safeParseJson } from '../admin/admin-query-utils.js' +import { publishAdminOrderRealtimeChange } from '../realtime/realtime-event-service.js' +import { + buildFileUrl, + ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES, + type UploadedFileDto, +} from '../file-storage/file-storage-service.js' +import { + confirmHuyaBinding, + createHuyaBrowserTicket, + createHuyaLoginSession, + getHuyaBinding, + getHuyaLoginSession, + revokeHuyaSession, + startHuyaBinding, + consumeHuyaBrowserTicket, + type ConnectorBindingSession, + type ConnectorLoginSession, +} from './huya-connector-client.js' + +const SEMI_AUTO_PLATFORMS = new Set(['huya', 'weibo']) +type SemiAutoPlatform = 'huya' | 'weibo' +type LoginStatus = + | 'waiting_login' + | 'qr_pending' + | 'scanned' + | 'authenticated' + | 'failed' + | 'expired' +type BindingStatus = + | 'not_started' + | 'qr_pending' + | 'scanned' + | 'role_ready' + | 'confirmed' + | 'failed' + | 'expired' +type ExecutionStatus = + | 'waiting_customer' + | 'waiting_operator' + | 'processing' + | 'completed' + | 'failed' + | 'cancelled' + +type TaskRow = { + id: number + order_id: number + order_item_id: number + fulfillment_id: number | null + manual_fulfillment_config_id: number | null + platform: SemiAutoPlatform + login_status: LoginStatus + binding_status: BindingStatus + execution_status: ExecutionStatus + connector_session_id: string + huya_uid: string + platform_nickname: string + bind_game_account: string + bind_game_role: string + bind_game_channel: string + target_quantity: number + completed_quantity: number + assigned_admin_user_id: number | null + assigned_admin_name?: string + customer_link_opened_at: string | null + auth_started_at: string | null + authenticated_at: string | null + binding_started_at: string | null + bound_at: string | null + completed_at: string | null + completion_note: string + external_order_no: string + completion_evidence_json: Record + last_error: string + created_at: string + updated_at: string + platform_order_id: string + provider: string + shop_id: string + sku_code: string + sku_name: string + quantity: number + product_name: string + manual_platform_key: string + manual_cost_mode: string + manual_credit_quantity: number | string + semi_auto_config_json: Record +} + +type TaskEventInput = { + eventType: string + actorType?: 'system' | 'customer' | 'admin' | 'connector' + actorId?: string + fromLoginStatus?: LoginStatus | null + toLoginStatus?: LoginStatus | null + fromBindingStatus?: BindingStatus | null + toBindingStatus?: BindingStatus | null + fromExecutionStatus?: ExecutionStatus | null + toExecutionStatus?: ExecutionStatus | null + detail?: Record +} + +type TaskActorType = 'system' | 'customer' | 'admin' | 'connector' + +function normalizeId(value: unknown, label = '任务 ID'): number { + const id = Number(value) + if (!Number.isInteger(id) || id <= 0) { + throw createHttpError(`${label}无效`, { + statusCode: 400, + errorCode: 'semi_auto_task_id_invalid', + }) + } + return id +} + +function normalizeOrderNo(value: unknown): string { + const text = String(value ?? '').trim() + if (!text) { + throw createHttpError('订单号无效,请确认订单号是否正确', { + statusCode: 404, + errorCode: 'semi_auto_order_not_found', + }) + } + return text +} + +function displayStatus(task: TaskRow) { + if (task.execution_status === 'completed') return { key: 'completed', label: '已完成' } + if (task.execution_status === 'cancelled') return { key: 'cancelled', label: '已取消' } + if ( + task.execution_status === 'failed' || + task.login_status === 'failed' || + task.binding_status === 'failed' + ) { + return { key: 'failed', label: '需客服处理' } + } + if (task.binding_status === 'confirmed') { + return { key: 'waiting_operator', label: '等待客服处理' } + } + if (task.binding_status === 'role_ready') { + return { key: 'binding_confirm', label: '待确认绑定' } + } + if (task.login_status === 'authenticated') { + return { key: 'waiting_binding', label: '等待扫码绑定' } + } + if (task.login_status === 'scanned') return { key: 'login_scanned', label: '已扫码,确认中' } + if (task.login_status === 'qr_pending') return { key: 'waiting_login', label: '等待客户登录' } + return { key: 'waiting_customer', label: '等待客户打开链接' } +} + +function mapTask(task: TaskRow) { + return { + id: Number(task.id), + orderId: Number(task.order_id), + orderItemId: Number(task.order_item_id), + fulfillmentId: task.fulfillment_id ? Number(task.fulfillment_id) : null, + platform: task.platform, + platformLabel: + task.platform === 'huya' ? '虎牙' : task.platform === 'weibo' ? '微博' : task.platform, + loginStatus: task.login_status, + bindingStatus: task.binding_status, + executionStatus: task.execution_status, + displayStatus: displayStatus(task), + connectorReady: Boolean(task.connector_session_id), + huyaUid: task.huya_uid, + platformNickname: task.platform_nickname, + bindGameAccount: task.bind_game_account, + bindGameRole: task.bind_game_role, + bindGameChannel: task.bind_game_channel, + targetQuantity: Number(task.target_quantity || 1), + completedQuantity: Number(task.completed_quantity || 0), + assignedAdminUserId: task.assigned_admin_user_id ? Number(task.assigned_admin_user_id) : null, + assignedAdminName: String(task.assigned_admin_name || ''), + customerLinkOpenedAt: task.customer_link_opened_at, + authStartedAt: task.auth_started_at, + authenticatedAt: task.authenticated_at, + bindingStartedAt: task.binding_started_at, + boundAt: task.bound_at, + completedAt: task.completed_at, + completionNote: task.completion_note, + externalOrderNo: task.external_order_no, + completionEvidence: task.completion_evidence_json || {}, + lastError: task.last_error, + createdAt: task.created_at, + updatedAt: task.updated_at, + orderNo: task.platform_order_id, + provider: task.provider, + shopId: task.shop_id, + skuCode: task.sku_code, + skuName: task.sku_name, + productName: task.product_name, + quantity: Number(task.quantity || 1), + semiAutoConfig: task.semi_auto_config_json || {}, + } +} + +export type SemiAutoTaskDto = ReturnType + +async function appendTaskEvent( + client: Pick, + taskId: number, + event: TaskEventInput, +) { + await client.query( + ` + INSERT INTO semi_auto_task_events ( + task_id, event_type, actor_type, actor_id, + from_login_status, to_login_status, + from_binding_status, to_binding_status, + from_execution_status, to_execution_status, + detail_json, created_at + ) VALUES ( + $1, $2, $3, $4, + $5, $6, + $7, $8, + $9, $10, + $11::jsonb, NOW() + ) + `, + [ + taskId, + event.eventType, + event.actorType || 'system', + event.actorId || '', + event.fromLoginStatus || null, + event.toLoginStatus || null, + event.fromBindingStatus || null, + event.toBindingStatus || null, + event.fromExecutionStatus || null, + event.toExecutionStatus || null, + JSON.stringify(event.detail || {}), + ], + ) +} + +const TASK_SELECT = ` + SELECT + task.*, + admin.display_name AS assigned_admin_name, + o.platform_order_id, + o.provider, + o.shop_id, + oi.sku_code, + oi.sku_name, + oi.quantity, + COALESCE(NULLIF(oi.sku_name, ''), NULLIF(oi.sku_code, ''), '订单商品') AS product_name, + cfg.platform_key AS manual_platform_key, + cfg.cost_mode AS manual_cost_mode, + cfg.credit_quantity AS manual_credit_quantity, + cfg.semi_auto_config_json + FROM semi_auto_tasks task + JOIN orders o ON o.id = task.order_id + JOIN order_items oi ON oi.id = task.order_item_id + LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = task.manual_fulfillment_config_id + LEFT JOIN admin_users admin ON admin.id = task.assigned_admin_user_id +` + +async function loadTask(taskId: number): Promise { + const result = await query(`${TASK_SELECT} WHERE task.id = $1 LIMIT 1`, [taskId]) + const task = result.rows[0] + if (!task) { + throw createHttpError('半自动履约任务不存在', { + statusCode: 404, + errorCode: 'semi_auto_task_not_found', + }) + } + return task +} + +async function loadTaskForOrder(orderNo: string, taskId: number): Promise { + const task = await loadTask(taskId) + if (String(task.platform_order_id || '').trim() !== orderNo) { + throw createHttpError('订单号或任务无效', { + statusCode: 404, + errorCode: 'semi_auto_task_not_found', + }) + } + return task +} + +async function loadTaskForUpdateWithClient(client: PoolClient, taskId: number): Promise { + const result = await client.query( + `${TASK_SELECT} WHERE task.id = $1 LIMIT 1 FOR UPDATE OF task`, + [taskId], + ) + const task = result.rows[0] + if (!task) { + throw createHttpError('半自动履约任务不存在', { + statusCode: 404, + errorCode: 'semi_auto_task_not_found', + }) + } + return task +} + +type SemiAutoCandidate = { + order_id: number + platform_order_id: string + order_item_id: number + fulfillment_id: number + manual_fulfillment_config_id: number + platform: SemiAutoPlatform + quantity: number +} + +async function insertMissingSemiAutoTasksWithClient(client: PoolClient, orderNo?: string) { + const params: unknown[] = [] + let orderFilter = '' + if (orderNo) { + params.push(orderNo) + orderFilter = 'AND o.platform_order_id = $1' + } + const candidates = await client.query( + ` + SELECT + o.id AS order_id, + o.platform_order_id, + oi.id AS order_item_id, + f.id AS fulfillment_id, + cfg.id AS manual_fulfillment_config_id, + cfg.semi_auto_platform AS platform, + GREATEST(1, COALESCE(oi.quantity, 1))::int AS quantity + FROM orders o + JOIN order_items oi ON oi.order_id = o.id + JOIN fulfillments f ON f.order_item_id = oi.id + JOIN manual_fulfillment_configs cfg ON cfg.id = f.fulfillment_config_id + WHERE f.channel = 'manual' + AND f.status = 'assigned' + AND cfg.enabled = TRUE + AND cfg.semi_auto_enabled = TRUE + AND cfg.semi_auto_platform IN ('huya', 'weibo') + ${orderFilter} + AND NOT EXISTS ( + SELECT 1 FROM semi_auto_tasks existing WHERE existing.order_item_id = oi.id + ) + ORDER BY o.id DESC, oi.id ASC + `, + params, + ) + + const createdIds: number[] = [] + const orderIds = new Set() + for (const row of candidates.rows) { + const inserted = await client.query<{ id: number }>( + ` + INSERT INTO semi_auto_tasks ( + order_id, order_item_id, fulfillment_id, manual_fulfillment_config_id, + platform, target_quantity, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + ON CONFLICT (order_item_id) DO NOTHING + RETURNING id + `, + [ + Number(row.order_id), + Number(row.order_item_id), + Number(row.fulfillment_id), + Number(row.manual_fulfillment_config_id), + row.platform, + Number(row.quantity || 1), + ], + ) + const taskId = Number(inserted.rows[0]?.id || 0) + if (!taskId) continue + createdIds.push(taskId) + orderIds.add(Number(row.order_id)) + await appendTaskEvent(client, taskId, { + eventType: 'task_created', + toLoginStatus: 'waiting_login', + toBindingStatus: 'not_started', + toExecutionStatus: 'waiting_customer', + detail: { orderNo: row.platform_order_id }, + }) + } + return { createdIds, orderIds: [...orderIds] } +} + +export async function ensureSemiAutoTasksForOrder(orderNoInput: unknown) { + const orderNo = normalizeOrderNo(orderNoInput) + const result = await withTransaction((client) => + insertMissingSemiAutoTasksWithClient(client, orderNo), + ) + if (result.orderIds[0]) { + publishAdminOrderRealtimeChange(result.orderIds[0]) + } + return { orderNo, createdIds: result.createdIds } +} + +/** + * 后台列表读取前的幂等回填。 + * + * 客户不先打开 `/c/订单号` 时,任务也必须进入客服工作台;后备查询只插入缺失任务, + * 不修改已有任务状态,也不会重复写事件。 + */ +export async function backfillSemiAutoTasks() { + const result = await withTransaction((client) => insertMissingSemiAutoTasksWithClient(client)) + return { createdIds: result.createdIds, orderIds: result.orderIds } +} + +export async function listPublicSemiAutoTasks(orderNoInput: unknown) { + const orderNo = normalizeOrderNo(orderNoInput) + await ensureSemiAutoTasksForOrder(orderNo) + const result = await query( + `${TASK_SELECT} + WHERE o.platform_order_id = $1 + AND task.execution_status <> 'cancelled' + ORDER BY task.id ASC`, + [orderNo], + ) + return result.rows.map(mapTask) +} + +export async function markCustomerLinkOpened(orderNoInput: unknown, taskIdInput: unknown) { + const orderNo = normalizeOrderNo(orderNoInput) + const taskId = normalizeId(taskIdInput) + await ensureSemiAutoTasksForOrder(orderNo) + const task = await loadTaskForOrder(orderNo, taskId) + if (!task.customer_link_opened_at) { + await withTransaction(async (client) => { + await client.query( + `UPDATE semi_auto_tasks SET customer_link_opened_at = NOW(), updated_at = NOW() WHERE id = $1`, + [taskId], + ) + await appendTaskEvent(client, taskId, { + eventType: 'customer_link_opened', + actorType: 'customer', + }) + }) + } + return { ok: true } +} + +function mapLoginUpdate(session: ConnectorLoginSession) { + const statusMap: Record = { + waiting_scan: 'qr_pending', + scanned: 'scanned', + authenticated: 'authenticated', + expired: 'expired', + failed: 'failed', + } + return { + status: statusMap[session.status] || 'failed', + uid: String(session.huyaUid || ''), + nickname: String(session.nickname || ''), + message: String(session.message || ''), + hasCredential: session.hasCredential === true, + } +} + +async function updateLoginTask( + task: TaskRow, + session: ConnectorLoginSession, + actorType: TaskActorType, +) { + const mapped = mapLoginUpdate(session) + const nextExecutionStatus: ExecutionStatus = + mapped.status === 'authenticated' + ? task.binding_status === 'confirmed' + ? 'waiting_operator' + : 'waiting_customer' + : task.execution_status + await withTransaction(async (client) => { + await client.query( + ` + UPDATE semi_auto_tasks + SET login_status = $2, + huya_uid = CASE WHEN $3 <> '' THEN $3 ELSE huya_uid END, + platform_nickname = CASE WHEN $4 <> '' THEN $4 ELSE platform_nickname END, + authenticated_at = CASE WHEN $2 = 'authenticated' THEN COALESCE(authenticated_at, NOW()) ELSE authenticated_at END, + execution_status = $5, + last_error = CASE WHEN $2 IN ('failed', 'expired') THEN $6 ELSE '' END, + updated_at = NOW() + WHERE id = $1 + `, + [task.id, mapped.status, mapped.uid, mapped.nickname, nextExecutionStatus, mapped.message], + ) + await appendTaskEvent(client, task.id, { + eventType: 'login_status_changed', + actorType, + fromLoginStatus: task.login_status, + toLoginStatus: mapped.status, + fromExecutionStatus: task.execution_status, + toExecutionStatus: nextExecutionStatus, + detail: { + hasCredential: mapped.hasCredential, + message: mapped.message, + }, + }) + }) +} + +function mapBindingUpdate(session: ConnectorBindingSession) { + const statusMap: Record = { + not_started: 'not_started', + qr_pending: 'qr_pending', + scanned: 'scanned', + role_ready: 'role_ready', + confirmed: 'confirmed', + expired: 'expired', + failed: 'failed', + } + return { + status: statusMap[session.status] || 'failed', + qrImage: String(session.qrImage || ''), + bindGameAccount: String(session.bindGameAccount || ''), + bindGameRole: String(session.bindGameRole || ''), + bindGameChannel: String(session.bindGameChannel || ''), + message: String(session.message || ''), + } +} + +async function updateBindingTask( + task: TaskRow, + session: ConnectorBindingSession, + actorType: TaskActorType, +) { + const mapped = mapBindingUpdate(session) + const executionStatus: ExecutionStatus = + mapped.status === 'confirmed' + ? 'waiting_operator' + : mapped.status === 'failed' || mapped.status === 'expired' + ? 'failed' + : task.execution_status === 'completed' || task.execution_status === 'cancelled' + ? task.execution_status + : 'waiting_customer' + await withTransaction(async (client) => { + await client.query( + ` + UPDATE semi_auto_tasks + SET binding_status = $2, + bind_game_account = CASE WHEN $3 <> '' THEN $3 ELSE bind_game_account END, + bind_game_role = CASE WHEN $4 <> '' THEN $4 ELSE bind_game_role END, + bind_game_channel = CASE WHEN $5 <> '' THEN $5 ELSE bind_game_channel END, + bound_at = CASE WHEN $2 IN ('role_ready', 'confirmed') THEN COALESCE(bound_at, NOW()) ELSE bound_at END, + execution_status = $6, + last_error = CASE WHEN $2 IN ('failed', 'expired') THEN $7 ELSE '' END, + updated_at = NOW() + WHERE id = $1 + `, + [ + task.id, + mapped.status, + mapped.bindGameAccount, + mapped.bindGameRole, + mapped.bindGameChannel, + executionStatus, + mapped.message, + ], + ) + await appendTaskEvent(client, task.id, { + eventType: 'binding_status_changed', + actorType, + fromBindingStatus: task.binding_status, + toBindingStatus: mapped.status, + fromExecutionStatus: task.execution_status, + toExecutionStatus: executionStatus, + detail: { + gameAccount: mapped.bindGameAccount, + gameRole: mapped.bindGameRole, + gameChannel: mapped.bindGameChannel, + message: mapped.message, + }, + }) + }) + return { + status: mapped.status, + qrImage: mapped.qrImage, + bindGameAccount: mapped.bindGameAccount, + bindGameRole: mapped.bindGameRole, + bindGameChannel: mapped.bindGameChannel, + message: mapped.message, + } +} + +export async function startPublicLogin(orderNoInput: unknown, taskIdInput: unknown) { + const orderNo = normalizeOrderNo(orderNoInput) + const taskId = normalizeId(taskIdInput) + await ensureSemiAutoTasksForOrder(orderNo) + const task = await loadTaskForOrder(orderNo, taskId) + if (task.execution_status === 'completed' || task.execution_status === 'cancelled') { + throw createHttpError('当前订单已结束,不能重新登录', { + statusCode: 409, + errorCode: 'semi_auto_task_closed', + }) + } + + let reusableSession: ConnectorLoginSession | null = null + if ( + task.connector_session_id && + task.login_status !== 'expired' && + task.login_status !== 'failed' + ) { + try { + reusableSession = await getHuyaLoginSession(task.connector_session_id) + } catch { + // Connector 重启或短期会话被清理后,任务里的旧 ID 已不可用;重新创建前先尽力撤销。 + await revokeHuyaSession(task.connector_session_id).catch(() => undefined) + } + } else if (task.connector_session_id) { + await revokeHuyaSession(task.connector_session_id).catch(() => undefined) + } + + if (reusableSession) { + await updateLoginTask(task, reusableSession, 'customer') + const refreshedTask = await loadTask(task.id) + return { + task: mapTask(refreshedTask), + login: { + status: mapLoginUpdate(reusableSession).status, + qrContent: reusableSession.qrContent, + expiresAt: reusableSession.expiresAt, + message: reusableSession.message, + }, + } + } + + const session = await createHuyaLoginSession({ taskId: task.id, orderNo }) + await withTransaction(async (client) => { + await client.query( + ` + UPDATE semi_auto_tasks + SET connector_session_id = $2, + login_status = 'qr_pending', + binding_status = 'not_started', + execution_status = 'waiting_customer', + auth_started_at = COALESCE(auth_started_at, NOW()), + last_error = '', + updated_at = NOW() + WHERE id = $1 + `, + [task.id, session.sessionId], + ) + await appendTaskEvent(client, task.id, { + eventType: 'login_started', + actorType: 'customer', + fromLoginStatus: task.login_status, + toLoginStatus: 'qr_pending', + fromExecutionStatus: task.execution_status, + toExecutionStatus: 'waiting_customer', + detail: { expiresAt: session.expiresAt }, + }) + }) + const refreshedTask = await loadTask(task.id) + return { + task: mapTask(refreshedTask), + login: { + status: mapLoginUpdate(session).status, + qrContent: session.qrContent, + expiresAt: session.expiresAt, + message: session.message, + }, + } +} + +export async function pollPublicLogin(orderNoInput: unknown, taskIdInput: unknown) { + const orderNo = normalizeOrderNo(orderNoInput) + const taskId = normalizeId(taskIdInput) + const task = await loadTaskForOrder(orderNo, taskId) + if (!task.connector_session_id) { + return { task: mapTask(task), login: null } + } + const session = await getHuyaLoginSession(task.connector_session_id) + await updateLoginTask(task, session, 'customer') + const updated = await loadTask(task.id) + return { + task: mapTask(updated), + login: { + status: mapLoginUpdate(session).status, + qrContent: session.qrContent, + expiresAt: session.expiresAt, + message: session.message, + }, + } +} + +export async function startPublicBinding(orderNoInput: unknown, taskIdInput: unknown) { + const orderNo = normalizeOrderNo(orderNoInput) + const taskId = normalizeId(taskIdInput) + const task = await loadTaskForOrder(orderNo, taskId) + if (task.execution_status === 'completed' || task.execution_status === 'cancelled') { + throw createHttpError('当前订单已结束,不能重新绑定', { + statusCode: 409, + errorCode: 'semi_auto_task_closed', + }) + } + if (!task.connector_session_id || task.login_status !== 'authenticated') { + throw createHttpError('请先完成平台登录', { + statusCode: 409, + errorCode: 'semi_auto_login_required', + }) + } + const session = await startHuyaBinding(task.connector_session_id) + const binding = await updateBindingTask(task, session, 'customer') + const updated = await loadTask(task.id) + return { task: mapTask(updated), binding } +} + +export async function pollPublicBinding(orderNoInput: unknown, taskIdInput: unknown) { + const orderNo = normalizeOrderNo(orderNoInput) + const taskId = normalizeId(taskIdInput) + const task = await loadTaskForOrder(orderNo, taskId) + if (task.execution_status === 'completed' || task.execution_status === 'cancelled') { + return { task: mapTask(task), binding: null } + } + if (!task.connector_session_id || task.login_status !== 'authenticated') { + return { task: mapTask(task), binding: null } + } + const session = await getHuyaBinding(task.connector_session_id) + const binding = await updateBindingTask(task, session, 'customer') + const updated = await loadTask(task.id) + return { task: mapTask(updated), binding } +} + +export async function confirmPublicBinding(orderNoInput: unknown, taskIdInput: unknown) { + const orderNo = normalizeOrderNo(orderNoInput) + const taskId = normalizeId(taskIdInput) + const task = await loadTaskForOrder(orderNo, taskId) + if (task.execution_status === 'completed' || task.execution_status === 'cancelled') { + throw createHttpError('当前订单已结束,不能确认绑定', { + statusCode: 409, + errorCode: 'semi_auto_task_closed', + }) + } + if (!task.connector_session_id || task.binding_status !== 'role_ready') { + throw createHttpError('当前没有待确认的绑定角色', { + statusCode: 409, + errorCode: 'semi_auto_binding_not_ready', + }) + } + const session = await confirmHuyaBinding(task.connector_session_id) + const binding = await updateBindingTask(task, session, 'customer') + const updated = await loadTask(task.id) + return { task: mapTask(updated), binding } +} + +export async function listAdminSemiAutoTasks(input: Record = {}) { + await backfillSemiAutoTasks() + const page = Math.max(1, Math.trunc(Number(input.page) || 1)) + const pageSize = Math.min(100, Math.max(1, Math.trunc(Number(input.pageSize) || 20))) + const platform = String(input.platform || '').trim() + const status = String(input.status || '').trim() + const keyword = String(input.keyword || '').trim() + const conditions = ['1 = 1'] + const params: unknown[] = [] + if (platform === 'huya' || platform === 'weibo') { + params.push(platform) + conditions.push(`task.platform = $${params.length}`) + } + if ( + [ + 'waiting_customer', + 'waiting_operator', + 'processing', + 'completed', + 'failed', + 'cancelled', + ].includes(status) + ) { + params.push(status) + conditions.push(`task.execution_status = $${params.length}`) + } + if (keyword) { + params.push(`%${keyword.replace(/[\\%_]/g, '\\$&')}%`) + const p = `$${params.length}` + conditions.push( + `(o.platform_order_id ILIKE ${p} OR oi.sku_name ILIKE ${p} OR oi.sku_code ILIKE ${p} OR task.huya_uid ILIKE ${p} OR task.bind_game_role ILIKE ${p})`, + ) + } + const where = conditions.join(' AND ') + const count = await query<{ total: number }>( + `SELECT COUNT(*)::int AS total + FROM semi_auto_tasks task + JOIN orders o ON o.id = task.order_id + JOIN order_items oi ON oi.id = task.order_item_id + WHERE ${where}`, + params, + ) + const listParams = [...params, pageSize, (page - 1) * pageSize] + const result = await query( + `${TASK_SELECT} + WHERE ${where} + ORDER BY + CASE task.execution_status + WHEN 'waiting_operator' THEN 0 + WHEN 'waiting_customer' THEN 1 + WHEN 'processing' THEN 2 + WHEN 'failed' THEN 3 + ELSE 4 + END, + task.updated_at DESC, + task.id DESC + LIMIT $${listParams.length - 1} OFFSET $${listParams.length}`, + listParams, + ) + return { + items: result.rows.map(mapTask), + pagination: { page, pageSize, total: Number(count.rows[0]?.total || 0) }, + } +} + +export async function getAdminSemiAutoTask(taskIdInput: unknown) { + const task = await loadTask(normalizeId(taskIdInput)) + const events = await query<{ + id: number + event_type: string + actor_type: string + actor_id: string + detail_json: Record + created_at: string + }>( + `SELECT id, event_type, actor_type, actor_id, detail_json, created_at + FROM semi_auto_task_events + WHERE task_id = $1 + ORDER BY id DESC + LIMIT 200`, + [task.id], + ) + return { + task: mapTask(task), + events: events.rows.map((event) => ({ + id: Number(event.id), + eventType: event.event_type, + actorType: event.actor_type, + actorId: event.actor_id, + detail: event.detail_json || {}, + createdAt: event.created_at, + })), + } +} + +export async function claimAdminSemiAutoTask(taskIdInput: unknown, session: AdminSession) { + const taskId = normalizeId(taskIdInput) + const task = await loadTask(taskId) + const adminUserId = Number(session.userId || 0) + if (task.assigned_admin_user_id && Number(task.assigned_admin_user_id) !== adminUserId) { + throw createHttpError('该任务已被其他客服领取', { + statusCode: 409, + errorCode: 'semi_auto_task_claimed', + }) + } + await withTransaction(async (client) => { + await client.query( + `UPDATE semi_auto_tasks + SET assigned_admin_user_id = $2, updated_at = NOW() + WHERE id = $1`, + [taskId, adminUserId], + ) + await appendTaskEvent(client, taskId, { + eventType: 'task_claimed', + actorType: 'admin', + actorId: String(adminUserId), + }) + }) + return getAdminSemiAutoTask(taskId) +} + +export async function syncAdminSemiAutoTask(taskIdInput: unknown) { + const taskId = normalizeId(taskIdInput) + let task = await loadTask(taskId) + if (!task.connector_session_id) return getAdminSemiAutoTask(taskId) + if ( + task.login_status !== 'authenticated' && + task.login_status !== 'failed' && + task.login_status !== 'expired' + ) { + const login = await getHuyaLoginSession(task.connector_session_id) + await updateLoginTask(task, login, 'connector') + task = await loadTask(taskId) + } + if (task.login_status === 'authenticated' && task.binding_status !== 'confirmed') { + const binding = await getHuyaBinding(task.connector_session_id) + await updateBindingTask(task, binding, 'connector') + } + return getAdminSemiAutoTask(taskId) +} + +export async function createAdminBrowserSession( + taskIdInput: unknown, + targetUrlInput: unknown, + session: AdminSession, +) { + const taskId = normalizeId(taskIdInput) + const task = await loadTask(taskId) + const adminUserId = Number(session.userId || 0) + if (task.assigned_admin_user_id && Number(task.assigned_admin_user_id) !== adminUserId) { + throw createHttpError('该任务已被其他客服领取', { + statusCode: 409, + errorCode: 'semi_auto_task_claimed', + }) + } + if (!task.connector_session_id || task.binding_status !== 'confirmed') { + throw createHttpError('客户尚未完成登录和绑定', { + statusCode: 409, + errorCode: 'semi_auto_binding_required', + }) + } + const config = safeParseJson(task.semi_auto_config_json) + const targets = Array.isArray(config.browserTargets) ? config.browserTargets : [] + const explicitTarget = String(targetUrlInput || '').trim() + const fallbackTarget = targets.find((item): item is { url?: string } => + Boolean(item && typeof item === 'object'), + )?.url + const targetUrl = explicitTarget || String(fallbackTarget || '') + const ticket = await createHuyaBrowserTicket({ + sessionId: task.connector_session_id, + targetUrl, + }) + await withTransaction(async (client) => { + await client.query( + `UPDATE semi_auto_tasks + SET assigned_admin_user_id = COALESCE(assigned_admin_user_id, $2), + execution_status = CASE WHEN execution_status = 'waiting_operator' THEN 'processing' ELSE execution_status END, + updated_at = NOW() + WHERE id = $1`, + [taskId, adminUserId], + ) + await appendTaskEvent(client, taskId, { + eventType: 'browser_session_opened', + actorType: 'admin', + actorId: String(adminUserId), + detail: { targetUrl }, + }) + }) + return ticket +} + +export async function consumeSemiAutoInjectionTicket(ticketInput: unknown) { + const ticket = String(ticketInput || '').trim() + if (!ticket) { + throw createHttpError('注入票据无效', { + statusCode: 400, + errorCode: 'semi_auto_injection_ticket_invalid', + }) + } + return consumeHuyaBrowserTicket(ticket) +} + +async function resolveCompletionEvidence( + screenshots: unknown, + session: AdminSession, +): Promise { + const files = Array.isArray(screenshots) ? screenshots : [] + const uploaderId = String(session.userId || session.username || '') + const result: UploadedFileDto[] = [] + for (const item of files.slice(0, 10)) { + const source = item && typeof item === 'object' ? (item as Record) : {} + const objectKey = String(source.objectKey || source.object_key || '').trim() + if (!objectKey) continue + const asset = await getFileAssetByObjectKey(objectKey) + if ( + !asset || + asset.status !== 'active' || + asset.uploader_type !== 'admin' || + asset.uploader_id !== uploaderId || + asset.scene !== 'semi-auto-evidence' || + !ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES.has(String(asset.content_type || '')) + ) { + throw createHttpError('完成截图无效,请重新上传', { + statusCode: 400, + errorCode: 'semi_auto_evidence_invalid', + }) + } + result.push({ + objectKey: asset.object_key, + url: buildFileUrl(asset.object_key), + thumbnailUrl: buildFileUrl(asset.thumbnail_object_key || asset.object_key), + mediumUrl: buildFileUrl(asset.medium_object_key || asset.object_key), + filename: asset.original_filename, + contentType: asset.content_type, + size: Number(asset.size_bytes || 0), + }) + } + return result +} + +export async function completeAdminSemiAutoTask( + taskIdInput: unknown, + payload: Record, + session: AdminSession, +) { + const taskId = normalizeId(taskIdInput) + const current = await loadTask(taskId) + if (current.execution_status === 'completed') return getAdminSemiAutoTask(taskId) + if (current.execution_status === 'cancelled') { + throw createHttpError('任务已取消,不能完成', { + statusCode: 409, + errorCode: 'semi_auto_task_cancelled', + }) + } + const screenshots = await resolveCompletionEvidence(payload.screenshots || [], session) + const note = String(payload.note || '') + .trim() + .slice(0, 1000) + const externalOrderNo = String(payload.externalOrderNo || payload.external_order_no || '') + .trim() + .slice(0, 200) + const costFenRaw = Number(payload.costFen ?? payload.cost_fen) + const costFen = + Number.isFinite(costFenRaw) && costFenRaw >= 0 ? Math.trunc(costFenRaw) : undefined + const completionNote = [note, externalOrderNo ? `外部单号:${externalOrderNo}` : ''] + .filter(Boolean) + .join(';') + const adminUserId = Number(session.userId || 0) + + // 人工财务确认、履约状态和半自动任务完成必须同事务提交: + // 任何任务更新失败都会回滚成本快照、钱包扣减和履约状态,避免“已扣钱但任务未完成”。 + const completion = await withTransaction(async (client) => { + const task = await loadTaskForUpdateWithClient(client, taskId) + if (task.execution_status === 'completed') { + return { task, completedNow: false } + } + if (task.execution_status === 'cancelled') { + throw createHttpError('任务已取消,不能完成', { + statusCode: 409, + errorCode: 'semi_auto_task_cancelled', + }) + } + if (!task.connector_session_id || task.binding_status !== 'confirmed') { + throw createHttpError('客户尚未完成登录和绑定,不能完成履约', { + statusCode: 409, + errorCode: 'semi_auto_binding_required', + }) + } + if (task.assigned_admin_user_id && Number(task.assigned_admin_user_id) !== adminUserId) { + throw createHttpError('该任务已被其他客服领取', { + statusCode: 409, + errorCode: 'semi_auto_task_claimed', + }) + } + + await confirmManualFulfillment( + { + orderItemId: task.order_item_id, + platformKey: task.manual_platform_key, + ...(costFen !== undefined ? { costFen } : {}), + note: completionNote || '半自动履约完成', + }, + session, + { client }, + ) + + const updated = await client.query<{ id: number }>( + ` + UPDATE semi_auto_tasks + SET assigned_admin_user_id = COALESCE(assigned_admin_user_id, $2), + execution_status = 'completed', + completed_quantity = target_quantity, + completed_at = NOW(), + completion_note = $3, + external_order_no = $4, + completion_evidence_json = $5::jsonb, + last_error = '', + updated_at = NOW() + WHERE id = $1 + RETURNING id + `, + [taskId, adminUserId, note, externalOrderNo, JSON.stringify({ screenshots })], + ) + if (!updated.rows[0]) { + throw createHttpError('半自动履约任务状态更新失败', { + statusCode: 500, + errorCode: 'semi_auto_task_completion_failed', + }) + } + await appendTaskEvent(client, taskId, { + eventType: 'task_completed', + actorType: 'admin', + actorId: String(adminUserId), + fromExecutionStatus: task.execution_status, + toExecutionStatus: 'completed', + detail: { + note, + externalOrderNo, + screenshotCount: screenshots.length, + }, + }) + return { task, completedNow: true } + }) + + if (!completion.completedNow) { + return getAdminSemiAutoTask(taskId) + } + await runManualFulfillmentPostCommit(completion.task.order_item_id) + if (completion.task.connector_session_id) { + await revokeHuyaSession(completion.task.connector_session_id).catch(() => undefined) + } + publishAdminOrderRealtimeChange(Number(completion.task.order_id)) + return getAdminSemiAutoTask(taskId) +} + +export async function cancelAdminSemiAutoTask( + taskIdInput: unknown, + payload: Record, + session: AdminSession, +) { + const taskId = normalizeId(taskIdInput) + const task = await loadTask(taskId) + if (task.execution_status === 'completed') { + throw createHttpError('已完成任务不能取消', { + statusCode: 409, + errorCode: 'semi_auto_task_completed', + }) + } + const reason = String(payload.reason || '') + .trim() + .slice(0, 500) + const adminUserId = Number(session.userId || 0) + await withTransaction(async (client) => { + await client.query( + ` + UPDATE semi_auto_tasks + SET execution_status = 'cancelled', + last_error = $2, + updated_at = NOW() + WHERE id = $1 + `, + [taskId, reason], + ) + await appendTaskEvent(client, taskId, { + eventType: 'task_cancelled', + actorType: 'admin', + actorId: String(adminUserId), + fromExecutionStatus: task.execution_status, + toExecutionStatus: 'cancelled', + detail: { reason }, + }) + }) + if (task.connector_session_id) { + await revokeHuyaSession(task.connector_session_id).catch(() => undefined) + } + publishAdminOrderRealtimeChange(Number(task.order_id)) + return getAdminSemiAutoTask(taskId) +} diff --git a/apps/backend/src/services/semi-auto/semi-auto-task.integration.test.ts b/apps/backend/src/services/semi-auto/semi-auto-task.integration.test.ts new file mode 100644 index 00000000..cd8adc38 --- /dev/null +++ b/apps/backend/src/services/semi-auto/semi-auto-task.integration.test.ts @@ -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() + } + }, +) diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index faa95205..eedddb8c 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -61,6 +61,11 @@ export type RuntimeConfig = { claimBaseUrl: string tokenTtlHours: number } + semiAuto: { + connectorBaseUrl: string + connectorApiKey: string + connectorTimeoutMs: number + } admin: { sessionSecret: string /** 后台空闲多久未访问即失效(每次访问滑动续期)。 */ diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 11e2415c..877b1dda 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -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 } + if (isCustomerFulfillmentPath(window.location.pathname)) { + return + } + // 旧 /supplement 短期兼容:统一跳到 /o。 if (isLegacySupplementPath(window.location.pathname)) { return @@ -158,6 +167,20 @@ function OrderServiceApplication() { ) } +function CustomerFulfillmentApplication() { + useClearStrayHash() + return ( + + }> + + } /> + } /> + + + + ) +} + function CollectRedirectApplication() { useEffect(() => { const orderNo = resolveLegacySupplementOrderNo(window.location.pathname) @@ -180,6 +203,7 @@ function AdminApplication() { } /> } /> } /> + } /> }> } /> diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index adb231e2..e7d9e526 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -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: , label: '任务' }, + { + key: '/admin/semi-auto-tasks', + icon: , + label: '半自动履约', + }, { key: '/admin/worker-platform', icon: , label: '接单平台' }, { key: '/admin/kuaishou-industry', @@ -246,6 +252,11 @@ export default function AdminLayout() { { key: '/admin/orders', icon: , label: '订单' }, financeSubMenu, { key: '/admin/tasks', icon: , label: '任务' }, + { + key: '/admin/semi-auto-tasks', + icon: , + label: '半自动履约', + }, { key: '/admin/worker-platform', icon: , label: '接单平台' }, { key: '/admin/kuaishou-industry', diff --git a/apps/frontend/src/pages/admin/AdminSemiAutoTasksPage.tsx b/apps/frontend/src/pages/admin/AdminSemiAutoTasksPage.tsx new file mode 100644 index 00000000..7fdf6af2 --- /dev/null +++ b/apps/frontend/src/pages/admin/AdminSemiAutoTasksPage.tsx @@ -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 = { + 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 = { + 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(null) + const [completeTask, setCompleteTask] = useState(null) + const [completeFiles, setCompleteFiles] = useState([]) + const [completeForm] = Form.useForm() + + 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).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: ( + { + reason = event.target.value + }} + /> + ), + okText: '确认取消', + okButtonProps: { danger: true }, + cancelText: '返回', + onOk: () => cancelMutation.mutateAsync({ taskId: task.id, reason }), + }) + } + + const columns: TableColumnsType = [ + { + title: '订单 / 商品', + key: 'order', + width: 260, + render: (_, task) => ( + + {task.orderNo} + {task.productName || task.skuName || task.skuCode} + SKU:{task.skuCode || '-'} + + ), + }, + { + title: '平台', + dataIndex: 'platform', + width: 90, + render: (value: string) => {PLATFORM_LABELS[value] || value}, + }, + { + title: '客户登录', + key: 'login', + width: 150, + render: (_, task) => ( + + {task.displayStatus.label} + {task.huyaUid ? `UID ${task.huyaUid}` : '未获取 UID'} + + ), + }, + { + title: '绑定角色', + key: 'binding', + width: 190, + render: (_, task) => + task.bindGameRole ? ( + + {task.bindGameRole} + + {[task.bindGameAccount, task.bindGameChannel].filter(Boolean).join(' · ') || '-'} + + + ) : ( + 未绑定 + ), + }, + { + 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) => ( + + + + + {!task.assignedAdminUserId ? ( + + ) : null} + + {task.executionStatus !== 'completed' && task.executionStatus !== 'cancelled' ? ( + <> + + + + ) : null} + + ), + }, + ] + + return ( +
+ + + + { + setKeyword(event.target.value) + setPage(1) + }} + style={{ width: 280 }} + /> + { + 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 }} + /> + + + + 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) + }, + }} + /> + + + setDetailTaskId(null)} + > + {detailQuery.isLoading ? ( + 加载中... + ) : detail?.task ? ( + + + + {detail.task.productName} + + + {PLATFORM_LABELS[detail.task.platform] || detail.task.platform} + + + + {detail.task.displayStatus.label} + + + {detail.task.huyaUid || '-'} + + {detail.task.platformNickname || '-'} + + + {detail.task.bindGameAccount || '-'} + + + {detail.task.bindGameRole || '-'} + + + {detail.task.bindGameChannel || '-'} + + + {detail.task.assignedAdminName || '未领取'} + + + {formatAdminDateTime(detail.task.updatedAt)} + + + {detail.task.lastError ? ( + + {detail.task.lastError} + + ) : null} + + + + + + + + + ({ + color: event.eventType.includes('failed') ? 'red' : 'blue', + children: ( + + {event.eventType} + + {formatAdminDateTime(event.createdAt)} · {event.actorType} + {event.actorId ? ` ${event.actorId}` : ''} + + + ), + }))} + /> + + + ) : ( + 暂无任务详情 + )} + + + { + setCompleteTask(null) + setCompleteFiles([]) + completeForm.resetFields() + }} + onOk={() => { + void completeForm.validateFields().then((values) => completeMutation.mutate(values)) + }} + > + form={completeForm} layout="vertical"> + + + + + + + + + + + + + + +
+ ) +} + +function firstTargetUrl(task: SemiAutoTask) { + const targets = Array.isArray(task.semiAutoConfig?.browserTargets) + ? task.semiAutoConfig.browserTargets + : [] + return String(targets.find((item) => item?.url)?.url || '') +} diff --git a/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx index 40c0368c..d85f531b 100644 --- a/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx @@ -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 = { @@ -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() { )} + + + + + + {() => + form.getFieldValue('semiAutoEnabled') ? ( + <> + + + + + + + + + + + + + + + + + + ) : null + } + + diff --git a/apps/frontend/src/pages/customer-fulfillment/CustomerFulfillmentPage.tsx b/apps/frontend/src/pages/customer-fulfillment/CustomerFulfillmentPage.tsx new file mode 100644 index 00000000..f741c3a4 --- /dev/null +++ b/apps/frontend/src/pages/customer-fulfillment/CustomerFulfillmentPage.tsx @@ -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([]) + const [selectedTaskId, setSelectedTaskId] = useState(0) + const [loginInfo, setLoginInfo] = useState(null) + const [bindingInfo, setBindingInfo] = useState(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 ( +
+ } tip="正在读取订单履约信息..." /> +
+ ) + } + + if (loadError || tasks.length === 0) { + return ( +
+ +
+ ) + } + + if (selectedTask?.executionStatus === 'completed') { + return ( +
+ } + title="履约已完成" + subTitle={ + selectedTask.completionNote + ? selectedTask.completionNote + : '客服已完成本订单的充值或赠送操作,请到平台账号中确认。' + } + extra={ + selectedTask.completedAt + ? `完成时间:${formatAdminDateTime(selectedTask.completedAt)}` + : undefined + } + /> +
+ ) + } + + 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 ( +
+ +
+ + {title} + + + {description} + +
+ + {tasks.length > 1 ? ( + + + {tasks.map((task) => ( + + ))} + + + ) : null} + + + + + + 订单号 + + {selectedTask?.orderNo} + + 商品 + {selectedTask?.productName || selectedTask?.skuName} + {selectedTask?.platformLabel || selectedTask?.platform} + {selectedTask?.displayStatus.label} + + + + }, + { title: '扫码绑定', icon: }, + { title: '客服处理', icon: }, + { title: '完成' }, + ]} + /> + + + + + {selectedTask && selectedTask.executionStatus === 'failed' ? ( + + ) : null} + + {selectedTask?.loginStatus === 'waiting_login' || + selectedTask?.loginStatus === 'failed' || + selectedTask?.loginStatus === 'expired' ? ( + + + + + + + ) : null} + + {loginQrVisible ? ( + + + {loginQrImage ? ( + 虎牙登录二维码 + ) : ( + + )} + {loginInfo?.message || '等待扫码'} + {selectedTask.loginStatus === 'scanned' ? ( + 已扫码,请在 App 确认 + ) : null} + + + + ) : null} + + {bindingQrVisible ? ( + + + {bindingInfo?.qrImage ? ( + 游戏角色绑定二维码 + ) : ( + + )} + + {bindingInfo?.message || + config.bindingInstructions || + '请使用虎牙 App 扫码完成游戏角色绑定。'} + + {bindingInfo?.bindGameRole ? ( + + ) : null} + {selectedTask.bindingStatus === 'role_ready' ? ( + + ) : null} + + + ) : null} + + {selectedTask?.bindingStatus === 'confirmed' && + selectedTask.executionStatus !== 'completed' ? ( + + + + ) : null} +
+
+ ) +} + +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 +} diff --git a/apps/frontend/src/services/admin/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts index 0136ec42..a61b1ea5 100644 --- a/apps/frontend/src/services/admin/cost-center.ts +++ b/apps/frontend/src/services/admin/cost-center.ts @@ -362,6 +362,9 @@ export function saveAdminManualConfig(payload: { canonicalProductId?: number | null enabled?: boolean note?: string + semiAutoEnabled?: boolean + semiAutoPlatform?: '' | 'huya' | 'weibo' + semiAutoConfig?: Record }) { return apiPost('/api/v1/admin/cost-center/manual-configs', payload) } diff --git a/apps/frontend/src/services/admin/semi-auto.ts b/apps/frontend/src/services/admin/semi-auto.ts new file mode 100644 index 00000000..ece6c540 --- /dev/null +++ b/apps/frontend/src/services/admin/semi-auto.ts @@ -0,0 +1,41 @@ +import { apiGet, apiPost } from '@/lib/http' +import type { SemiAutoTaskDetail, SemiAutoTaskListResult } from '@/types/admin/semi-auto' + +export function fetchAdminSemiAutoTasks(params: Record = {}) { + return apiGet('/api/v1/admin/semi-auto/tasks', params) +} + +export function fetchAdminSemiAutoTask(taskId: number) { + return apiGet(`/api/v1/admin/semi-auto/tasks/${taskId}`) +} + +export function claimAdminSemiAutoTask(taskId: number) { + return apiPost(`/api/v1/admin/semi-auto/tasks/${taskId}/claim`, {}) +} + +export function syncAdminSemiAutoTask(taskId: number) { + return apiPost(`/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> + }, +) { + return apiPost(`/api/v1/admin/semi-auto/tasks/${taskId}/complete`, payload) +} + +export function cancelAdminSemiAutoTask(taskId: number, reason: string) { + return apiPost(`/api/v1/admin/semi-auto/tasks/${taskId}/cancel`, { reason }) +} diff --git a/apps/frontend/src/services/customer-fulfillment.ts b/apps/frontend/src/services/customer-fulfillment.ts new file mode 100644 index 00000000..6aeea706 --- /dev/null +++ b/apps/frontend/src/services/customer-fulfillment.ts @@ -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( + `/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`, + {}, + ) +} diff --git a/apps/frontend/src/styles/client.css b/apps/frontend/src/styles/client.css index 42e7c49f..5dadeaba 100644 --- a/apps/frontend/src/styles/client.css +++ b/apps/frontend/src/styles/client.css @@ -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%); +} diff --git a/apps/frontend/src/types/admin/cost-center.ts b/apps/frontend/src/types/admin/cost-center.ts index d11b3b40..dc5a3952 100644 --- a/apps/frontend/src/types/admin/cost-center.ts +++ b/apps/frontend/src/types/admin/cost-center.ts @@ -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 diff --git a/apps/frontend/src/types/admin/semi-auto.ts b/apps/frontend/src/types/admin/semi-auto.ts new file mode 100644 index 00000000..ec29529f --- /dev/null +++ b/apps/frontend/src/types/admin/semi-auto.ts @@ -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 + 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 + createdAt: string +} + +export type SemiAutoTaskListResult = { + items: SemiAutoTask[] + pagination: { page: number; pageSize: number; total: number } +} + +export type SemiAutoTaskDetail = { + task: SemiAutoTask + events: SemiAutoTaskEvent[] +} diff --git a/apps/frontend/src/types/customer-fulfillment.ts b/apps/frontend/src/types/customer-fulfillment.ts new file mode 100644 index 00000000..e0567e9e --- /dev/null +++ b/apps/frontend/src/types/customer-fulfillment.ts @@ -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 +} diff --git a/apps/frontend/src/utils/customer-fulfillment-link.ts b/apps/frontend/src/utils/customer-fulfillment-link.ts new file mode 100644 index 00000000..5fc0777b --- /dev/null +++ b/apps/frontend/src/utils/customer-fulfillment-link.ts @@ -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' +} diff --git a/apps/frontend/tests/customer-fulfillment-link.test.ts b/apps/frontend/tests/customer-fulfillment-link.test.ts new file mode 100644 index 00000000..ea553831 --- /dev/null +++ b/apps/frontend/tests/customer-fulfillment-link.test.ts @@ -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) +}) diff --git a/deploy/mac-dev.sh b/deploy/mac-dev.sh index aad4cc72..6a6b7944 100755 --- a/deploy/mac-dev.sh +++ b/deploy/mac-dev.sh @@ -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 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 93adc571..ac286475 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index eff95463..8cf421d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/统一订单履约与财务模型-权威版.md b/docs/统一订单履约与财务模型-权威版.md index 7d1d797c..e46059ae 100644 --- a/docs/统一订单履约与财务模型-权威版.md +++ b/docs/统一订单履约与财务模型-权威版.md @@ -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_*` 字段的删除迁移。