feat(consignment): 匹配成功短信通知并支持模板配置
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
-- 143_consignment_sms_logs.sql
|
||||
-- 寄卖匹配成功短信发送记录:用于幂等、审计与 Mock 联调验证。
|
||||
-- 同一工单 + 同一库存只允许一条记录,避免重试/重复调度重复发短信。
|
||||
CREATE TABLE IF NOT EXISTS consignment_sms_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
work_order_id BIGINT REFERENCES work_orders(id) ON DELETE SET NULL,
|
||||
work_order_no TEXT NOT NULL DEFAULT '',
|
||||
inventory_id BIGINT REFERENCES consignment_inventories(id) ON DELETE SET NULL,
|
||||
inventory_no TEXT NOT NULL DEFAULT '',
|
||||
worker_id BIGINT REFERENCES worker_users(id) ON DELETE SET NULL,
|
||||
phone TEXT NOT NULL DEFAULT '',
|
||||
template_key TEXT NOT NULL DEFAULT 'consignment_matched',
|
||||
template_text TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
params_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
provider TEXT NOT NULL DEFAULT 'mock',
|
||||
status TEXT NOT NULL DEFAULT 'sending'
|
||||
CHECK (status IN ('sending', 'sent', 'failed', 'skipped')),
|
||||
error_text TEXT NOT NULL DEFAULT '',
|
||||
match_type TEXT NOT NULL DEFAULT 'auto'
|
||||
CHECK (match_type IN ('auto', 'manual')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
sent_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_consignment_sms_logs_work_order_inventory
|
||||
ON consignment_sms_logs (work_order_id, inventory_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consignment_sms_logs_created
|
||||
ON consignment_sms_logs (created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consignment_sms_logs_work_order
|
||||
ON consignment_sms_logs (work_order_id, created_at DESC);
|
||||
|
||||
COMMENT ON TABLE consignment_sms_logs IS '寄卖匹配成功短信发送记录(幂等键:工单 + 库存)';
|
||||
COMMENT ON COLUMN consignment_sms_logs.status IS 'sending/sent/failed/skipped';
|
||||
COMMENT ON COLUMN consignment_sms_logs.match_type IS 'auto/manual';
|
||||
COMMENT ON COLUMN consignment_sms_logs.template_key IS '短信场景键,寄卖匹配成功固定为 consignment_matched';
|
||||
@@ -20,8 +20,18 @@ type AliyunSmsClient = {
|
||||
): Promise<{ body?: { code?: string; message?: string } }>
|
||||
}
|
||||
|
||||
export type SmsTemplateMessage = {
|
||||
/** 阿里云审核通过的模板 Code;Mock 模式可留空 */
|
||||
templateCode: string
|
||||
/** 模板变量,key 与模板中的 ${name} 对应 */
|
||||
params: Record<string, string>
|
||||
/** 渲染后的完整文案,仅用于 Mock 日志与发送记录 */
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface SmsProvider {
|
||||
sendCode(phone: string, code: string): Promise<void>
|
||||
sendTemplate(phone: string, message: SmsTemplateMessage): Promise<void>
|
||||
}
|
||||
|
||||
const ALIYUN_DEFAULT_ENDPOINT = 'dysmsapi.aliyuncs.com'
|
||||
@@ -55,6 +65,15 @@ export class MockSmsProvider implements SmsProvider {
|
||||
hint: '生产环境请配置 WORKER_SMS_PROVIDER=aliyun 后使用真实短信',
|
||||
})
|
||||
}
|
||||
|
||||
async sendTemplate(phone: string, message: SmsTemplateMessage) {
|
||||
logWarn('[sms/mock]', `【开发模式】短信模板:${phone} → ${message.content}`, {
|
||||
phone,
|
||||
templateCode: message.templateCode,
|
||||
params: message.params,
|
||||
hint: '生产环境请配置 WORKER_SMS_PROVIDER=aliyun 后使用真实短信',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class AliyunSmsProvider implements SmsProvider {
|
||||
@@ -82,12 +101,27 @@ export class AliyunSmsProvider implements SmsProvider {
|
||||
}
|
||||
|
||||
async sendCode(phone: string, code: string) {
|
||||
await this.send(phone, this.templateCode, { code })
|
||||
}
|
||||
|
||||
async sendTemplate(phone: string, message: SmsTemplateMessage) {
|
||||
await this.send(phone, message.templateCode, message.params)
|
||||
}
|
||||
|
||||
private async send(phone: string, templateCode: string, params: Record<string, string>) {
|
||||
try {
|
||||
const code = String(templateCode || '').trim()
|
||||
if (!code) {
|
||||
throw createHttpError('短信模板 Code 未配置', {
|
||||
statusCode: 500,
|
||||
errorCode: 'worker_sms_template_missing',
|
||||
})
|
||||
}
|
||||
const request = new Dysmsapi.SendSmsRequest({
|
||||
phoneNumbers: phone,
|
||||
signName: this.signName,
|
||||
templateCode: this.templateCode,
|
||||
templateParam: JSON.stringify({ code }),
|
||||
templateCode: code,
|
||||
templateParam: JSON.stringify(params),
|
||||
})
|
||||
const response = await this.client.sendSmsWithOptions(request, {})
|
||||
const resultCode = String(response?.body?.code || '')
|
||||
|
||||
@@ -38,6 +38,10 @@ import {
|
||||
} from './consignment-matching-service.js'
|
||||
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
|
||||
import { recordConsignmentMatchLog } from './consignment-match-log-service.js'
|
||||
import {
|
||||
sendConsignmentMatchSuccessSms,
|
||||
type ConsignmentMatchSmsPayload,
|
||||
} from './consignment-sms-service.js'
|
||||
|
||||
type MatchCandidate = {
|
||||
workOrder: WorkOrderRow
|
||||
@@ -472,14 +476,17 @@ async function applyWholeOrderMatch(
|
||||
const now = nowIso()
|
||||
const manual = context.matchType === 'manual'
|
||||
|
||||
return withTransaction(async (client: PoolClient) => {
|
||||
type ApplyResult = { applied: boolean; sms?: ConsignmentMatchSmsPayload }
|
||||
const result = await withTransaction<ApplyResult>(async (client: PoolClient) => {
|
||||
const locked = await client.query<WorkOrderRow>(
|
||||
'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE',
|
||||
[workOrder.id],
|
||||
)
|
||||
const current = locked.rows[0]
|
||||
if (!current || current.status !== WORK_ORDER_STATUS.INTERNAL_MATCHING) return false
|
||||
if (current.assigned_worker_id) return false
|
||||
if (!current || current.status !== WORK_ORDER_STATUS.INTERNAL_MATCHING) {
|
||||
return { applied: false }
|
||||
}
|
||||
if (current.assigned_worker_id) return { applied: false }
|
||||
|
||||
const material = parseMaterial(current.material_json)
|
||||
const source = parseMaterial(material.source)
|
||||
@@ -494,7 +501,7 @@ async function applyWholeOrderMatch(
|
||||
isFriendGiftAcceptanceMode(current.acceptance_mode) &&
|
||||
Number(inventory.remaining_quantity ?? 1) !== requiredQuantity
|
||||
) {
|
||||
return false
|
||||
return { applied: false }
|
||||
}
|
||||
const allocation = await reserveConsignmentInventoryWithClient(client, {
|
||||
inventoryId: Number(inventory.id),
|
||||
@@ -506,7 +513,7 @@ async function applyWholeOrderMatch(
|
||||
boundPlatformOrderId: current.platform_order_id || '',
|
||||
shopName,
|
||||
})
|
||||
if (!allocation) return false
|
||||
if (!allocation) return { applied: false }
|
||||
|
||||
const config = profile?.config
|
||||
const grossRewardFen =
|
||||
@@ -585,7 +592,8 @@ async function applyWholeOrderMatch(
|
||||
current.id,
|
||||
],
|
||||
)
|
||||
if (!updated.rows[0]) return false
|
||||
const updatedRow = updated.rows[0]
|
||||
if (!updatedRow) return { applied: false }
|
||||
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: Number(current.id),
|
||||
@@ -617,8 +625,22 @@ async function applyWholeOrderMatch(
|
||||
}),
|
||||
now,
|
||||
})
|
||||
return true
|
||||
return {
|
||||
applied: true,
|
||||
sms: {
|
||||
workOrder: updatedRow,
|
||||
inventory,
|
||||
quantity: requiredQuantity,
|
||||
priceFen: unitPriceFen,
|
||||
matchType: manual ? 'manual' : 'auto',
|
||||
matchedAt: now,
|
||||
},
|
||||
}
|
||||
})
|
||||
if (result.applied && result.sms) {
|
||||
await sendConsignmentMatchSuccessSms(result.sms)
|
||||
}
|
||||
return result.applied
|
||||
}
|
||||
|
||||
async function applySharingMatch(
|
||||
@@ -632,13 +654,16 @@ async function applySharingMatch(
|
||||
if (!workOrder.id || totalQuantity <= 1) return 0
|
||||
const manual = context.matchType === 'manual'
|
||||
|
||||
return withTransaction(async (client: PoolClient) => {
|
||||
type ApplyResult = { createdCount: number; sms: ConsignmentMatchSmsPayload[] }
|
||||
const result = await withTransaction<ApplyResult>(async (client: PoolClient) => {
|
||||
const locked = await client.query<WorkOrderRow>(
|
||||
'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE',
|
||||
[workOrder.id],
|
||||
)
|
||||
const current = locked.rows[0]
|
||||
if (!current || current.status !== WORK_ORDER_STATUS.INTERNAL_MATCHING) return 0
|
||||
if (!current || current.status !== WORK_ORDER_STATUS.INTERNAL_MATCHING) {
|
||||
return { createdCount: 0, sms: [] }
|
||||
}
|
||||
|
||||
const activeResult = await client.query<{ active: number | string }>(
|
||||
`SELECT COALESCE(SUM(quantity), 0) AS active FROM work_order_shares WHERE work_order_id = $1 AND status IN ('joined', 'submitted', 'accepted')`,
|
||||
@@ -646,7 +671,7 @@ async function applySharingMatch(
|
||||
)
|
||||
let active = Number(activeResult.rows[0]?.active || 0)
|
||||
let remaining = Math.max(0, totalQuantity - active)
|
||||
if (remaining <= 0) return 0
|
||||
if (remaining <= 0) return { createdCount: 0, sms: [] }
|
||||
|
||||
const config = profile?.config
|
||||
const timeoutMinutes = config?.timeout.minutes ?? 30
|
||||
@@ -666,6 +691,7 @@ async function applySharingMatch(
|
||||
let createdQuantity = 0
|
||||
let firstInventoryId: number | null = null
|
||||
let firstInventoryQuantity = 0
|
||||
const smsPayloads: ConsignmentMatchSmsPayload[] = []
|
||||
for (const { inventory } of candidates) {
|
||||
if (remaining <= 0) break
|
||||
if (!inventory.worker_id) continue
|
||||
@@ -727,6 +753,14 @@ async function applySharingMatch(
|
||||
)
|
||||
createdCount += 1
|
||||
createdQuantity += take
|
||||
smsPayloads.push({
|
||||
workOrder: current,
|
||||
inventory,
|
||||
quantity: take,
|
||||
priceFen: inventorySalePriceFen(inventory),
|
||||
matchType: manual ? 'manual' : 'auto',
|
||||
matchedAt: now,
|
||||
})
|
||||
if (!firstInventoryId) {
|
||||
firstInventoryId = Number(inventory.id)
|
||||
firstInventoryQuantity = take
|
||||
@@ -820,8 +854,12 @@ async function applySharingMatch(
|
||||
now,
|
||||
})
|
||||
}
|
||||
return createdCount
|
||||
return { createdCount, sms: smsPayloads }
|
||||
})
|
||||
for (const sms of result.sms) {
|
||||
await sendConsignmentMatchSuccessSms(sms)
|
||||
}
|
||||
return result.createdCount
|
||||
}
|
||||
|
||||
/** 自动匹配一批内部匹配工单:低价订单优先,精确规格,支持整单/拼单。 */
|
||||
|
||||
@@ -36,6 +36,10 @@ import {
|
||||
submitWorkerConsignmentGiftEvidence,
|
||||
updateWorkerConsignmentSalePrice,
|
||||
} from './consignment-worker-service.js'
|
||||
import {
|
||||
createDefaultWorkerPlatformNotificationConfig,
|
||||
saveWorkerPlatformNotificationConfig,
|
||||
} from './worker-platform-notification-config-service.js'
|
||||
import type { WorkerSession } from './worker-session-context-service.js'
|
||||
|
||||
const TEST_DATABASE_URL = String(process.env.CONSIGNMENT_MATCHING_TEST_DATABASE_URL || '').trim()
|
||||
@@ -363,6 +367,7 @@ async function main() {
|
||||
quantity?: number
|
||||
sourceSubmissionId?: number | null
|
||||
profileId?: number | null
|
||||
sellerPhone?: string
|
||||
}) {
|
||||
const res = await query<Record<string, any>>(
|
||||
`INSERT INTO consignment_inventories
|
||||
@@ -406,7 +411,15 @@ async function main() {
|
||||
`${input.system || '苹果'} ${input.series || '暗潮'} ${input.diamond || 1}钻`,
|
||||
],
|
||||
)
|
||||
return res.rows[0]!
|
||||
const inserted = res.rows[0]!
|
||||
const sellerPhone = String(input.sellerPhone || '13800138000')
|
||||
await query(
|
||||
'UPDATE consignment_inventories SET seller_phone = $1, worker_phone = $1 WHERE id = $2',
|
||||
[sellerPhone, Number(inserted.id)],
|
||||
)
|
||||
inserted.seller_phone = sellerPhone
|
||||
inserted.worker_phone = sellerPhone
|
||||
return inserted
|
||||
}
|
||||
|
||||
console.log(`\n========== 寄卖全链路探针 ==========`)
|
||||
@@ -676,6 +689,14 @@ async function main() {
|
||||
})
|
||||
|
||||
// ---------- 4. 手动精确匹配 ----------
|
||||
await saveWorkerPlatformNotificationConfig({
|
||||
...createDefaultWorkerPlatformNotificationConfig(),
|
||||
consignmentMatchSms: {
|
||||
enabled: true,
|
||||
template: '【CTEST】寄卖成功 {itemName} / {workOrderNo} / ¥{price} / {quantity}',
|
||||
templateCode: '',
|
||||
},
|
||||
})
|
||||
await check(
|
||||
'手动精确匹配:工单 in_progress、库存 consumed、分配 reserved、报酬扣 5% 服务费',
|
||||
async () => {
|
||||
@@ -709,6 +730,17 @@ async function main() {
|
||||
assert(allocation.rows.length === 1, `分配行 ${allocation.rows.length}`)
|
||||
assert(allocation.rows[0]!.status === 'reserved', `分配 status=${allocation.rows[0]!.status}`)
|
||||
assert(Number(allocation.rows[0]!.unit_price_fen) === 9000, '分配单价必须 9000')
|
||||
const sms = await query<Record<string, any>>(
|
||||
'SELECT * FROM consignment_sms_logs WHERE work_order_id = $1 AND inventory_id = $2',
|
||||
[Number(exactWo.id), Number(invARow.id)],
|
||||
)
|
||||
const smsRow = sms.rows[0]
|
||||
assert(smsRow, '手动匹配没有生成短信记录')
|
||||
assert(smsRow.status === 'sent', `短信状态=${smsRow.status}`)
|
||||
assert(smsRow.phone === '13800138000', `短信手机号=${smsRow.phone}`)
|
||||
assert(smsRow.match_type === 'manual', `短信 match_type=${smsRow.match_type}`)
|
||||
assert(String(smsRow.content).includes('【CTEST】'), `短信内容=${smsRow.content}`)
|
||||
assert(String(smsRow.content).includes('CT-WO-EXACT-'), `短信内容=${smsRow.content}`)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -851,6 +883,7 @@ async function main() {
|
||||
})
|
||||
|
||||
// ---------- 6. 自动匹配 ----------
|
||||
await saveWorkerPlatformNotificationConfig(createDefaultWorkerPlatformNotificationConfig())
|
||||
const autoInv = await createInventory({
|
||||
no: `CT-INV-AUTO-${suffix}`,
|
||||
workerId: sellerId,
|
||||
@@ -878,6 +911,16 @@ async function main() {
|
||||
Number(wo.rows[0]!.material_json.consignment.inventoryId) === Number(autoInv.id),
|
||||
'material 未写自动匹配库存',
|
||||
)
|
||||
const sms = await query<Record<string, any>>(
|
||||
'SELECT * FROM consignment_sms_logs WHERE work_order_id = $1 AND inventory_id = $2',
|
||||
[Number(autoWo.id), Number(autoInv.id)],
|
||||
)
|
||||
const smsRow = sms.rows[0]
|
||||
assert(smsRow, '自动匹配没有生成短信记录')
|
||||
assert(smsRow.status === 'sent', `短信状态=${smsRow.status}`)
|
||||
assert(smsRow.match_type === 'auto', `短信 match_type=${smsRow.match_type}`)
|
||||
assert(String(smsRow.content).includes('寄卖成功'), `短信内容=${smsRow.content}`)
|
||||
assert(String(smsRow.content).includes('CT-WO-AUTO-'), `短信内容=${smsRow.content}`)
|
||||
})
|
||||
|
||||
// ---------- 7. 拼单匹配 ----------
|
||||
@@ -924,6 +967,19 @@ async function main() {
|
||||
allocations.rows.every((allocation) => Number(allocation.work_order_share_id) > 0),
|
||||
'分配没有绑定份额',
|
||||
)
|
||||
const sms = await query<Record<string, any>>(
|
||||
'SELECT * FROM consignment_sms_logs WHERE work_order_id = $1 ORDER BY id',
|
||||
[Number(shareWo.id)],
|
||||
)
|
||||
assert(sms.rows.length === 2, `拼单短信=${sms.rows.length}`)
|
||||
assert(
|
||||
sms.rows.every((row) => row.status === 'sent'),
|
||||
'拼单短信必须全部发送成功',
|
||||
)
|
||||
assert(
|
||||
sms.rows.every((row) => row.match_type === 'manual'),
|
||||
'拼单短信 match_type 必须 manual',
|
||||
)
|
||||
const wo = await query<Record<string, any>>('SELECT * FROM work_orders WHERE id = $1', [
|
||||
Number(shareWo.id),
|
||||
])
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { ConsignmentInventoryRow } from '../../repositories/worker-platform/consignment-repo.js'
|
||||
import type { WorkOrderRow } from '../../repositories/worker-platform/index.js'
|
||||
import {
|
||||
buildConsignmentMatchSmsParams,
|
||||
renderConsignmentMatchSmsTemplate,
|
||||
type ConsignmentMatchSmsPayload,
|
||||
} from './consignment-sms-service.js'
|
||||
import { DEFAULT_CONSIGNMENT_MATCH_SMS_TEMPLATE } from './worker-platform-notification-config-service.js'
|
||||
|
||||
function payload(): ConsignmentMatchSmsPayload {
|
||||
return {
|
||||
workOrder: {
|
||||
id: 11,
|
||||
work_order_no: 'WO202611060001',
|
||||
} as unknown as WorkOrderRow,
|
||||
inventory: {
|
||||
id: 22,
|
||||
inventory_no: 'INV202611060001',
|
||||
item_name: '苹果微信区暗潮1钻1璀 苏尔南',
|
||||
seller_phone: '13800138000',
|
||||
worker_phone: '',
|
||||
worker_name: '测试卖家',
|
||||
worker_id: 33,
|
||||
} as unknown as ConsignmentInventoryRow,
|
||||
quantity: 2,
|
||||
priceFen: 12345,
|
||||
matchType: 'auto',
|
||||
matchedAt: '2026-11-06T06:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
test('寄卖匹配短信变量:商品、数量、金额、工单号、寄卖单号、卖家名', () => {
|
||||
const params = buildConsignmentMatchSmsParams(payload())
|
||||
assert.equal(params.itemName, '苹果微信区暗潮1钻1璀 苏尔南')
|
||||
assert.equal(params.quantity, '2')
|
||||
assert.equal(params.price, '123.45')
|
||||
assert.equal(params.workOrderNo, 'WO202611060001')
|
||||
assert.equal(params.inventoryNo, 'INV202611060001')
|
||||
assert.equal(params.sellerName, '测试卖家')
|
||||
})
|
||||
|
||||
test('默认短信模板包含寄卖成功语义与全部核心变量', () => {
|
||||
for (const fragment of ['寄卖成功', '{itemName}', '{quantity}', '{price}', '{workOrderNo}']) {
|
||||
assert.ok(DEFAULT_CONSIGNMENT_MATCH_SMS_TEMPLATE.includes(fragment), `缺少 ${fragment}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('模板渲染替换已知变量,未知变量原样保留(便于发现配置笔误)', () => {
|
||||
const params = buildConsignmentMatchSmsParams(payload())
|
||||
const rendered = renderConsignmentMatchSmsTemplate(
|
||||
'寄卖成功:{itemName} × {quantity},¥{price},{workOrderNo},{unknown}',
|
||||
params,
|
||||
)
|
||||
assert.equal(
|
||||
rendered,
|
||||
'寄卖成功:苹果微信区暗潮1钻1璀 苏尔南 × 2,¥123.45,WO202611060001,{unknown}',
|
||||
)
|
||||
})
|
||||
|
||||
test('空模板回落默认模板,不会发出空短信', () => {
|
||||
const rendered = renderConsignmentMatchSmsTemplate(
|
||||
' ',
|
||||
buildConsignmentMatchSmsParams(payload()),
|
||||
)
|
||||
assert.ok(rendered.includes('寄卖成功'))
|
||||
assert.ok(!rendered.includes('{itemName}'))
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import type { ConsignmentInventoryRow } from '../../repositories/worker-platform/consignment-repo.js'
|
||||
import type { WorkOrderRow } from '../../repositories/worker-platform/index.js'
|
||||
import { logWarn } from '../../utils/logger.js'
|
||||
import { getSmsProvider } from '../sms/index.js'
|
||||
import {
|
||||
DEFAULT_CONSIGNMENT_MATCH_SMS_TEMPLATE,
|
||||
getWorkerPlatformNotificationConfig,
|
||||
} from './worker-platform-notification-config-service.js'
|
||||
|
||||
export type ConsignmentMatchSmsPayload = {
|
||||
workOrder: WorkOrderRow
|
||||
inventory: ConsignmentInventoryRow
|
||||
quantity: number
|
||||
priceFen: number
|
||||
matchType: 'auto' | 'manual'
|
||||
matchedAt?: string
|
||||
}
|
||||
|
||||
export type ConsignmentMatchSmsStatus = 'sent' | 'failed' | 'skipped'
|
||||
|
||||
const SMS_TEMPLATE_KEY = 'consignment_matched'
|
||||
|
||||
const PLACEHOLDER_PATTERN = /\{([a-zA-Z0-9_]+)\}/g
|
||||
|
||||
export function buildConsignmentMatchSmsParams(
|
||||
payload: ConsignmentMatchSmsPayload,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
itemName: String(payload.inventory.item_name || '寄卖商品'),
|
||||
quantity: String(Math.max(1, Math.round(Number(payload.quantity || 1)))),
|
||||
price: (Math.max(0, Math.round(Number(payload.priceFen || 0))) / 100).toFixed(2),
|
||||
workOrderNo: String(payload.workOrder.work_order_no || ''),
|
||||
inventoryNo: String(payload.inventory.inventory_no || ''),
|
||||
sellerName: String(payload.inventory.worker_name || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export function renderConsignmentMatchSmsTemplate(
|
||||
template: string,
|
||||
params: Record<string, string>,
|
||||
): string {
|
||||
const source = String(template || '').trim() || DEFAULT_CONSIGNMENT_MATCH_SMS_TEMPLATE
|
||||
return source.replace(PLACEHOLDER_PATTERN, (match, key: string) => {
|
||||
const value = params[key]
|
||||
return value === undefined ? match : value
|
||||
})
|
||||
}
|
||||
|
||||
function resolveSellerPhone(inventory: ConsignmentInventoryRow): string {
|
||||
const phone = String(inventory.seller_phone || inventory.worker_phone || '').trim()
|
||||
return /^1[3-9]\d{9}$/.test(phone) ? phone : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 寄卖匹配成功短信:自动匹配、后台手动匹配、拼单份额匹配都会走这里。
|
||||
*
|
||||
* 发送记录以「工单 + 库存」为幂等键:同一工单同一库存只发一次;
|
||||
* 短信失败不回滚匹配事务,只记录 failed 便于后台排查/补发。
|
||||
*/
|
||||
export async function sendConsignmentMatchSuccessSms(
|
||||
payload: ConsignmentMatchSmsPayload,
|
||||
): Promise<ConsignmentMatchSmsStatus> {
|
||||
try {
|
||||
const config = getWorkerPlatformNotificationConfig().consignmentMatchSms
|
||||
if (!config.enabled) {
|
||||
return 'skipped'
|
||||
}
|
||||
|
||||
const phone = resolveSellerPhone(payload.inventory)
|
||||
if (!phone) {
|
||||
logWarn('[consignment-sms]', '寄卖匹配成功短信跳过:卖家手机号为空或格式不正确', {
|
||||
workOrderId: Number(payload.workOrder.id),
|
||||
inventoryId: Number(payload.inventory.id),
|
||||
sellerPhone: payload.inventory.seller_phone || '',
|
||||
workerPhone: payload.inventory.worker_phone || '',
|
||||
})
|
||||
return 'skipped'
|
||||
}
|
||||
|
||||
const params = buildConsignmentMatchSmsParams(payload)
|
||||
const content = renderConsignmentMatchSmsTemplate(config.template, params)
|
||||
const provider = String(runtimeConfig.worker.sms.provider || 'mock')
|
||||
|
||||
const inserted = await query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO consignment_sms_logs (
|
||||
work_order_id, work_order_no, inventory_id, inventory_no, worker_id,
|
||||
phone, template_key, template_text, content, params_json, provider, status, match_type
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $9, $10::jsonb, $11, 'sending', $12
|
||||
)
|
||||
ON CONFLICT (work_order_id, inventory_id) DO NOTHING
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
Number(payload.workOrder.id) || null,
|
||||
String(payload.workOrder.work_order_no || ''),
|
||||
Number(payload.inventory.id) || null,
|
||||
String(payload.inventory.inventory_no || ''),
|
||||
Number(payload.inventory.worker_id || 0) || null,
|
||||
phone,
|
||||
SMS_TEMPLATE_KEY,
|
||||
config.template,
|
||||
content,
|
||||
JSON.stringify(params),
|
||||
provider,
|
||||
payload.matchType,
|
||||
],
|
||||
)
|
||||
const logId = Number(inserted.rows[0]?.id || 0)
|
||||
if (!logId) {
|
||||
return 'skipped'
|
||||
}
|
||||
|
||||
try {
|
||||
await getSmsProvider().sendTemplate(phone, {
|
||||
templateCode: String(config.templateCode || '').trim(),
|
||||
params,
|
||||
content,
|
||||
})
|
||||
await query(
|
||||
`UPDATE consignment_sms_logs SET status = 'sent', sent_at = NOW() WHERE id = $1`,
|
||||
[logId],
|
||||
)
|
||||
return 'sent'
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
await query(
|
||||
`UPDATE consignment_sms_logs SET status = 'failed', error_text = $2 WHERE id = $1`,
|
||||
[logId, message.slice(0, 500)],
|
||||
)
|
||||
logWarn('[consignment-sms]', '寄卖匹配成功短信发送失败', {
|
||||
workOrderId: Number(payload.workOrder.id),
|
||||
inventoryId: Number(payload.inventory.id),
|
||||
phone,
|
||||
error: message,
|
||||
})
|
||||
return 'failed'
|
||||
}
|
||||
} catch (error) {
|
||||
logWarn('[consignment-sms]', '寄卖匹配成功短信记录失败', {
|
||||
workOrderId: Number(payload.workOrder.id),
|
||||
inventoryId: Number(payload.inventory.id),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return 'failed'
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
DEFAULT_CONSIGNMENT_MATCH_SMS_TEMPLATE,
|
||||
createDefaultWorkerPlatformNotificationConfig,
|
||||
normalizeWorkerPlatformNotificationConfig,
|
||||
} from './worker-platform-notification-config-service.js'
|
||||
|
||||
test('通知配置默认开启寄卖匹配短信,并带默认模板', () => {
|
||||
const config = createDefaultWorkerPlatformNotificationConfig()
|
||||
assert.equal(config.consignmentMatchSms.enabled, true)
|
||||
assert.equal(config.consignmentMatchSms.template, DEFAULT_CONSIGNMENT_MATCH_SMS_TEMPLATE)
|
||||
assert.equal(config.consignmentMatchSms.templateCode, '')
|
||||
})
|
||||
|
||||
test('寄卖短信模板可配置:自定义模板与模板 Code 原样保留', () => {
|
||||
const config = normalizeWorkerPlatformNotificationConfig({
|
||||
events: {},
|
||||
consignmentMatchSms: {
|
||||
enabled: false,
|
||||
template: ' 寄卖成功 {itemName} {workOrderNo} ',
|
||||
templateCode: ' SMS_123456789 ',
|
||||
},
|
||||
})
|
||||
assert.equal(config.consignmentMatchSms.enabled, false)
|
||||
assert.equal(config.consignmentMatchSms.template, '寄卖成功 {itemName} {workOrderNo}')
|
||||
assert.equal(config.consignmentMatchSms.templateCode, 'SMS_123456789')
|
||||
})
|
||||
|
||||
test('旧配置没有寄卖短信段时自动补默认值,不清空事件配置', () => {
|
||||
const config = normalizeWorkerPlatformNotificationConfig({
|
||||
events: {
|
||||
withdraw_requested: { todoEnabled: false, soundEnabled: true, externalPushEnabled: false },
|
||||
},
|
||||
})
|
||||
assert.equal(config.events.withdraw_requested.todoEnabled, false)
|
||||
assert.equal(config.events.withdraw_requested.soundEnabled, false)
|
||||
assert.equal(config.consignmentMatchSms.enabled, true)
|
||||
assert.equal(config.consignmentMatchSms.template, DEFAULT_CONSIGNMENT_MATCH_SMS_TEMPLATE)
|
||||
})
|
||||
|
||||
test('空白模板保存时回落默认模板', () => {
|
||||
const config = normalizeWorkerPlatformNotificationConfig({
|
||||
consignmentMatchSms: { enabled: true, template: ' ', templateCode: '' },
|
||||
})
|
||||
assert.equal(config.consignmentMatchSms.template, DEFAULT_CONSIGNMENT_MATCH_SMS_TEMPLATE)
|
||||
})
|
||||
+22
@@ -22,8 +22,18 @@ export type WorkerPlatformNotificationEventConfig = {
|
||||
externalPushEnabled: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_CONSIGNMENT_MATCH_SMS_TEMPLATE =
|
||||
'【苏尔南寄卖】寄卖成功:{itemName} × {quantity},成交价 ¥{price},工单号 {workOrderNo}。请及时联系客服办理后续。'
|
||||
|
||||
export type WorkerPlatformConsignmentMatchSmsConfig = {
|
||||
enabled: boolean
|
||||
template: string
|
||||
templateCode: string
|
||||
}
|
||||
|
||||
export type WorkerPlatformNotificationConfig = {
|
||||
events: Record<WorkerPlatformNotificationEventKey, WorkerPlatformNotificationEventConfig>
|
||||
consignmentMatchSms: WorkerPlatformConsignmentMatchSmsConfig
|
||||
}
|
||||
|
||||
export function getWorkerPlatformNotificationConfig(): WorkerPlatformNotificationConfig {
|
||||
@@ -58,6 +68,11 @@ export function createDefaultWorkerPlatformNotificationConfig(): WorkerPlatformN
|
||||
cancel_requested: createEnabledEventConfig(),
|
||||
feedback_submitted: createEnabledEventConfig(),
|
||||
},
|
||||
consignmentMatchSms: {
|
||||
enabled: true,
|
||||
template: DEFAULT_CONSIGNMENT_MATCH_SMS_TEMPLATE,
|
||||
templateCode: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +81,7 @@ export function normalizeWorkerPlatformNotificationConfig(
|
||||
): WorkerPlatformNotificationConfig {
|
||||
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||
const rawEvents = isPlainObject(source.events) ? source.events : {}
|
||||
const rawSms = isPlainObject(source.consignmentMatchSms) ? source.consignmentMatchSms : {}
|
||||
const defaults = createDefaultWorkerPlatformNotificationConfig()
|
||||
|
||||
return {
|
||||
@@ -75,6 +91,12 @@ export function normalizeWorkerPlatformNotificationConfig(
|
||||
normalizeEventConfig(rawEvents[key], defaults.events[key]),
|
||||
]),
|
||||
) as WorkerPlatformNotificationConfig['events'],
|
||||
consignmentMatchSms: {
|
||||
enabled:
|
||||
typeof rawSms.enabled === 'boolean' ? rawSms.enabled : defaults.consignmentMatchSms.enabled,
|
||||
template: String(rawSms.template || '').trim() || defaults.consignmentMatchSms.template,
|
||||
templateCode: String(rawSms.templateCode || '').trim(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { App, Alert, Button, Card, Space, Switch, Typography } from 'antd'
|
||||
import { App, Alert, Button, Card, Input, Space, Switch, Typography } from 'antd'
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -74,6 +74,19 @@ export default function NotificationsPanel() {
|
||||
})
|
||||
}
|
||||
|
||||
function updateConsignmentSms(
|
||||
patch: Partial<WorkerPlatformNotificationConfig['consignmentMatchSms']>,
|
||||
) {
|
||||
setDraft((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
consignmentMatchSms: { ...current.consignmentMatchSms, ...patch },
|
||||
}
|
||||
: current,
|
||||
)
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!draft) return
|
||||
setSaving(true)
|
||||
@@ -157,6 +170,53 @@ export default function NotificationsPanel() {
|
||||
loading={configQuery.isLoading}
|
||||
scroll={{ x: 660 }}
|
||||
/>
|
||||
<Card size="small" title="寄卖匹配成功短信">
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="匹配成功后向寄卖人发送短信"
|
||||
description="自动匹配、后台手动匹配与拼单份额匹配都会触发;同一工单同一库存只发送一次,短信失败不影响匹配结果。"
|
||||
/>
|
||||
<Space align="center" size={12}>
|
||||
<Switch
|
||||
checked={draft?.consignmentMatchSms?.enabled || false}
|
||||
disabled={!draft || saving}
|
||||
onChange={(checked) => updateConsignmentSms({ enabled: checked })}
|
||||
/>
|
||||
<Typography.Text>启用寄卖匹配成功短信</Typography.Text>
|
||||
</Space>
|
||||
<div>
|
||||
<Typography.Text strong>短信模板</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={draft?.consignmentMatchSms?.template || ''}
|
||||
disabled={!draft || saving}
|
||||
rows={3}
|
||||
maxLength={300}
|
||||
showCount
|
||||
onChange={(event) => updateConsignmentSms({ template: event.target.value })}
|
||||
style={{ marginTop: 6 }}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
可用变量:{'{itemName}'} 商品名、{'{quantity}'} 数量、{'{price}'} 成交价(元)、
|
||||
{'{workOrderNo}'} 工单号、{'{inventoryNo}'} 寄卖单号、{'{sellerName}'} 卖家名。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong>阿里云模板 Code(可选)</Typography.Text>
|
||||
<Input
|
||||
value={draft?.consignmentMatchSms?.templateCode || ''}
|
||||
disabled={!draft || saving}
|
||||
placeholder="如 SMS_123456789"
|
||||
onChange={(event) => updateConsignmentSms({ templateCode: event.target.value })}
|
||||
style={{ marginTop: 6 }}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
Mock 模式可留空;真实短信需填写阿里云审核通过的模板 Code,变量名需与模板一致。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
|
||||
@@ -313,6 +313,12 @@ export type WorkerPlatformNotificationEventKey =
|
||||
| 'cancel_requested'
|
||||
| 'feedback_submitted'
|
||||
|
||||
export type WorkerPlatformConsignmentMatchSmsConfig = {
|
||||
enabled: boolean
|
||||
template: string
|
||||
templateCode: string
|
||||
}
|
||||
|
||||
export type WorkerPlatformNotificationConfig = {
|
||||
events: Record<
|
||||
WorkerPlatformNotificationEventKey,
|
||||
@@ -322,6 +328,7 @@ export type WorkerPlatformNotificationConfig = {
|
||||
externalPushEnabled: boolean
|
||||
}
|
||||
>
|
||||
consignmentMatchSms: WorkerPlatformConsignmentMatchSmsConfig
|
||||
}
|
||||
|
||||
export type WorkerProductMatchConfig = {
|
||||
|
||||
Reference in New Issue
Block a user