feat(worker-platform): 支持客户退款冲正并关闭订单
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
-- 134_customer_refund_operations.sql
|
||||
--
|
||||
-- 客户退款编排:
|
||||
-- 1. 平台售后单落库,支持抖音从 refund_RefundCreated 消息自动发现;
|
||||
-- 2. 一键退款操作记录平台调用、本地退款和业务关闭步骤,保证可重试且不重复退款。
|
||||
|
||||
INSERT INTO admin_permissions (permission_key, permission_name, permission_group, description)
|
||||
VALUES (
|
||||
'worker_order.customer_refund',
|
||||
'客户退款并关闭订单',
|
||||
'接单管理',
|
||||
'同意平台用户售后,冲正核销,并关闭订单关联履约、资料和领取任务'
|
||||
)
|
||||
ON CONFLICT (permission_key) DO UPDATE SET
|
||||
permission_name = EXCLUDED.permission_name,
|
||||
permission_group = EXCLUDED.permission_group,
|
||||
description = EXCLUDED.description,
|
||||
updated_at = NOW();
|
||||
|
||||
-- 客服和运营默认具备前台退款处置能力;高风险管理员仍可在角色权限里收回。
|
||||
INSERT INTO admin_role_permissions (role_key, permission_key)
|
||||
SELECT role_key, 'worker_order.customer_refund'
|
||||
FROM (VALUES ('admin'), ('operator'), ('support')) AS roles(role_key)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform_after_sales (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
platform_order_id TEXT NOT NULL,
|
||||
sub_order_id TEXT NOT NULL DEFAULT '',
|
||||
external_after_sale_id TEXT NOT NULL,
|
||||
local_order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
|
||||
local_order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL,
|
||||
amount_fen BIGINT NOT NULL DEFAULT 0 CHECK (amount_fen >= 0),
|
||||
after_sale_status INTEGER,
|
||||
after_sale_type INTEGER,
|
||||
refund_status INTEGER,
|
||||
platform_update_time TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT 'open'
|
||||
CHECK (state IN ('open', 'agreed', 'refused', 'closed', 'success', 'failed')),
|
||||
raw_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
applied_at TIMESTAMPTZ,
|
||||
last_event_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_platform_after_sales_external
|
||||
ON platform_after_sales(provider, external_after_sale_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_after_sales_order
|
||||
ON platform_after_sales(provider, platform_order_id, state, last_event_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_after_sales_local_order
|
||||
ON platform_after_sales(local_order_id, state, last_event_at DESC)
|
||||
WHERE local_order_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE platform_after_sales IS
|
||||
'平台电商售后单:抖音由退款消息落库,供后台自动选择待处理退款;不是打手履约售后单';
|
||||
COMMENT ON COLUMN platform_after_sales.platform_update_time IS
|
||||
'平台售后版本时间;抖音 afterSale.operate 必须回传该值防止版本过期';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refund_cancel_operations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
operation_no TEXT NOT NULL UNIQUE,
|
||||
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
order_item_id BIGINT NOT NULL REFERENCES order_items(id) ON DELETE CASCADE,
|
||||
work_order_id BIGINT REFERENCES work_orders(id) ON DELETE SET NULL,
|
||||
provider TEXT NOT NULL,
|
||||
external_after_sale_id TEXT NOT NULL,
|
||||
amount_fen BIGINT NOT NULL CHECK (amount_fen > 0),
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN (
|
||||
'pending', 'platform_approved', 'refund_recorded', 'closed', 'failed'
|
||||
)),
|
||||
current_step TEXT NOT NULL DEFAULT 'created',
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
result_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_refund_cancel_operations_external
|
||||
ON refund_cancel_operations(provider, external_after_sale_id, order_item_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_refund_cancel_operations_order
|
||||
ON refund_cancel_operations(order_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_refund_cancel_operations_status
|
||||
ON refund_cancel_operations(status, updated_at DESC);
|
||||
|
||||
COMMENT ON TABLE refund_cancel_operations IS
|
||||
'客户退款并关闭订单的幂等编排记录:平台同意、本地退款、业务关闭分步留痕,可安全重试';
|
||||
|
||||
COMMENT ON COLUMN work_order_shares.cancelled_reason IS
|
||||
'取消来源:timeout/worker_request/admin/admin_partial/problem/settlement/customer_refund';
|
||||
@@ -32,6 +32,10 @@ import {
|
||||
updateAdminWorkOrderPaymentAmount,
|
||||
updateAdminWorkOrderSharing,
|
||||
} from '../../../services/worker-platform/index.js'
|
||||
import {
|
||||
executeCustomerRefund,
|
||||
previewCustomerRefund,
|
||||
} from '../../../services/finance/customer-refund-service.js'
|
||||
import { buildWorkOrderPaymentAmountAuditData } from '../../../services/worker-platform/work-order-payment-amount.js'
|
||||
import {
|
||||
createJsonHandler,
|
||||
@@ -359,6 +363,40 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/customer-refund/preview',
|
||||
requireAdminPermission('worker_order.customer_refund'),
|
||||
createJsonHandler((req) => previewCustomerRefund(String(req.params.workOrderId || '')), {
|
||||
successMessage: '客户退款预检完成',
|
||||
errorMessage: '客户退款预检失败',
|
||||
scope: '[admin/worker-platform/orders/:workOrderId/customer-refund/preview]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/customer-refund',
|
||||
requireAdminPermission('worker_order.customer_refund'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
executeCustomerRefund(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: '客户退款已完成,订单相关操作已关闭',
|
||||
errorMessage: '客户退款失败',
|
||||
scope: '[admin/worker-platform/orders/:workOrderId/customer-refund]',
|
||||
audit: (req, data) => ({
|
||||
action: 'customer_refund_completed',
|
||||
targetType: 'work_order',
|
||||
targetId: String(req.params.workOrderId || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.delete(
|
||||
'/worker-platform/orders/:workOrderId',
|
||||
requireAdminPermission('worker_order.delete'),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user