新增:半自动履约工作台与虎牙客户授权流程

- 新增 153 半自动履约迁移、后台任务接口与客户履约接口
- 接入虎牙 Connector 登录会话、绑定与一次性 CK 注入票据
- 新增后台半自动履约页面和客户履约入口
- 完善手工履约配置、成本确认事务与运行时连接器配置
- 同步权威文档、环境示例、部署脚本和迁移展示逻辑
This commit is contained in:
yml2213
2026-11-09 15:29:20 +08:00
parent 4159a435d5
commit cb9538a775
38 changed files with 3697 additions and 30 deletions
+4
View File
@@ -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)
+6
View File
@@ -61,6 +61,12 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
tokenTtlHours: 0,
},
semiAuto: {
connectorBaseUrl: '',
connectorApiKey: '',
connectorTimeoutMs: 15_000,
},
admin: {
sessionSecret: '',
// 后台:24h 空闲过期,登录后最多 7 天必须重新登录。
+3
View File
@@ -51,6 +51,9 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
stringEnv('STORAGE_OSS_SECRET_ACCESS_KEY', ['storage', 'oss', 'secretAccessKey']),
stringEnv('STORAGE_OSS_REGION', ['storage', 'oss', 'region']),
integerEnv('CLAIM_TOKEN_TTL_HOURS', ['orders', 'tokenTtlHours']),
stringEnv('SEMI_AUTO_CONNECTOR_BASE_URL', ['semiAuto', 'connectorBaseUrl']),
stringEnv('SEMI_AUTO_CONNECTOR_API_KEY', ['semiAuto', 'connectorApiKey']),
integerEnv('SEMI_AUTO_CONNECTOR_TIMEOUT_MS', ['semiAuto', 'connectorTimeoutMs']),
stringEnv('ADMIN_SESSION_SECRET', ['admin', 'sessionSecret']),
// 兼容旧变量:固定 TTL 时代按“绝对上限”理解,空闲窗口仍用新默认值并被绝对上限钳制。
integerEnv('ADMIN_SESSION_TTL_HOURS', ['admin', 'sessionAbsoluteTtlHours']),
@@ -77,6 +77,13 @@ export function validateRuntimeConfig(
config.database?.slowQueryThresholdMs,
{ min: 0 },
)
validateOptionalHttpUrl(issues, 'semiAuto.connectorBaseUrl', config.semiAuto?.connectorBaseUrl)
requireOptionalInteger(
issues,
'semiAuto.connectorTimeoutMs',
config.semiAuto?.connectorTimeoutMs,
{ min: 1 },
)
requireInteger(issues, 'storage.maxUploadSizeMb', config.storage?.maxUploadSizeMb, {
min: 1,
max: 100,
@@ -0,0 +1,108 @@
-- 153_semi_auto_fulfillment.sql
--
-- 人工履约渠道下的半自动任务:
-- 1. 人工 SKU 配置可显式启用虎牙/微博等半自动平台;
-- 2. 半自动任务绑定订单项和正式履约,不另建接单平台工单;
-- 3. 登录、绑定、执行状态正交保存,客户链接只按订单号展示必要信息;
-- 4. CK 不进入 order_site;由外部 Connector 加密托管,任务只保存平台账号快照。
ALTER TABLE manual_fulfillment_configs
ADD COLUMN IF NOT EXISTS semi_auto_enabled BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS semi_auto_platform TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS semi_auto_config_json JSONB NOT NULL DEFAULT '{}'::jsonb;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'chk_manual_fulfillment_configs_semi_auto_platform'
) THEN
ALTER TABLE manual_fulfillment_configs
ADD CONSTRAINT chk_manual_fulfillment_configs_semi_auto_platform
CHECK (semi_auto_platform IN ('', 'huya', 'weibo'));
END IF;
END $$;
CREATE TABLE IF NOT EXISTS semi_auto_tasks (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
order_item_id BIGINT NOT NULL UNIQUE REFERENCES order_items(id) ON DELETE CASCADE,
fulfillment_id BIGINT REFERENCES fulfillments(id) ON DELETE SET NULL,
manual_fulfillment_config_id BIGINT
REFERENCES manual_fulfillment_configs(id) ON DELETE SET NULL,
platform TEXT NOT NULL CHECK (platform IN ('huya', 'weibo')),
login_status TEXT NOT NULL DEFAULT 'waiting_login'
CHECK (login_status IN (
'waiting_login', 'qr_pending', 'scanned', 'authenticated', 'failed', 'expired'
)),
binding_status TEXT NOT NULL DEFAULT 'not_started'
CHECK (binding_status IN (
'not_started', 'qr_pending', 'scanned', 'role_ready', 'confirmed', 'failed', 'expired'
)),
execution_status TEXT NOT NULL DEFAULT 'waiting_customer'
CHECK (execution_status IN (
'waiting_customer', 'waiting_operator', 'processing', 'completed', 'failed', 'cancelled'
)),
connector_session_id TEXT NOT NULL DEFAULT '',
huya_uid TEXT NOT NULL DEFAULT '',
platform_nickname TEXT NOT NULL DEFAULT '',
bind_game_account TEXT NOT NULL DEFAULT '',
bind_game_role TEXT NOT NULL DEFAULT '',
bind_game_channel TEXT NOT NULL DEFAULT '',
target_quantity INTEGER NOT NULL DEFAULT 1 CHECK (target_quantity > 0),
completed_quantity INTEGER NOT NULL DEFAULT 0
CHECK (completed_quantity >= 0 AND completed_quantity <= target_quantity),
assigned_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
customer_link_opened_at TIMESTAMPTZ,
auth_started_at TIMESTAMPTZ,
authenticated_at TIMESTAMPTZ,
binding_started_at TIMESTAMPTZ,
bound_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
completion_note TEXT NOT NULL DEFAULT '',
external_order_no TEXT NOT NULL DEFAULT '',
completion_evidence_json JSONB NOT NULL DEFAULT '{}'::jsonb,
last_error TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_semi_auto_tasks_status
ON semi_auto_tasks (execution_status, platform, updated_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_semi_auto_tasks_order
ON semi_auto_tasks (order_id, id);
CREATE INDEX IF NOT EXISTS idx_semi_auto_tasks_operator
ON semi_auto_tasks (assigned_admin_user_id, execution_status, updated_at DESC)
WHERE assigned_admin_user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_semi_auto_tasks_connector
ON semi_auto_tasks (connector_session_id)
WHERE connector_session_id <> '';
CREATE TABLE IF NOT EXISTS semi_auto_task_events (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT NOT NULL REFERENCES semi_auto_tasks(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
actor_type TEXT NOT NULL DEFAULT 'system'
CHECK (actor_type IN ('system', 'customer', 'admin', 'connector')),
actor_id TEXT NOT NULL DEFAULT '',
from_login_status TEXT,
to_login_status TEXT,
from_binding_status TEXT,
to_binding_status TEXT,
from_execution_status TEXT,
to_execution_status TEXT,
detail_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_semi_auto_task_events_task
ON semi_auto_task_events (task_id, id DESC);
COMMENT ON TABLE semi_auto_tasks IS
'人工履约渠道下的半自动任务;绑定订单项和正式履约,不承载平台 CK';
COMMENT ON COLUMN semi_auto_tasks.connector_session_id IS
'外部平台 Connector 的短期登录/绑定会话 ID;CK 仅保存在 Connector 加密凭证域';
COMMENT ON COLUMN semi_auto_tasks.completion_evidence_json IS
'可选交付证据(截图 object_key、备注、外部单号等),不得保存 Cookie 或令牌';
COMMENT ON COLUMN manual_fulfillment_configs.semi_auto_config_json IS
'半自动履约展示与目标页配置;不得保存平台 CK 或运行时状态';
+2
View File
@@ -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)
+125
View File
@@ -0,0 +1,125 @@
import { Router } from 'express'
import {
cancelAdminSemiAutoTask,
claimAdminSemiAutoTask,
completeAdminSemiAutoTask,
consumeSemiAutoInjectionTicket,
createAdminBrowserSession,
getAdminSemiAutoTask,
listAdminSemiAutoTasks,
syncAdminSemiAutoTask,
} from '../../services/semi-auto/semi-auto-task-service.js'
import { bodyOf, sessionOf } from './request-helpers.js'
import { createJsonHandler, requireAdminRoles } from './session.js'
const router = Router()
router.use('/semi-auto', requireAdminRoles(['admin', 'operator', 'support']))
router.get(
'/semi-auto/tasks',
createJsonHandler((req) => listAdminSemiAutoTasks(req.query as Record<string, unknown>), {
successMessage: 'ok',
errorMessage: '读取半自动履约任务失败',
scope: '[admin/semi-auto/tasks]',
}),
)
router.get(
'/semi-auto/tasks/:taskId',
createJsonHandler((req) => getAdminSemiAutoTask(req.params.taskId), {
successMessage: 'ok',
errorMessage: '读取半自动履约任务详情失败',
scope: '[admin/semi-auto/tasks/:taskId]',
}),
)
router.post(
'/semi-auto/tasks/:taskId/claim',
createJsonHandler((req) => claimAdminSemiAutoTask(req.params.taskId, sessionOf(req)), {
successMessage: '任务已领取',
errorMessage: '领取半自动履约任务失败',
scope: '[admin/semi-auto/tasks/:taskId/claim]',
audit: (req) => ({
action: 'semi_auto_task_claimed',
targetType: 'semi_auto_task',
targetId: String(req.params.taskId || ''),
data: {},
}),
}),
)
router.post(
'/semi-auto/tasks/:taskId/sync',
createJsonHandler((req) => syncAdminSemiAutoTask(req.params.taskId), {
successMessage: '状态已刷新',
errorMessage: '刷新半自动履约状态失败',
scope: '[admin/semi-auto/tasks/:taskId/sync]',
}),
)
router.post(
'/semi-auto/tasks/:taskId/browser-session',
createJsonHandler(
(req) => createAdminBrowserSession(req.params.taskId, bodyOf(req).targetUrl, sessionOf(req)),
{
successMessage: '注入票据已生成',
errorMessage: '生成浏览器登录态失败',
scope: '[admin/semi-auto/tasks/:taskId/browser-session]',
audit: (req) => ({
action: 'semi_auto_browser_session_created',
targetType: 'semi_auto_task',
targetId: String(req.params.taskId || ''),
data: {},
}),
},
),
)
router.get(
'/semi-auto/injections/:ticket',
createJsonHandler((req) => consumeSemiAutoInjectionTicket(req.params.ticket), {
successMessage: 'ok',
errorMessage: '消费浏览器注入票据失败',
scope: '[admin/semi-auto/injections/:ticket]',
}),
)
router.post(
'/semi-auto/tasks/:taskId/complete',
createJsonHandler(
(req) => completeAdminSemiAutoTask(req.params.taskId, bodyOf(req), sessionOf(req)),
{
successMessage: '半自动履约已完成',
errorMessage: '完成半自动履约失败',
scope: '[admin/semi-auto/tasks/:taskId/complete]',
audit: (req) => ({
action: 'semi_auto_task_completed',
targetType: 'semi_auto_task',
targetId: String(req.params.taskId || ''),
data: {},
}),
},
),
)
router.post(
'/semi-auto/tasks/:taskId/cancel',
createJsonHandler(
(req) => cancelAdminSemiAutoTask(req.params.taskId, bodyOf(req), sessionOf(req)),
{
successMessage: '半自动履约任务已取消',
errorMessage: '取消半自动履约任务失败',
scope: '[admin/semi-auto/tasks/:taskId/cancel]',
audit: (req) => ({
action: 'semi_auto_task_cancelled',
targetType: 'semi_auto_task',
targetId: String(req.params.taskId || ''),
data: {},
}),
},
),
)
export default router
@@ -0,0 +1,97 @@
import { Router } from 'express'
import {
confirmPublicBinding,
listPublicSemiAutoTasks,
markCustomerLinkOpened,
pollPublicBinding,
pollPublicLogin,
startPublicBinding,
startPublicLogin,
} from '../services/semi-auto/semi-auto-task-service.js'
import { createRateLimitMiddleware, getParamRateLimitKey } from '../middleware/rate-limit.js'
import { createRouteHandler } from '../utils/http.js'
const router = Router()
const readLimit = createRateLimitMiddleware({
scope: 'customer-fulfillment:read',
windowMs: 60_000,
max: 240,
key: getParamRateLimitKey('orderNo'),
})
const writeLimit = createRateLimitMiddleware({
scope: 'customer-fulfillment:write',
windowMs: 60_000,
max: 60,
key: getParamRateLimitKey('orderNo'),
})
router.get(
'/:orderNo',
readLimit,
createRouteHandler((req) => listPublicSemiAutoTasks(req.params.orderNo), {
errorMessage: '读取订单履约信息失败',
scope: '[customer-fulfillment/:orderNo]',
}),
)
router.post(
'/:orderNo/tasks/:taskId/open',
writeLimit,
createRouteHandler((req) => markCustomerLinkOpened(req.params.orderNo, req.params.taskId), {
successMessage: 'ok',
errorMessage: '记录客户访问失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/open]',
}),
)
router.post(
'/:orderNo/tasks/:taskId/login/start',
writeLimit,
createRouteHandler((req) => startPublicLogin(req.params.orderNo, req.params.taskId), {
successMessage: '登录会话已创建',
errorMessage: '开始平台登录失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/login/start]',
}),
)
router.get(
'/:orderNo/tasks/:taskId/login/status',
readLimit,
createRouteHandler((req) => pollPublicLogin(req.params.orderNo, req.params.taskId), {
errorMessage: '刷新平台登录状态失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/login/status]',
}),
)
router.post(
'/:orderNo/tasks/:taskId/binding/start',
writeLimit,
createRouteHandler((req) => startPublicBinding(req.params.orderNo, req.params.taskId), {
successMessage: '绑定会话已创建',
errorMessage: '开始账号绑定失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/binding/start]',
}),
)
router.get(
'/:orderNo/tasks/:taskId/binding/status',
readLimit,
createRouteHandler((req) => pollPublicBinding(req.params.orderNo, req.params.taskId), {
errorMessage: '刷新账号绑定状态失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/binding/status]',
}),
)
router.post(
'/:orderNo/tasks/:taskId/binding/confirm',
writeLimit,
createRouteHandler((req) => confirmPublicBinding(req.params.orderNo, req.params.taskId), {
successMessage: '绑定已确认',
errorMessage: '确认账号绑定失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/binding/confirm]',
}),
)
export default router
@@ -0,0 +1,23 @@
import { Router } from 'express'
import { consumeSemiAutoInjectionTicket } from '../services/semi-auto/semi-auto-task-service.js'
import { createRateLimitMiddleware } from '../middleware/rate-limit.js'
import { createRouteHandler } from '../utils/http.js'
const router = Router()
router.get(
'/:ticket',
createRateLimitMiddleware({
scope: 'semi-auto-injection-consume',
windowMs: 60_000,
max: 30,
}),
createRouteHandler((req) => consumeSemiAutoInjectionTicket(req.params.ticket), {
successMessage: 'ok',
errorMessage: '消费浏览器注入票据失败',
scope: '[semi-auto-injections/:ticket]',
}),
)
export default router
@@ -41,6 +41,8 @@ import { productSourceFilterSql } from './cost-center-mappers.js'
export const MANUAL_PLATFORM_KEYS = ['nanhang', 'lewan', 'rock', 'fund_cash', 'on_account'] as const
export type ManualPlatformKey = (typeof MANUAL_PLATFORM_KEYS)[number]
export const SEMI_AUTO_PLATFORM_KEYS = ['huya', 'weibo'] as const
export type SemiAutoPlatform = (typeof SEMI_AUTO_PLATFORM_KEYS)[number]
/**
* 积分换算率(分/积分),**按平台不同**:
@@ -109,6 +111,9 @@ type ManualSkuRow = {
config_canonical_product_id: number | null
config_enabled: boolean | null
config_channels_json: unknown
config_semi_auto_enabled: boolean | null
config_semi_auto_platform: string | null
config_semi_auto_config_json: unknown
}
type ManualChannelConfig = {
@@ -344,7 +349,10 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
cfg.cost_mode AS config_cost_mode,
COALESCE(cfg.canonical_product_id, p.id) AS config_canonical_product_id,
cfg.enabled AS config_enabled,
cfg.channels_json AS config_channels_json
cfg.channels_json AS config_channels_json,
cfg.semi_auto_enabled AS config_semi_auto_enabled,
cfg.semi_auto_platform AS config_semi_auto_platform,
cfg.semi_auto_config_json AS config_semi_auto_config_json
FROM product_catalog p
JOIN internal_skus product_sku ON product_sku.id = p.internal_sku_id
LEFT JOIN LATERAL (
@@ -458,6 +466,7 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
)
const enabled = payload.enabled === false ? false : true
const note = optionalText(payload.note, 300)
const semiAuto = normalizeSemiAutoConfig(payload)
// 消耗积分为 0 = 成本不固定,客服确认履约时手填。
const costMode = primaryChannel.costMode
@@ -546,7 +555,11 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
`UPDATE manual_fulfillment_configs
SET sku_key = $2, canonical_product_id = $3, platform_key = $4,
credit_quantity = $5, cost_mode = $6, enabled = $7, note = $8,
channels_json = $9::jsonb, updated_at = $10
channels_json = $9::jsonb,
semi_auto_enabled = $10,
semi_auto_platform = $11,
semi_auto_config_json = $12::jsonb,
updated_at = $13
WHERE id = $1
RETURNING *`,
[
@@ -559,14 +572,22 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
enabled,
note,
JSON.stringify(channels),
semiAuto.enabled,
semiAuto.platform,
JSON.stringify(semiAuto.config),
now,
],
)
: await client.query(
`INSERT INTO manual_fulfillment_configs (
sku_key, canonical_product_id, platform_key, credit_quantity,
cost_mode, enabled, note, channels_json, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $9)
cost_mode, enabled, note, channels_json,
semi_auto_enabled, semi_auto_platform, semi_auto_config_json,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8::jsonb,
$9, $10, $11::jsonb, $12, $12
)
RETURNING *`,
[
skuKey,
@@ -577,6 +598,9 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
enabled,
note,
JSON.stringify(channels),
semiAuto.enabled,
semiAuto.platform,
JSON.stringify(semiAuto.config),
now,
],
)
@@ -1046,6 +1070,9 @@ export async function getManualFulfillmentConfig(skuKey: string) {
enabled: boolean
note: string
channels_json: unknown
semi_auto_enabled: boolean
semi_auto_platform: string
semi_auto_config_json: unknown
created_at: string
updated_at: string
}>(
@@ -1174,6 +1201,9 @@ function mapManualSku(row: ManualSkuRow, unitPrices: Record<ManualPlatformKey, n
? Number(row.config_canonical_product_id)
: null,
enabled: row.config_enabled !== false,
semiAutoEnabled: row.config_semi_auto_enabled === true,
semiAutoPlatform: String(row.config_semi_auto_platform || ''),
semiAutoConfig: normalizeStoredSemiAutoConfig(row.config_semi_auto_config_json),
channels: mapConfiguredChannels(channels, unitPrices),
}
: null,
@@ -1191,6 +1221,9 @@ function mapManualConfig(
enabled: boolean
note: string
channels_json: unknown
semi_auto_enabled: boolean
semi_auto_platform: string
semi_auto_config_json: unknown
created_at: string
updated_at: string
},
@@ -1211,6 +1244,9 @@ function mapManualConfig(
costFen: calculateManualCostFen(creditQuantity, unitPriceFen),
creditUnitPriceFen: unitPriceFen,
enabled: row.enabled,
semiAutoEnabled: row.semi_auto_enabled === true,
semiAutoPlatform: String(row.semi_auto_platform || ''),
semiAutoConfig: normalizeStoredSemiAutoConfig(row.semi_auto_config_json),
channels: mapConfiguredChannels(channels, unitPrices),
note: row.note || '',
createdAt: row.created_at,
@@ -1218,6 +1254,73 @@ function mapManualConfig(
}
}
function normalizeSemiAutoConfig(payload: JsonObject): {
enabled: boolean
platform: SemiAutoPlatform | ''
config: Record<string, unknown>
} {
const enabled = payload.semiAutoEnabled === true || payload.semi_auto_enabled === true
const platform = String(payload.semiAutoPlatform ?? payload.semi_auto_platform ?? '')
.trim()
.toLowerCase()
if (!enabled) {
return { enabled: false, platform: '', config: {} }
}
if (!(SEMI_AUTO_PLATFORM_KEYS as readonly string[]).includes(platform)) {
throw createHttpError('半自动履约平台无效', {
statusCode: 400,
errorCode: 'manual_config_semi_auto_platform_invalid',
})
}
const rawConfig = payload.semiAutoConfig ?? payload.semi_auto_config
const config =
rawConfig && typeof rawConfig === 'object' && !Array.isArray(rawConfig)
? (rawConfig as Record<string, unknown>)
: {}
return {
enabled: true,
platform: platform as SemiAutoPlatform,
config: {
pageTitle: String(config.pageTitle || '')
.trim()
.slice(0, 60),
pageDescription: String(config.pageDescription || '')
.trim()
.slice(0, 500),
loginInstructions: String(config.loginInstructions || '')
.trim()
.slice(0, 1000),
bindingInstructions: String(config.bindingInstructions || '')
.trim()
.slice(0, 1000),
browserTargets: Array.isArray(config.browserTargets)
? config.browserTargets
.filter((item): item is Record<string, unknown> =>
Boolean(item && typeof item === 'object'),
)
.slice(0, 10)
.map((item) => ({
key: String(item.key || '')
.trim()
.slice(0, 40),
label: String(item.label || '')
.trim()
.slice(0, 60),
url: String(item.url || '')
.trim()
.slice(0, 1000),
}))
.filter((item) => item.key && item.url)
: [],
},
}
}
function normalizeStoredSemiAutoConfig(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
return value as Record<string, unknown>
}
function normalizePlatformKey(value: unknown): ManualPlatformKey {
const text = String(value || '').trim()
if (!MANUAL_PLATFORM_KEYS.includes(text as ManualPlatformKey)) {
@@ -66,10 +66,19 @@ test('人工确认必须单事务:扣款与快照同事务,且使用 with-cl
const end = source.indexOf('async function resolveFundAccountId')
const body = source.slice(start, end)
assert.ok(body.includes('withTransaction('), '人工确认必须包在事务里')
const executeStart = body.indexOf('const execute = async (client: PoolClient)')
const executeEnd = body.indexOf('const result = options.client', executeStart)
const transactionalBody = body.slice(executeStart, executeEnd)
assert.ok(executeStart >= 0 && executeEnd > executeStart, '人工确认必须抽出事务执行体')
assert.ok(
body.indexOf('withTransaction(') < body.indexOf('writeFundExpense(client'),
'备用金扣款必须在同一事务内执行',
body.includes(
'options.client ? await execute(options.client) : await withTransaction(execute)',
),
'默认路径必须包在事务里,外部事务必须复用调用方 client',
)
assert.ok(
transactionalBody.includes('writeFundExpense(client'),
'备用金扣款必须在调用方事务内执行',
)
assert.ok(
body.includes('consumePlatformCreditWithClient(client'),
@@ -65,10 +65,13 @@ type ManualConfiguredChannel = {
enabled: boolean
}
async function loadManualPlatformUnitPrices(): Promise<Record<ManualExecutionPlatformKey, number>> {
const result = await query<{ profile_key: string; config_json: Record<string, unknown> }>(
`SELECT profile_key, config_json FROM fulfillment_profiles WHERE profile_key LIKE 'manual_%'`,
)
async function loadManualPlatformUnitPrices(
client?: Pick<PoolClient, 'query'>,
): Promise<Record<ManualExecutionPlatformKey, number>> {
const sql = `SELECT profile_key, config_json FROM fulfillment_profiles WHERE profile_key LIKE 'manual_%'`
const result = client
? await client.query<{ profile_key: string; config_json: Record<string, unknown> }>(sql)
: await query<{ profile_key: string; config_json: Record<string, unknown> }>(sql)
const prices: Record<ManualExecutionPlatformKey, number> = {
nanhang: 1,
lewan: 1,
@@ -299,17 +302,26 @@ export async function listManualFulfillmentPending(
* 固定成本(消耗积分 > 0):直接用配置值,零输入。
* 成本不固定(消耗积分 = 0):客服必须填实际成本金额。
*/
export async function confirmManualFulfillment(payload: JsonObject, session: AdminSession) {
export type ConfirmManualFulfillmentOptions = {
/** 由调用方提供事务连接;此时调用方负责提交/回滚和提交后的报表刷新。 */
client?: PoolClient
}
export async function confirmManualFulfillment(
payload: JsonObject,
session: AdminSession,
options: ConfirmManualFulfillmentOptions = {},
) {
const orderItemId = normalizeRequiredId(
payload.orderItemId ?? payload.order_item_id,
'请选择订单项',
)
const now = nowIso()
const platformUnitPrices = await loadManualPlatformUnitPrices()
// 钱与成本必须同一事务:先锁订单项、复查未履约,再扣款、最后写快照。
// 任何一步失败(余额不足、成本版本缺失、重复快照)都整体回滚,不会留下「扣了钱没记成本」。
const result = await withTransaction(async (client) => {
const execute = async (client: PoolClient) => {
const now = nowIso()
const platformUnitPrices = await loadManualPlatformUnitPrices(client)
// 配置可能晚于订单创建:确认前按 SKU 再匹配一次(与定时 rematch 同口径),
// 保证「配置好之后的新单」不需要人工再点一次「重新匹配」。
await matchManualFulfillmentConfigWithClient(client, orderItemId)
@@ -716,14 +728,26 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
onAccountBillId,
costPending: onAccountCostPending,
}
})
}
await scheduleVoucherConsumeForOrderItems([orderItemId], 'order_received').catch(() => undefined)
// 成本已落快照,立刻重算该业务日的事实与总账;失败不阻断确认(定时任务会兜底)。
await refreshFactsForOrderItem(orderItemId)
const result = options.client ? await execute(options.client) : await withTransaction(execute)
if (!options.client) {
await runManualFulfillmentPostCommit(orderItemId)
}
return result
}
/**
* 成本事务提交后的非关键旁路动作。
*
* 调用方若把 confirmManualFulfillment 纳入更大的事务,必须在提交后显式调用本函数。
* 这里的失败只影响报表时效,不反写已经提交的成本事实。
*/
export async function runManualFulfillmentPostCommit(orderItemId: number): Promise<void> {
await scheduleVoucherConsumeForOrderItems([orderItemId], 'order_received').catch(() => undefined)
await refreshFactsForOrderItem(orderItemId)
}
/**
* 人工确认后按「订单业务日期」重算销售事实与总账。
*
@@ -191,8 +191,13 @@ export async function ensureCarrierForOrderItem(input: {
}
if (channel === 'manual') {
const config = await query<{ id: number; canonical_product_id: number | null }>(
`SELECT id, canonical_product_id
const config = await query<{
id: number
canonical_product_id: number | null
semi_auto_enabled: boolean
semi_auto_platform: string
}>(
`SELECT id, canonical_product_id, semi_auto_enabled, semi_auto_platform
FROM manual_fulfillment_configs
WHERE canonicalize_internal_sku(sku_key) = resolve_internal_sku($1, $2, $3)
AND enabled = TRUE
@@ -213,6 +218,14 @@ export async function ensureCarrierForOrderItem(input: {
],
onlyWhenCanonicalProductNull: true,
})
if (
row.semi_auto_enabled === true &&
['huya', 'weibo'].includes(String(row.semi_auto_platform || ''))
) {
const { ensureSemiAutoTasksForOrder } =
await import('../semi-auto/semi-auto-task-service.js')
await ensureSemiAutoTasksForOrder(order.platform_order_id)
}
return { ready: true, created: false, reason: 'manual_config_ready', message: '' }
}
return {
@@ -0,0 +1,191 @@
import { runtimeConfig } from '../../config/runtime.js'
import { createHttpError } from '../../utils/http.js'
export type ConnectorLoginSession = {
sessionId: string
status: 'waiting_scan' | 'scanned' | 'authenticated' | 'expired' | 'failed'
qrContent: string
expiresAt: string
huyaUid: string
nickname: string
hasCredential: boolean
message: string
}
export type ConnectorBindingSession = {
sessionId: string
status:
| 'not_started'
| 'qr_pending'
| 'scanned'
| 'role_ready'
| 'confirmed'
| 'expired'
| 'failed'
qrImage: string
qrExpiresAt: string
bindGameAccount: string
bindGameRole: string
bindGameChannel: string
message: string
}
export type ConnectorBrowserTicket = {
ticket: string
expiresAt: string
targetUrl: string
}
export type ConnectorInjectionPayload = {
platform: 'huya' | 'weibo'
targetUrl: string
expiresAt: string
cookies: Array<{
name: string
value: string
domain: string
path: string
secure: boolean
}>
}
type ConnectorRequestOptions = {
method?: 'GET' | 'POST' | 'DELETE'
body?: unknown
}
function connectorBaseUrl() {
return String(runtimeConfig.semiAuto?.connectorBaseUrl || '')
.trim()
.replace(/\/+$/, '')
}
function connectorApiKey() {
return String(runtimeConfig.semiAuto?.connectorApiKey || '').trim()
}
export function isSemiAutoConnectorConfigured() {
return Boolean(connectorBaseUrl() && connectorApiKey())
}
async function callConnector<T>(path: string, options: ConnectorRequestOptions = {}): Promise<T> {
const baseUrl = connectorBaseUrl()
const apiKey = connectorApiKey()
if (!baseUrl || !apiKey) {
throw createHttpError('半自动履约 Connector 尚未配置', {
statusCode: 503,
errorCode: 'semi_auto_connector_not_configured',
})
}
const timeoutMs = Math.max(1000, Number(runtimeConfig.semiAuto?.connectorTimeoutMs || 15_000))
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const requestInit: RequestInit = {
method: options.method || 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Semi-Auto-Key': apiKey,
},
signal: controller.signal,
}
if (options.body !== undefined) requestInit.body = JSON.stringify(options.body)
const response = await fetch(`${baseUrl}${path}`, requestInit)
const text = await response.text()
let payload: unknown = null
try {
payload = text ? JSON.parse(text) : null
} catch {
payload = null
}
if (!response.ok) {
const detail =
payload && typeof payload === 'object'
? String((payload as Record<string, unknown>).detail || '')
: ''
throw createHttpError(detail || `半自动履约 Connector 请求失败(${response.status})`, {
statusCode: 502,
errorCode: 'semi_auto_connector_request_failed',
})
}
return payload as T
} catch (error) {
if (error && typeof error === 'object' && 'statusCode' in error) throw error
if (error instanceof Error && error.name === 'AbortError') {
throw createHttpError('半自动履约 Connector 请求超时', {
statusCode: 504,
errorCode: 'semi_auto_connector_timeout',
})
}
throw createHttpError(
error instanceof Error ? error.message : '半自动履约 Connector 请求失败',
{
statusCode: 502,
errorCode: 'semi_auto_connector_request_failed',
},
)
} finally {
clearTimeout(timer)
}
}
export async function createHuyaLoginSession(input: {
taskId: number
orderNo: string
}): Promise<ConnectorLoginSession> {
return callConnector<ConnectorLoginSession>('/api/internal/huya-semi-auto/sessions', {
method: 'POST',
body: input,
})
}
export async function getHuyaLoginSession(sessionId: string): Promise<ConnectorLoginSession> {
return callConnector<ConnectorLoginSession>(
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}`,
)
}
export async function startHuyaBinding(sessionId: string): Promise<ConnectorBindingSession> {
return callConnector<ConnectorBindingSession>(
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}/binding`,
{ method: 'POST', body: {} },
)
}
export async function getHuyaBinding(sessionId: string): Promise<ConnectorBindingSession> {
return callConnector<ConnectorBindingSession>(
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}/binding`,
)
}
export async function confirmHuyaBinding(sessionId: string): Promise<ConnectorBindingSession> {
return callConnector<ConnectorBindingSession>(
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}/binding/confirm`,
{ method: 'POST', body: {} },
)
}
export async function createHuyaBrowserTicket(input: {
sessionId: string
targetUrl?: string
}): Promise<ConnectorBrowserTicket> {
return callConnector<ConnectorBrowserTicket>(
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(input.sessionId)}/browser-ticket`,
{ method: 'POST', body: { targetUrl: input.targetUrl || '' } },
)
}
export async function consumeHuyaBrowserTicket(ticket: string): Promise<ConnectorInjectionPayload> {
return callConnector<ConnectorInjectionPayload>(
`/api/internal/huya-semi-auto/browser-tickets/${encodeURIComponent(ticket)}`,
)
}
export async function revokeHuyaSession(sessionId: string): Promise<void> {
await callConnector<{ ok: boolean }>(
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}`,
{ method: 'DELETE' },
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,341 @@
/**
* 半自动任务真实 PostgreSQL 集成测试。
*
* SEMI_AUTO_TASK_TEST_DATABASE_URL=postgres://... \
* npx tsx --test src/services/semi-auto/semi-auto-task.integration.test.ts
*/
import assert from 'node:assert/strict'
import test from 'node:test'
const TEST_DATABASE_URL = String(process.env.SEMI_AUTO_TASK_TEST_DATABASE_URL || '').trim()
test(
'人工渠道半自动任务可由客户入口和后台列表幂等回填',
{ skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false },
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
const { closeDb, query } = await import('../../db/client.js')
const { ensureSemiAutoTasksForOrder, listAdminSemiAutoTasks, listPublicSemiAutoTasks } =
await import('./semi-auto-task-service.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const orderNo = `IT-SEMI-AUTO-${suffix}`
const skuKey = `半自动测试-${suffix}`
let orderId = 0
let configId = 0
try {
const order = await query<{ id: number }>(
`
INSERT INTO orders (
provider, platform, shop_id, shop_name, platform_order_id,
order_status, pay_status, total_amount, paid_at, created_at, updated_at
) VALUES (
'91kaquan', 'kuaishou-91', $1, '半自动测试店', $2,
'paid', 'paid', 1000, NOW(), NOW(), NOW()
) RETURNING id
`,
[`semi-auto-shop-${suffix}`, orderNo],
)
orderId = Number(order.rows[0]?.id || 0)
assert.ok(orderId > 0)
const item = await query<{ id: number }>(
`
INSERT INTO order_items (
order_id, sku_code, sku_name, quantity, spec_json, item_snapshot_json,
revenue_amount_fen, revenue_basis, created_at, updated_at
) VALUES (
$1, $2, $3, 1, $4::jsonb, $4::jsonb,
1000, 'order_total', NOW(), NOW()
) RETURNING id
`,
[orderId, `semi-${suffix}`, skuKey, JSON.stringify({ skuNick: skuKey })],
)
const orderItemId = Number(item.rows[0]?.id || 0)
assert.ok(orderItemId > 0)
const fulfillment = await query<{ id: number }>(
'SELECT id FROM fulfillments WHERE order_item_id = $1 LIMIT 1',
[orderItemId],
)
const fulfillmentId = Number(fulfillment.rows[0]?.id || 0)
assert.ok(fulfillmentId > 0)
const config = await query<{ id: number }>(
`
INSERT INTO manual_fulfillment_configs (
sku_key, platform_key, credit_quantity, cost_mode, enabled, note,
channels_json, semi_auto_enabled, semi_auto_platform,
semi_auto_config_json, created_at, updated_at
) VALUES (
$1, 'fund_cash', 100, 'fixed', TRUE, '集成测试',
'[{"platformKey":"fund_cash","creditQuantity":100,"costMode":"fixed","enabled":true}]'::jsonb,
TRUE, 'huya', '{"pageTitle":"虎牙授权测试"}'::jsonb,
NOW(), NOW()
) RETURNING id
`,
[skuKey],
)
configId = Number(config.rows[0]?.id || 0)
assert.ok(configId > 0)
await query(
`
UPDATE fulfillments
SET channel = 'manual', status = 'assigned', fulfillment_config_id = $2,
source = 'manual_confirm', updated_at = NOW()
WHERE id = $1
`,
[fulfillmentId, configId],
)
// 客服工作台先打开时也必须看到任务,不能依赖客户先访问 `/c/订单号`。
const adminList = await listAdminSemiAutoTasks({ keyword: orderNo })
assert.equal(adminList.pagination.total, 1)
assert.equal(adminList.items[0]?.orderNo, orderNo)
const first = await ensureSemiAutoTasksForOrder(orderNo)
const second = await ensureSemiAutoTasksForOrder(orderNo)
assert.equal(first.createdIds.length, 0)
assert.equal(second.createdIds.length, 0)
const tasks = await listPublicSemiAutoTasks(orderNo)
assert.equal(tasks.length, 1)
assert.equal(tasks[0]?.orderItemId, orderItemId)
assert.equal(tasks[0]?.platform, 'huya')
assert.equal(tasks[0]?.orderNo, orderNo)
assert.equal(tasks[0]?.quantity, 1)
assert.equal(tasks[0]?.semiAutoConfig.pageTitle, '虎牙授权测试')
} finally {
if (orderId > 0) await query('DELETE FROM orders WHERE id = $1', [orderId])
if (configId > 0) {
await query('DELETE FROM manual_fulfillment_configs WHERE id = $1', [configId])
}
await closeDb()
}
},
)
test(
'半自动完成失败会连同财务确认一起回滚,成功后重复完成不重复扣成本',
{ skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false },
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
const { closeDb, query } = await import('../../db/client.js')
const { completeAdminSemiAutoTask, ensureSemiAutoTasksForOrder } =
await import('./semi-auto-task-service.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const orderNo = `IT-SEMI-AUTO-ATOMIC-${suffix}`
const skuKey = `半自动原子性-${suffix}`
const triggerName = `trg_semi_auto_atomic_${suffix}`
const functionName = `fn_semi_auto_atomic_${suffix}`
let orderId = 0
let configId = 0
let adminId = 0
let triggerCreated = false
try {
const admin = await query<{ id: number }>(
`INSERT INTO admin_users (username, password_hash, role, status, created_at, updated_at)
VALUES ($1, 'x', 'support', 'active', NOW(), NOW()) RETURNING id`,
[`it_semi_auto_admin_${suffix}`],
)
adminId = Number(admin.rows[0]?.id || 0)
assert.ok(adminId > 0)
const order = await query<{ id: number }>(
`
INSERT INTO orders (
provider, platform, shop_id, shop_name, platform_order_id,
order_status, pay_status, total_amount, paid_at, created_at, updated_at
) VALUES (
'91kaquan', 'kuaishou-91', $1, '半自动原子性测试店', $2,
'paid', 'paid', 1000, NOW(), NOW(), NOW()
) RETURNING id
`,
[`semi-auto-atomic-shop-${suffix}`, orderNo],
)
orderId = Number(order.rows[0]?.id || 0)
assert.ok(orderId > 0)
const item = await query<{ id: number }>(
`
INSERT INTO order_items (
order_id, sku_code, sku_name, quantity, spec_json, item_snapshot_json,
revenue_amount_fen, revenue_basis, created_at, updated_at
) VALUES (
$1, $2, $3, 1, $4::jsonb, $4::jsonb,
1000, 'order_total', NOW(), NOW()
) RETURNING id
`,
[orderId, `semi-atomic-${suffix}`, skuKey, JSON.stringify({ skuNick: skuKey })],
)
const orderItemId = Number(item.rows[0]?.id || 0)
assert.ok(orderItemId > 0)
const fulfillment = await query<{ id: number }>(
'SELECT id FROM fulfillments WHERE order_item_id = $1 LIMIT 1',
[orderItemId],
)
const fulfillmentId = Number(fulfillment.rows[0]?.id || 0)
assert.ok(fulfillmentId > 0)
const config = await query<{ id: number }>(
`
INSERT INTO manual_fulfillment_configs (
sku_key, platform_key, credit_quantity, cost_mode, enabled, note,
channels_json, semi_auto_enabled, semi_auto_platform,
semi_auto_config_json, created_at, updated_at
) VALUES (
$1, 'on_account', 0, 'variable', TRUE, '原子性集成测试',
'[{"platformKey":"on_account","creditQuantity":0,"costMode":"variable","enabled":true}]'::jsonb,
TRUE, 'huya', '{}'::jsonb, NOW(), NOW()
) RETURNING id
`,
[skuKey],
)
configId = Number(config.rows[0]?.id || 0)
assert.ok(configId > 0)
await query(
`
UPDATE fulfillments
SET channel = 'manual', status = 'assigned', fulfillment_config_id = $2,
source = 'manual_confirm', updated_at = NOW()
WHERE id = $1
`,
[fulfillmentId, configId],
)
const ensured = await ensureSemiAutoTasksForOrder(orderNo)
const taskId = Number(ensured.createdIds[0] || 0)
assert.ok(taskId > 0)
await query(
`
UPDATE semi_auto_tasks
SET connector_session_id = $2,
login_status = 'authenticated',
binding_status = 'confirmed',
execution_status = 'waiting_operator',
authenticated_at = NOW(),
bound_at = NOW(),
updated_at = NOW()
WHERE id = $1
`,
[taskId, `it-session-${suffix}`],
)
await query(
`
CREATE OR REPLACE FUNCTION ${functionName}() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.execution_status = 'completed' THEN
RAISE EXCEPTION 'forced semi auto completion failure';
END IF;
RETURN NEW;
END;
$$
`,
)
await query(
`
CREATE TRIGGER ${triggerName}
BEFORE UPDATE OF execution_status ON semi_auto_tasks
FOR EACH ROW EXECUTE FUNCTION ${functionName}()
`,
)
triggerCreated = true
const session = {
userId: adminId,
role: 'support',
displayName: '半自动原子性客服',
} as never
await assert.rejects(
() =>
completeAdminSemiAutoTask(
taskId,
{ costFen: 123, note: '触发任务更新失败以验证回滚' },
session,
),
/forced semi auto completion failure/,
)
const rolledBack = await query<{
execution_status: string
snapshot_count: number
bill_count: number
fulfillment_channel: string
}>(
`
SELECT
task.execution_status,
(SELECT COUNT(*)::int FROM cost_snapshots cs WHERE cs.order_item_id = $2) AS snapshot_count,
(SELECT COUNT(*)::int FROM on_account_bills oab WHERE oab.order_item_id = $2) AS bill_count,
f.channel AS fulfillment_channel
FROM semi_auto_tasks task
JOIN fulfillments f ON f.id = task.fulfillment_id
WHERE task.id = $1
`,
[taskId, orderItemId],
)
assert.equal(rolledBack.rows[0]?.execution_status, 'waiting_operator')
assert.equal(Number(rolledBack.rows[0]?.snapshot_count), 0)
assert.equal(Number(rolledBack.rows[0]?.bill_count), 0)
assert.equal(rolledBack.rows[0]?.fulfillment_channel, 'manual')
await query(`DROP TRIGGER ${triggerName} ON semi_auto_tasks`)
triggerCreated = false
await query(`DROP FUNCTION ${functionName}()`)
const completed = await completeAdminSemiAutoTask(
taskId,
{ costFen: 123, note: '事务重试成功' },
session,
)
assert.equal(completed.task.executionStatus, 'completed')
const afterCompletion = await query<{ snapshot_count: number; bill_count: number }>(
`
SELECT
(SELECT COUNT(*)::int FROM cost_snapshots cs WHERE cs.order_item_id = $1) AS snapshot_count,
(SELECT COUNT(*)::int FROM on_account_bills oab WHERE oab.order_item_id = $1) AS bill_count
`,
[orderItemId],
)
assert.equal(Number(afterCompletion.rows[0]?.snapshot_count), 1)
assert.equal(Number(afterCompletion.rows[0]?.bill_count), 1)
await completeAdminSemiAutoTask(taskId, { costFen: 123 }, session)
const afterDuplicate = await query<{ snapshot_count: number }>(
'SELECT COUNT(*)::int AS snapshot_count FROM cost_snapshots WHERE order_item_id = $1',
[orderItemId],
)
assert.equal(Number(afterDuplicate.rows[0]?.snapshot_count), 1)
} finally {
if (triggerCreated) {
await query(`DROP TRIGGER IF EXISTS ${triggerName} ON semi_auto_tasks`)
}
await query(`DROP FUNCTION IF EXISTS ${functionName}()`)
if (orderId > 0) {
await query(
'DELETE FROM cost_snapshots WHERE order_item_id IN (SELECT id FROM order_items WHERE order_id = $1)',
[orderId],
)
await query(
'DELETE FROM on_account_bills WHERE order_item_id IN (SELECT id FROM order_items WHERE order_id = $1)',
[orderId],
)
await query(
'DELETE FROM cost_exceptions WHERE subject_id IN (SELECT id FROM order_items WHERE order_id = $1)',
[orderId],
)
await query('DELETE FROM orders WHERE id = $1', [orderId])
}
// 已过账总账凭证按财务规则不可物理删除;测试只清理可重建销售投影。
await query(
`DELETE FROM sales_daily_facts
WHERE stat_date = (NOW() AT TIME ZONE 'Asia/Shanghai')::date`,
)
if (configId > 0) {
await query('DELETE FROM manual_fulfillment_configs WHERE id = $1', [configId])
}
if (adminId > 0) {
await query('DELETE FROM admin_users WHERE id = $1', [adminId])
}
await closeDb()
}
},
)
+5
View File
@@ -61,6 +61,11 @@ export type RuntimeConfig = {
claimBaseUrl: string
tokenTtlHours: number
}
semiAuto: {
connectorBaseUrl: string
connectorApiKey: string
connectorTimeoutMs: number
}
admin: {
sessionSecret: string
/** 后台空闲多久未访问即失效(每次访问滑动续期)。 */