feat(consignment): 匹配成功短信通知并支持模板配置

This commit is contained in:
2026-11-06 14:58:29 +08:00
parent 8b7bcca36a
commit 1044948c99
10 changed files with 539 additions and 15 deletions
@@ -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';
+36 -2
View File
@@ -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'
}
}
@@ -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,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 = {