新增:半自动履约工作台与虎牙客户授权流程
- 新增 153 半自动履约迁移、后台任务接口与客户履约接口 - 接入虎牙 Connector 登录会话、绑定与一次性 CK 注入票据 - 新增后台半自动履约页面和客户履约入口 - 完善手工履约配置、成本确认事务与运行时连接器配置 - 同步权威文档、环境示例、部署脚本和迁移展示逻辑
This commit is contained in:
@@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user