feat(worker-platform): 支持客户退款冲正并关闭订单

This commit is contained in:
yml2213
2026-11-05 17:15:41 +08:00
parent b72d5877da
commit fc928c6b18
15 changed files with 2129 additions and 6 deletions
@@ -21,6 +21,7 @@ export const ADMIN_PERMISSION_KEYS = [
'worker_order.assign',
'worker_order.return_to_hall',
'worker_order.cancel',
'worker_order.customer_refund',
'worker_order.cancel_share',
'worker_order.delete',
'worker_order.publish',
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
test('客户退款第一版覆盖范围与原子关闭动作已声明', async () => {
const source = await readFile(new URL('./customer-refund-service.ts', import.meta.url), 'utf8')
for (const status of [
'pending_material',
'unassigned',
'open',
'in_progress',
'pending_acceptance',
'problem',
]) {
assert.ok(source.includes(`'${status}'`), `缺少工单状态 ${status}`)
}
assert.ok(source.includes("pay_status = 'refunded'"))
assert.ok(source.includes("order_status = 'refunded'"))
assert.ok(source.includes('work_order_shares'))
assert.ok(source.includes('voucher_consume_jobs'))
assert.ok(source.includes('redeem_certificates'))
assert.ok(source.includes('findResumableRefundCancelOperation'))
assert.ok(source.includes('customer_refund_processing'))
assert.ok(source.includes("status: 'platform_approved'"))
assert.ok(source.includes('refundFee'))
assert.ok(source.includes('queryType: 2'))
assert.ok(
source.includes('afterSale.operate') || source.includes('approveDouyinMashidaAfterSale'),
)
})
test('抖音退款创建和关闭消息会进入自动发现白名单', async () => {
const [configSource, notifySource] = await Promise.all([
readFile(
new URL('../platforms/douyin-mashida/source-config-service.ts', import.meta.url),
'utf8',
),
readFile(new URL('../platforms/douyin-mashida/notify-service.ts', import.meta.url), 'utf8'),
])
assert.ok(configSource.includes('refund_RefundCreated'))
assert.ok(configSource.includes('refund_RefundClosed'))
assert.ok(notifySource.includes('bindPlatformAfterSalesToLocalOrder'))
})
test('客户退款操作表和平台售后表具备幂等唯一键', async () => {
const migration = await readFile(
new URL('../../db/migrations/134_customer_refund_operations.sql', import.meta.url),
'utf8',
)
assert.ok(migration.includes('uq_platform_after_sales_external'))
assert.ok(migration.includes('uq_refund_cancel_operations_external'))
assert.ok(migration.includes('afterSale.operate') || migration.includes('平台售后'))
assert.ok(migration.includes('平台同意、本地退款、业务关闭'))
})
test('客户退款使用独立权限,客服默认可用且可由管理员收回', async () => {
const [routeSource, migration] = await Promise.all([
readFile(new URL('../../routes/admin/worker-platform/orders.ts', import.meta.url), 'utf8'),
readFile(
new URL('../../db/migrations/134_customer_refund_operations.sql', import.meta.url),
'utf8',
),
])
assert.ok(routeSource.includes("requireAdminPermission('worker_order.customer_refund')"))
assert.ok(migration.includes("'worker_order.customer_refund'"))
assert.ok(migration.includes("('support')"))
})
@@ -635,6 +635,8 @@ export async function approveAndRecordKuaishouRefund(
refundedAt?: string | undefined
sellerId?: string | undefined
refundHandingWay?: unknown
status?: unknown
negotiateStatus?: unknown
desc?: string | undefined
},
session: AdminSession | null,
@@ -770,6 +772,8 @@ export async function approveAndRecordKuaishouRefund(
refundAmount: amountFen,
desc: input.desc || input.note || '',
refundHandingWay: input.refundHandingWay,
status: input.status,
negotiateStatus: input.negotiateStatus,
...(input.sellerId ? { sellerId: input.sellerId } : {}),
})
await updatePlatformRefundIntent(intent.id, 'platform_approved')
@@ -124,6 +124,43 @@ export async function cancelDouyinMashidaCouponVerify(
})
}
/**
* 同意用户售后(`afterSale.operate`,type=201)。
* update_time 必须使用 refund_RefundCreated 消息中的原值,平台用它做版本校验。
*/
export async function approveDouyinMashidaAfterSale(
input: {
afterSaleId: string
updateTime: string
reason?: string | undefined
remark?: string | undefined
} & DouyinMashidaCallOptions,
): Promise<JsonObject> {
const afterSaleId = String(input.afterSaleId || '').trim()
const updateTime = String(input.updateTime || '').trim()
if (!afterSaleId || !updateTime) {
throw new Error('抖音售后审核缺少 aftersale_id 或 update_time')
}
const response = await douyinMashidaGatewayRequest({
method: 'afterSale.operate',
params: {
type: 201,
items: JSON.stringify([
{
repair_type: 1,
aftersale_id: afterSaleId,
reason: String(input.reason || '').trim(),
remark: String(input.remark || '').trim(),
update_time: updateTime,
},
]),
},
config: input.config,
timeoutMs: input.timeoutMs,
})
return isPlainObject(response.data) ? response.data : { data: response.data }
}
/** 订单消息(把「去使用链接」发给买家)。 */
export async function sendDouyinMashidaOrderMessage(
input: {
@@ -61,7 +61,7 @@ test('data 解析:坏 JSON 不抛错,返回空对象', () => {
assert.deepEqual(parseMessageData(''), {})
})
test('幂等键:有订单号时按 topic:pub_time:p_id:s_ids 派生', () => {
test('幂等键:有订单号时按 topic:pub_time:p_id:s_ids:aftersale_id 派生', () => {
const eventId = buildDouyinMashidaMessageEventId({
topic: 'trade_TradePaid',
pubTime: '1780000000',
@@ -76,6 +76,23 @@ test('幂等键:有订单号时按 topic:pub_time:p_id:s_ids 派生', () => {
)
})
test('幂等键:同一订单同一秒的两笔售后不会互相去重', () => {
const first = buildDouyinMashidaMessageEventId({
topic: 'refund_RefundCreated',
pubTime: '1780000000',
data: '{}',
message: { p_id: '4837328765348567000', s_id: '4837328765348567001', aftersale_id: '1' },
})
const second = buildDouyinMashidaMessageEventId({
topic: 'refund_RefundCreated',
pubTime: '1780000000',
data: '{}',
message: { p_id: '4837328765348567000', s_id: '4837328765348567001', aftersale_id: '2' },
})
assert.notEqual(first, second)
})
test('幂等键:拿不到订单号时退回 data 摘要(同一内容稳定、内容变则键变)', () => {
const base = { topic: 'refund_RefundSuccess', pubTime: '1780000000' }
const first = buildDouyinMashidaMessageEventId({ ...base, data: '{"a":1}', message: {} })
@@ -2,6 +2,7 @@ import { isPlainObject, type JsonObject } from '../../../types/json.js'
import { insertWebhookEventOnce } from '../../../repositories/webhook-event-repo.js'
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
import { query } from '../../../db/client.js'
import { upsertOrderFromSource } from '../../order/order-service.js'
import {
ensureCertificateForOrderItem,
@@ -26,6 +27,11 @@ import {
} from './source-config-service.js'
import { verifyMessageSignature } from './sign.js'
import type { OrderItemRow, OrderRow } from '../../../types/repository/rows.js'
import {
bindPlatformAfterSalesToLocalOrder,
markPlatformAfterSaleState,
upsertPlatformAfterSale,
} from '../platform-after-sales-service.js'
/**
* 抖音码速达消息推送入口(验签 → 幂等 → 分派)。
@@ -113,14 +119,96 @@ export async function handleDouyinMashidaMessage(input: {
return handlePaidMessage({ message, topic, eventId, config })
}
if (topic === 'refund_RefundCreated') {
return handleAfterSaleCreated({ message, topic, eventId, pubTime })
}
if (topic === 'refund_RefundClosed') {
return handleAfterSaleClosed({ message, topic, eventId, pubTime })
}
if (topic === 'trade_TradeCanceled' || topic === 'refund_RefundSuccess') {
return handleInvalidateMessage({ message, topic, eventId })
return handleInvalidateMessage({ message, topic, eventId, pubTime })
}
logIntegration('[douyin-mashida/notify]', 'topic 暂无处理逻辑', { topic, eventId })
return { ok: true, duplicate: false, topic, action: 'noop', eventId }
}
async function handleAfterSaleCreated(input: {
message: JsonObject
topic: string
eventId: string
pubTime: string
}): Promise<DouyinMashidaMessageResult> {
const afterSaleId = String(input.message.aftersale_id || '').trim()
const parentOrderId = resolveDouyinMashidaParentOrderId(input.message)
if (!afterSaleId || !parentOrderId) {
throw createHttpError('抖音售后消息缺少 aftersale_id 或 p_id', {
statusCode: 400,
errorCode: 'douyin_mashida_after_sale_id_missing',
context: { topic: input.topic, eventId: input.eventId },
})
}
const order = await findLatestOrderByPlatformOrderId({
provider: DOUYIN_MASHIDA_PROVIDER,
platform: DOUYIN_MASHIDA_PLATFORM,
platformOrderId: parentOrderId,
})
const orderItems = order
? await queryOrderItemsForAfterSale(order.id, String(input.message.s_id || '').trim())
: []
const afterSale = await upsertPlatformAfterSale({
provider: DOUYIN_MASHIDA_PROVIDER,
platformOrderId: parentOrderId,
subOrderId: String(input.message.s_id || ''),
externalAfterSaleId: afterSaleId,
localOrderId: order?.id || null,
localOrderItemId: orderItems.length === 1 ? orderItems[0]?.id || null : null,
amountFen: Number(input.message.refund_amount || 0),
afterSaleStatus: numberOrNull(input.message.aftersale_status),
afterSaleType: numberOrNull(input.message.aftersale_type),
refundStatus: numberOrNull(input.message.refund_status),
platformUpdateTime: String(input.message.update_time || ''),
state: 'open',
raw: input.message,
appliedAt: numberToIso(input.message.apply_time),
eventAt: numberToIso(input.pubTime),
})
return {
ok: true,
duplicate: false,
topic: input.topic,
action: afterSale ? 'after-sale-opened' : 'after-sale-ignored',
eventId: input.eventId,
orderId: order?.id || null,
}
}
async function handleAfterSaleClosed(input: {
message: JsonObject
topic: string
eventId: string
pubTime: string
}): Promise<DouyinMashidaMessageResult> {
const afterSaleId = String(input.message.aftersale_id || '').trim()
if (afterSaleId) {
await markPlatformAfterSaleState({
provider: DOUYIN_MASHIDA_PROVIDER,
externalAfterSaleId: afterSaleId,
state: 'closed',
eventAt: numberToIso(input.pubTime),
})
}
return {
ok: true,
duplicate: false,
topic: input.topic,
action: afterSaleId ? 'after-sale-closed' : 'after-sale-close-id-missing',
eventId: input.eventId,
}
}
/** `trade_TradePaid`:拉订单详情 → 建单 → 生成券码并同步给平台。 */
async function handlePaidMessage(input: {
message: JsonObject
@@ -171,6 +259,14 @@ async function handlePaidMessage(input: {
}
}
const orderItems = result.orderItems || []
await bindPlatformAfterSalesToLocalOrder({
provider: DOUYIN_MASHIDA_PROVIDER,
platformOrderId: parentOrderId,
localOrderId: order.id,
localOrderItemId: orderItems.length === 1 ? orderItems[0]?.id || null : null,
})
// 只有真正付款的单才发券(详情可能显示已取消/退款)
if (String(order.pay_status || '') !== 'paid') {
logIntegration('[douyin-mashida/notify]', '订单非已支付状态,跳过发券', {
@@ -315,6 +411,7 @@ async function handleInvalidateMessage(input: {
message: JsonObject
topic: string
eventId: string
pubTime: string
}): Promise<DouyinMashidaMessageResult> {
const parentOrderId = resolveDouyinMashidaParentOrderId(input.message)
if (!parentOrderId) {
@@ -364,6 +461,15 @@ async function handleInvalidateMessage(input: {
localOrderId: order.id,
reason: input.topic,
})
const afterSaleId = String(input.message.refund_id || input.message.aftersale_id || '').trim()
if (afterSaleId) {
await markPlatformAfterSaleState({
provider: DOUYIN_MASHIDA_PROVIDER,
externalAfterSaleId: afterSaleId,
state: input.topic === 'refund_RefundSuccess' ? 'success' : 'closed',
eventAt: numberToIso(input.message.success_time || input.pubTime),
})
}
return {
ok: true,
@@ -376,6 +482,28 @@ async function handleInvalidateMessage(input: {
}
}
async function queryOrderItemsForAfterSale(orderId: number, subOrderId: string) {
const result = await query<{ id: number; sku_code: string }>(
`SELECT id, sku_code FROM order_items WHERE order_id = $1 ORDER BY id`,
[orderId],
)
if (subOrderId) {
const matched = result.rows.find((row) => String(row.sku_code || '').trim() === subOrderId)
if (matched) return [matched]
}
return result.rows.length === 1 ? result.rows : []
}
function numberOrNull(value: unknown) {
const parsed = Number(value)
return Number.isInteger(parsed) ? parsed : null
}
function numberToIso(value: unknown) {
const seconds = Number(value)
return Number.isFinite(seconds) && seconds > 0 ? new Date(seconds * 1000).toISOString() : null
}
/**
* 从 POST body 取 `data` 原文(必须与平台签名时使用的字符串完全一致)。
* 兼容两种发法:body 直接是 JSON 字符串;或 `data=<json>` 的 urlencoded 表单。
@@ -474,9 +602,12 @@ export function buildDouyinMashidaMessageEventId(input: {
const subIds = Array.isArray(input.message.s_ids)
? input.message.s_ids.map((id) => String(id || '').trim()).filter(Boolean)
: []
const afterSaleId = String(input.message.aftersale_id || input.message.refund_id || '').trim()
if (parentOrderId) {
return [input.topic, input.pubTime, parentOrderId, subIds.join(',')].join(':')
return [input.topic, input.pubTime, parentOrderId, subIds.join(','), afterSaleId]
.filter(Boolean)
.join(':')
}
const digest = createHash('sha256')
@@ -30,7 +30,7 @@ const DOUYIN_MASHIDA_CONFIG_KEY = APP_CONFIG_KEYS.douyinMashida
export const DOUYIN_MASHIDA_DEFAULT_BASE_URL = 'https://dy-openplatform-api.wanmahui.com'
/** 最小接入只处理这几条:付款进单、取消/退款作废券码。 */
/** 付款进单、售后自动发现、取消/退款作废券码。 */
/** 兑换成功后 order.message 的默认正文(可在后台覆盖)。 */
export const DEFAULT_AFTER_VERIFY_MESSAGE = '您的订单已兑换成功'
@@ -40,6 +40,8 @@ export const DEFAULT_CLAIM_MESSAGE_TEMPLATE = '您的订单已发货,请点击
export const DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS = [
'trade_TradePaid',
'trade_TradeCanceled',
'refund_RefundCreated',
'refund_RefundClosed',
'refund_RefundSuccess',
] as const
@@ -165,7 +167,13 @@ function normalizeHandledTopics(value: unknown): string[] {
}
const topics = [...new Set(value.map((item) => String(item || '').trim()).filter(Boolean))]
return topics.length > 0 ? topics : [...DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS]
return [
...new Set([
...(topics.length > 0 ? topics : DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS),
'refund_RefundCreated',
'refund_RefundClosed',
]),
]
}
function normalizePositiveInteger(value: unknown, fallback: number) {
@@ -0,0 +1,283 @@
import { query } from '../../db/client.js'
import { nowIso } from '../../utils/time.js'
import type { JsonObject } from '../../types/json.js'
export type PlatformAfterSaleState = 'open' | 'agreed' | 'refused' | 'closed' | 'success' | 'failed'
export type PlatformAfterSaleInput = {
provider: string
platformOrderId: string
subOrderId?: string
externalAfterSaleId: string
localOrderId?: number | null
localOrderItemId?: number | null
amountFen?: number
afterSaleStatus?: number | null
afterSaleType?: number | null
refundStatus?: number | null
platformUpdateTime?: string
state?: PlatformAfterSaleState
raw?: JsonObject
appliedAt?: string | null
eventAt?: string | null
}
export type PlatformAfterSale = {
id: number
provider: string
platformOrderId: string
subOrderId: string
externalAfterSaleId: string
localOrderId: number | null
localOrderItemId: number | null
amountFen: number
afterSaleStatus: number | null
afterSaleType: number | null
refundStatus: number | null
platformUpdateTime: string
state: PlatformAfterSaleState
raw: JsonObject
appliedAt: string | null
lastEventAt: string | null
createdAt: string
updatedAt: string
}
type PlatformAfterSaleRow = {
id: number
provider: string
platform_order_id: string
sub_order_id: string
external_after_sale_id: string
local_order_id: number | null
local_order_item_id: number | null
amount_fen: number | string
after_sale_status: number | null
after_sale_type: number | null
refund_status: number | null
platform_update_time: string
state: PlatformAfterSaleState
raw_json: unknown
applied_at: string | null
last_event_at: string | null
created_at: string
updated_at: string
}
export async function upsertPlatformAfterSale(
input: PlatformAfterSaleInput,
): Promise<PlatformAfterSale | null> {
const provider = normalizeText(input.provider, 80)
const platformOrderId = normalizeText(input.platformOrderId, 120)
const externalAfterSaleId = normalizeText(input.externalAfterSaleId, 120)
if (!provider || !platformOrderId || !externalAfterSaleId) return null
const now = nowIso()
const result = await query<PlatformAfterSaleRow>(
`
INSERT INTO platform_after_sales (
provider, platform_order_id, sub_order_id, external_after_sale_id,
local_order_id, local_order_item_id, amount_fen,
after_sale_status, after_sale_type, refund_status, platform_update_time,
state, raw_json, applied_at, last_event_at, created_at, updated_at
) VALUES (
$1, $2, $3, $4,
$5, $6, $7,
$8, $9, $10, $11,
$12, $13::jsonb, $14, $15, $16, $16
)
ON CONFLICT (provider, external_after_sale_id)
DO UPDATE SET
platform_order_id = EXCLUDED.platform_order_id,
sub_order_id = EXCLUDED.sub_order_id,
local_order_id = COALESCE(EXCLUDED.local_order_id, platform_after_sales.local_order_id),
local_order_item_id = COALESCE(
EXCLUDED.local_order_item_id,
platform_after_sales.local_order_item_id
),
amount_fen = CASE
WHEN EXCLUDED.amount_fen > 0 THEN EXCLUDED.amount_fen
ELSE platform_after_sales.amount_fen
END,
after_sale_status = COALESCE(
EXCLUDED.after_sale_status,
platform_after_sales.after_sale_status
),
after_sale_type = COALESCE(EXCLUDED.after_sale_type, platform_after_sales.after_sale_type),
refund_status = COALESCE(EXCLUDED.refund_status, platform_after_sales.refund_status),
platform_update_time = CASE
WHEN EXCLUDED.platform_update_time <> '' THEN EXCLUDED.platform_update_time
ELSE platform_after_sales.platform_update_time
END,
state = CASE
WHEN platform_after_sales.state IN ('agreed', 'success', 'closed', 'refused')
AND EXCLUDED.state = 'open'
THEN platform_after_sales.state
ELSE EXCLUDED.state
END,
raw_json = EXCLUDED.raw_json,
applied_at = COALESCE(EXCLUDED.applied_at, platform_after_sales.applied_at),
last_event_at = COALESCE(EXCLUDED.last_event_at, platform_after_sales.last_event_at),
updated_at = EXCLUDED.updated_at
RETURNING *
`,
[
provider,
platformOrderId,
normalizeText(input.subOrderId, 120),
externalAfterSaleId,
normalizeOptionalId(input.localOrderId),
normalizeOptionalId(input.localOrderItemId),
normalizeAmountFen(input.amountFen),
normalizeOptionalInteger(input.afterSaleStatus),
normalizeOptionalInteger(input.afterSaleType),
normalizeOptionalInteger(input.refundStatus),
normalizeText(input.platformUpdateTime, 80),
input.state || 'open',
JSON.stringify(input.raw || {}),
input.appliedAt || null,
input.eventAt || now,
now,
],
)
return result.rows[0] ? mapPlatformAfterSale(result.rows[0]) : null
}
export async function findOpenPlatformAfterSale(input: {
provider: string
localOrderId: number
localOrderItemId?: number | null
}): Promise<PlatformAfterSale | null> {
const params: unknown[] = [input.provider, input.localOrderId]
let itemFilter = ''
if (input.localOrderItemId) {
params.push(input.localOrderItemId)
itemFilter = `AND local_order_item_id = $${params.length}`
}
const result = await query<PlatformAfterSaleRow>(
`
SELECT *
FROM platform_after_sales
WHERE provider = $1
AND local_order_id = $2
AND state = 'open'
${itemFilter}
ORDER BY last_event_at DESC NULLS LAST, id DESC
LIMIT 1
`,
params,
)
return result.rows[0] ? mapPlatformAfterSale(result.rows[0]) : null
}
export async function findPlatformAfterSaleByExternalId(input: {
provider: string
externalAfterSaleId: string
}): Promise<PlatformAfterSale | null> {
const result = await query<PlatformAfterSaleRow>(
`
SELECT *
FROM platform_after_sales
WHERE provider = $1 AND external_after_sale_id = $2
LIMIT 1
`,
[input.provider, input.externalAfterSaleId],
)
return result.rows[0] ? mapPlatformAfterSale(result.rows[0]) : null
}
export async function bindPlatformAfterSalesToLocalOrder(input: {
provider: string
platformOrderId: string
localOrderId: number
localOrderItemId?: number | null
}): Promise<number> {
const result = await query(
`
UPDATE platform_after_sales
SET local_order_id = $3,
local_order_item_id = COALESCE(local_order_item_id, $4),
updated_at = $5
WHERE provider = $1
AND platform_order_id = $2
AND local_order_id IS NULL
`,
[
input.provider,
input.platformOrderId,
input.localOrderId,
normalizeOptionalId(input.localOrderItemId),
nowIso(),
],
)
return result.rowCount || 0
}
export async function markPlatformAfterSaleState(input: {
provider: string
externalAfterSaleId: string
state: PlatformAfterSaleState
eventAt?: string | null
}): Promise<void> {
await query(
`
UPDATE platform_after_sales
SET state = CASE
WHEN state = 'success' THEN state
WHEN state IN ('agreed', 'closed', 'refused') AND $3 = 'open' THEN state
ELSE $3
END,
last_event_at = $4,
updated_at = $4
WHERE provider = $1 AND external_after_sale_id = $2
`,
[input.provider, input.externalAfterSaleId, input.state, input.eventAt || nowIso()],
)
}
function mapPlatformAfterSale(row: PlatformAfterSaleRow): PlatformAfterSale {
return {
id: Number(row.id),
provider: row.provider,
platformOrderId: row.platform_order_id,
subOrderId: row.sub_order_id || '',
externalAfterSaleId: row.external_after_sale_id,
localOrderId: row.local_order_id ? Number(row.local_order_id) : null,
localOrderItemId: row.local_order_item_id ? Number(row.local_order_item_id) : null,
amountFen: Number(row.amount_fen || 0),
afterSaleStatus: row.after_sale_status,
afterSaleType: row.after_sale_type,
refundStatus: row.refund_status,
platformUpdateTime: row.platform_update_time || '',
state: row.state,
raw:
row.raw_json && typeof row.raw_json === 'object' && !Array.isArray(row.raw_json)
? (row.raw_json as JsonObject)
: {},
appliedAt: row.applied_at,
lastEventAt: row.last_event_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
function normalizeText(value: unknown, maxLength: number) {
return String(value ?? '')
.trim()
.slice(0, maxLength)
}
function normalizeOptionalId(value: unknown): number | null {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
}
function normalizeAmountFen(value: unknown): number {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0
}
function normalizeOptionalInteger(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null
const parsed = Number(value)
return Number.isInteger(parsed) ? parsed : null
}