From 1044948c99a2e7f00ae4f7adc6217c71f80cb594 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Fri, 6 Nov 2026 14:58:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(consignment):=20=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E7=9F=AD=E4=BF=A1=E9=80=9A=E7=9F=A5=E5=B9=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=A8=A1=E6=9D=BF=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/143_consignment_sms_logs.sql | 38 +++++ apps/backend/src/services/sms/index.ts | 38 ++++- .../consignment-match-engine-service.ts | 60 +++++-- .../consignment-matching.integration.test.ts | 58 ++++++- .../consignment-sms-service.test.ts | 70 ++++++++ .../consignment-sms-service.ts | 151 ++++++++++++++++++ ...atform-notification-config-service.test.ts | 48 ++++++ ...er-platform-notification-config-service.ts | 22 +++ .../pages/admin/panels/NotificationsPanel.tsx | 62 ++++++- apps/frontend/src/types/worker-platform.ts | 7 + 10 files changed, 539 insertions(+), 15 deletions(-) create mode 100644 apps/backend/src/db/migrations/143_consignment_sms_logs.sql create mode 100644 apps/backend/src/services/worker-platform/consignment-sms-service.test.ts create mode 100644 apps/backend/src/services/worker-platform/consignment-sms-service.ts create mode 100644 apps/backend/src/services/worker-platform/worker-platform-notification-config-service.test.ts diff --git a/apps/backend/src/db/migrations/143_consignment_sms_logs.sql b/apps/backend/src/db/migrations/143_consignment_sms_logs.sql new file mode 100644 index 00000000..b43771c7 --- /dev/null +++ b/apps/backend/src/db/migrations/143_consignment_sms_logs.sql @@ -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'; diff --git a/apps/backend/src/services/sms/index.ts b/apps/backend/src/services/sms/index.ts index 9dd583d6..b4c4ce4a 100644 --- a/apps/backend/src/services/sms/index.ts +++ b/apps/backend/src/services/sms/index.ts @@ -20,8 +20,18 @@ type AliyunSmsClient = { ): Promise<{ body?: { code?: string; message?: string } }> } +export type SmsTemplateMessage = { + /** 阿里云审核通过的模板 Code;Mock 模式可留空 */ + templateCode: string + /** 模板变量,key 与模板中的 ${name} 对应 */ + params: Record + /** 渲染后的完整文案,仅用于 Mock 日志与发送记录 */ + content: string +} + export interface SmsProvider { sendCode(phone: string, code: string): Promise + sendTemplate(phone: string, message: SmsTemplateMessage): Promise } 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) { 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 || '') diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts index d6d44f96..1b973617 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts @@ -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(async (client: PoolClient) => { const locked = await client.query( '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(async (client: PoolClient) => { const locked = await client.query( '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 } /** 自动匹配一批内部匹配工单:低价订单优先,精确规格,支持整单/拼单。 */ diff --git a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts index e7dc9db0..0e06c6f3 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts @@ -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>( `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>( + '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>( + '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>( + '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>('SELECT * FROM work_orders WHERE id = $1', [ Number(shareWo.id), ]) diff --git a/apps/backend/src/services/worker-platform/consignment-sms-service.test.ts b/apps/backend/src/services/worker-platform/consignment-sms-service.test.ts new file mode 100644 index 00000000..38656c42 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-sms-service.test.ts @@ -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}')) +}) diff --git a/apps/backend/src/services/worker-platform/consignment-sms-service.ts b/apps/backend/src/services/worker-platform/consignment-sms-service.ts new file mode 100644 index 00000000..e8a490f6 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-sms-service.ts @@ -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 { + 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 { + 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 { + 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' + } +} diff --git a/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.test.ts new file mode 100644 index 00000000..9869988a --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.test.ts @@ -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) +}) diff --git a/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.ts b/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.ts index 9655be06..a58f43b0 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.ts @@ -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 + 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(), + }, } } diff --git a/apps/frontend/src/pages/admin/panels/NotificationsPanel.tsx b/apps/frontend/src/pages/admin/panels/NotificationsPanel.tsx index 0653af57..a06bd7c4 100644 --- a/apps/frontend/src/pages/admin/panels/NotificationsPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/NotificationsPanel.tsx @@ -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, + ) { + 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 }} /> + + + + + updateConsignmentSms({ enabled: checked })} + /> + 启用寄卖匹配成功短信 + +
+ 短信模板 + updateConsignmentSms({ template: event.target.value })} + style={{ marginTop: 6 }} + /> + + 可用变量:{'{itemName}'} 商品名、{'{quantity}'} 数量、{'{price}'} 成交价(元)、 + {'{workOrderNo}'} 工单号、{'{inventoryNo}'} 寄卖单号、{'{sellerName}'} 卖家名。 + +
+
+ 阿里云模板 Code(可选) + updateConsignmentSms({ templateCode: event.target.value })} + style={{ marginTop: 6 }} + /> + + Mock 模式可留空;真实短信需填写阿里云审核通过的模板 Code,变量名需与模板一致。 + +
+
+