客服推送处理+后台操作
This commit is contained in:
1 parent
b652132957
commit
a81c637a98
11 files changed
+302
-54
No files matched your search
@@ -1,5 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cdata, decryptWechatMessage, eventOf, fieldOf, sameHex, wechatSignature, xmlField } from './wechat-msg';
|
||||
import {
|
||||
cdata,
|
||||
customerServiceMsgTypeLabel,
|
||||
decryptWechatMessage,
|
||||
eventOf,
|
||||
fieldOf,
|
||||
isCustomerServicePush,
|
||||
sameHex,
|
||||
wechatSignature,
|
||||
xmlField,
|
||||
} from './wechat-msg';
|
||||
|
||||
// 用例全部取自微信官方文档「消息推送」的签名与加解密示例,改坏签名算法会立刻失败。
|
||||
describe('wechat-msg', () => {
|
||||
@@ -69,4 +79,39 @@ describe('wechat-msg', () => {
|
||||
expect(cdata('a]]>b')).toBe('<![CDATA[a]] >b]]>');
|
||||
expect(cdata('普通文本')).toBe('<![CDATA[普通文本]]>');
|
||||
});
|
||||
|
||||
it('isCustomerServicePush 认得客服推送,也不会误伤支付推送', () => {
|
||||
// 生产环境真实收到的「用户进入客服会话」报文
|
||||
const enterSession =
|
||||
'<xml><ToUserName><![CDATA[gh_22ad8dee88de]]></ToUserName>' +
|
||||
'<FromUserName><![CDATA[omYms6wwbYJogNXXQhiCqKfus5F4]]></FromUserName>' +
|
||||
'<CreateTime>1790763644</CreateTime>' +
|
||||
'<MsgType><![CDATA[event]]></MsgType>' +
|
||||
'<Event><![CDATA[user_enter_tempsession]]></Event>' +
|
||||
'<SessionFrom><![CDATA[wxapp]]></SessionFrom></xml>';
|
||||
expect(isCustomerServicePush(enterSession)).toBe(true);
|
||||
|
||||
const userText =
|
||||
'<xml><FromUserName><![CDATA[o1]]></FromUserName>' +
|
||||
'<MsgType><![CDATA[text]]></MsgType>' +
|
||||
'<Content><![CDATA[怎么买课]]></Content></xml>';
|
||||
expect(isCustomerServicePush(userText)).toBe(true);
|
||||
|
||||
// 支付推送没有 MsgType;普通事件(MsgType=event 但不是进入会话)也不算
|
||||
expect(
|
||||
isCustomerServicePush(
|
||||
'<xml><Event><![CDATA[xpay_goods_deliver_notify]]></Event><OutTradeNo>T1</OutTradeNo></xml>',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCustomerServicePush('<xml><MsgType><![CDATA[event]]></MsgType><Event><![CDATA[subscribe]]></Event></xml>'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('customerServiceMsgTypeLabel 给未收录的类型留退路', () => {
|
||||
expect(customerServiceMsgTypeLabel('text')).toBe('文字');
|
||||
expect(customerServiceMsgTypeLabel('image')).toBe('图片');
|
||||
expect(customerServiceMsgTypeLabel('voice')).toBe('voice');
|
||||
expect(customerServiceMsgTypeLabel('')).toBe('未知类型');
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,34 @@ export function fieldOf(body: string, field: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 客服消息的 MsgType 中文名,只用于日志展示。 */
|
||||
const CUSTOMER_SERVICE_MSG_TYPES: Record<string, string> = {
|
||||
text: '文字',
|
||||
image: '图片',
|
||||
miniprogrampage: '小程序卡片',
|
||||
link: '链接',
|
||||
};
|
||||
|
||||
export function customerServiceMsgTypeLabel(msgType: string): string {
|
||||
if (!msgType) return '未知类型';
|
||||
return CUSTOMER_SERVICE_MSG_TYPES[msgType] ?? msgType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否是客服消息推送。
|
||||
*
|
||||
* 小程序后台只有一个「消息推送 URL」,所以用户点客服按钮
|
||||
* (Event = user_enter_tempsession)和用户给客服发消息(MsgType = text / image …)
|
||||
* 都会推到同一个地址,和支付推送混在一起,必须分开认。
|
||||
*
|
||||
* 支付推送没有 MsgType 字段;有 MsgType 且不是 event 的,就是用户发来的消息。
|
||||
*/
|
||||
export function isCustomerServicePush(body: string): boolean {
|
||||
if (eventOf(body) === 'user_enter_tempsession') return true;
|
||||
const msgType = fieldOf(body, 'MsgType');
|
||||
return msgType !== '' && msgType !== 'event';
|
||||
}
|
||||
|
||||
/** 把值塞进 CDATA 安全地用于回包。 */
|
||||
export function cdata(value: string): string {
|
||||
return `<![CDATA[${value.replace(/]]>/g, ']] >')}]]>`;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
@@ -5,7 +6,7 @@ import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||
import type { DataSourceEnv } from '../../common/admin-env';
|
||||
import { hashPassword, verifyPassword } from '../../common/crypto';
|
||||
import { AppError } from '../../common/errors';
|
||||
import { fieldOf } from '../../common/wechat-msg';
|
||||
import { customerServiceMsgTypeLabel, fieldOf } from '../../common/wechat-msg';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { OrderStatus, PaymentsService } from '../payments/payments.service';
|
||||
import { WxPayService } from '../payments/wxpay.service';
|
||||
@@ -19,8 +20,23 @@ const NOTIFY_EVENT_LABEL: Record<string, string> = {
|
||||
xpay_coin_pay_notify: '代币支付推送',
|
||||
xpay_complaint_notify: '用户投诉',
|
||||
xpay_wxpay_callback_notify: '微信支付风控事件',
|
||||
user_enter_tempsession: '用户进入客服会话',
|
||||
};
|
||||
|
||||
/** 客服消息在日志里的 event 形如 cs_message:text,按前缀归成一类展示。 */
|
||||
const CUSTOMER_MSG_PREFIX = 'cs_message:';
|
||||
|
||||
function notifyEventLabel(event: string | null): string {
|
||||
if (!event) return '未识别的事件';
|
||||
if (event.startsWith(CUSTOMER_MSG_PREFIX)) {
|
||||
return `客服消息(${customerServiceMsgTypeLabel(event.slice(CUSTOMER_MSG_PREFIX.length))})`;
|
||||
}
|
||||
return NOTIFY_EVENT_LABEL[event] ?? '未识别的事件';
|
||||
}
|
||||
|
||||
/** 后台主动发放 / 回收产生的订单。不是支付单,列表里订单号显示为空。 */
|
||||
const MANUAL_ORDER_STATUSES: number[] = [OrderStatus.ADMIN_GRANT, OrderStatus.ADMIN_REVOKE];
|
||||
|
||||
/** 通用委托接口。用最小结构约束代替 any,保持 strict 校验通过。 */
|
||||
interface Delegate {
|
||||
findMany: (args?: unknown) => Promise<unknown[]>;
|
||||
@@ -102,7 +118,8 @@ export class AdminService {
|
||||
const [users, courses, orders, delivered, refunded, revenue] = await Promise.all([
|
||||
db.user.count(),
|
||||
db.course.count({ where: { status: 1 } }),
|
||||
db.order.count(),
|
||||
// 后台主动发放 / 回收不是成交,看板是经营指标,把它们排除掉
|
||||
db.order.count({ where: { status: { notIn: MANUAL_ORDER_STATUSES } } }),
|
||||
db.order.count({ where: { status: OrderStatus.DELIVERED } }),
|
||||
db.order.count({ where: { status: OrderStatus.REFUNDED } }),
|
||||
db.order.aggregate({
|
||||
@@ -318,7 +335,8 @@ export class AdminService {
|
||||
return {
|
||||
items: items.map((o) => ({
|
||||
id: o.id,
|
||||
out_trade_no: o.out_trade_no,
|
||||
// 后台主动发放 / 回收不是支付单,没有对外的订单号,列表里显示为空
|
||||
out_trade_no: MANUAL_ORDER_STATUSES.includes(o.status) ? null : o.out_trade_no,
|
||||
wx_order_id: o.wx_order_id,
|
||||
status: o.status,
|
||||
amount_fen: o.amount_fen,
|
||||
@@ -363,7 +381,7 @@ export class AdminService {
|
||||
return {
|
||||
id: r.id,
|
||||
event: r.event,
|
||||
event_label: NOTIFY_EVENT_LABEL[r.event ?? ''] ?? '未识别的事件',
|
||||
event_label: notifyEventLabel(r.event),
|
||||
processed: r.processed,
|
||||
err_msg: r.err_msg,
|
||||
// 发货推送带 OutTradeNo,退款问询带 pay_order_id,统一抽成一个「关联订单」
|
||||
@@ -517,7 +535,7 @@ export class AdminService {
|
||||
}));
|
||||
}
|
||||
|
||||
/** 手动发放权益(赠课、补发、老用户补救)。 */
|
||||
/** 手动发放权益(赠课、补发、老用户补救)。同时记一笔订单,订单页能看到这次操作。 */
|
||||
async grantEntitlement(
|
||||
env: DataSourceEnv,
|
||||
input: { userId: number; courseId: number; expireAt?: Date | null; note?: string },
|
||||
@@ -530,29 +548,87 @@ export class AdminService {
|
||||
if (!user) throw AppError.notFound('用户不存在');
|
||||
if (!course) throw AppError.notFound('课程不存在');
|
||||
|
||||
return db.entitlement.upsert({
|
||||
where: { user_id_course_id: { user_id: input.userId, course_id: input.courseId } },
|
||||
create: {
|
||||
user_id: input.userId,
|
||||
course_id: input.courseId,
|
||||
source: 3,
|
||||
granted_at: new Date(),
|
||||
expire_at: input.expireAt ?? null,
|
||||
note: input.note ?? '后台手动发放',
|
||||
},
|
||||
update: {
|
||||
expire_at: input.expireAt ?? null,
|
||||
note: input.note ?? '后台手动发放',
|
||||
},
|
||||
});
|
||||
// 权益与订单必须同事务:只写了其中一个,订单页和实际权益就对不上
|
||||
const note = input.note ?? '后台手动发放';
|
||||
const amountFen = await this.coursePriceFen(db, input.courseId);
|
||||
const [entitlement] = await db.$transaction([
|
||||
db.entitlement.upsert({
|
||||
where: { user_id_course_id: { user_id: input.userId, course_id: input.courseId } },
|
||||
create: {
|
||||
user_id: input.userId,
|
||||
course_id: input.courseId,
|
||||
source: 3,
|
||||
granted_at: new Date(),
|
||||
expire_at: input.expireAt ?? null,
|
||||
note,
|
||||
},
|
||||
update: { expire_at: input.expireAt ?? null, note },
|
||||
}),
|
||||
db.order.create({
|
||||
data: this.manualOrder(OrderStatus.ADMIN_GRANT, input.userId, input.courseId, amountFen, note),
|
||||
}),
|
||||
]);
|
||||
return entitlement;
|
||||
}
|
||||
|
||||
/** 回收权益(原「口令码」的替代)。同样记一笔订单。 */
|
||||
async revokeEntitlement(env: DataSourceEnv, userId: number, courseId: number): Promise<{ deleted: true }> {
|
||||
await this.prisma.clientFor(env).entitlement.delete({
|
||||
where: { user_id_course_id: { user_id: userId, course_id: courseId } },
|
||||
});
|
||||
const db = this.prisma.clientFor(env);
|
||||
const amountFen = await this.coursePriceFen(db, courseId);
|
||||
await db.$transaction([
|
||||
db.entitlement.delete({
|
||||
where: { user_id_course_id: { user_id: userId, course_id: courseId } },
|
||||
}),
|
||||
db.order.create({
|
||||
data: this.manualOrder(OrderStatus.ADMIN_REVOKE, userId, courseId, amountFen, '后台主动回收'),
|
||||
}),
|
||||
]);
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台主动发放 / 回收也记成一笔订单,订单页才看得到「谁在什么时候被动了什么课」。
|
||||
*
|
||||
* 这类单没有微信支付:金额记这门课「当前配置」的价格,终端、平台单号都为空。
|
||||
* `out_trade_no` 这一列是非空唯一的,存不下空串,所以内部仍然生成一个 ADMIN- 开头的
|
||||
* 流水号——列表接口对这两种状态统一返回空,界面上看不到它。
|
||||
*/
|
||||
private manualOrder(
|
||||
status: number,
|
||||
userId: number,
|
||||
courseId: number,
|
||||
amountFen: number,
|
||||
note: string,
|
||||
) {
|
||||
const prefix = status === OrderStatus.ADMIN_GRANT ? 'ADMIN-G' : 'ADMIN-R';
|
||||
return {
|
||||
out_trade_no: `${prefix}-${Date.now()}${randomInt(100, 1000)}`,
|
||||
user_id: userId,
|
||||
course_id: courseId,
|
||||
product_id: 'admin-manual',
|
||||
quantity: 1,
|
||||
amount_fen: amountFen,
|
||||
status,
|
||||
env: 0,
|
||||
attach: note.slice(0, 255),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 这门课「当前配置」的价格,和下单用的是同一份数据:
|
||||
* products 里启用的那条(必须与小程序后台道具价格一致),
|
||||
* 没配商品就退回课程表上的展示价,再没有就是 0。
|
||||
*/
|
||||
private async coursePriceFen(client: PrismaClient, courseId: number): Promise<number> {
|
||||
const [product, course] = await Promise.all([
|
||||
client.product.findFirst({
|
||||
where: { course_id: courseId, status: 1 },
|
||||
select: { price_fen: true },
|
||||
}),
|
||||
client.course.findUnique({ where: { id: courseId }, select: { price_fen: true } }),
|
||||
]);
|
||||
return product?.price_fen ?? course?.price_fen ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
function formatZodError(error: z.ZodError): string {
|
||||
|
||||
@@ -163,4 +163,6 @@ export const STATUS_TEXT: Record<number, string> = {
|
||||
[OrderStatus.DELIVERED]: '已开通',
|
||||
[OrderStatus.REFUNDED]: '已退款',
|
||||
[OrderStatus.CLOSED]: '已关闭',
|
||||
[OrderStatus.ADMIN_GRANT]: '后台主动发放',
|
||||
[OrderStatus.ADMIN_REVOKE]: '后台主动回收',
|
||||
};
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Controller, Get, Logger, Post, Query, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { Public } from '../../common/auth';
|
||||
import { decryptWechatMessage, sameHex, wechatSignature, xmlField } from '../../common/wechat-msg';
|
||||
import {
|
||||
decryptWechatMessage,
|
||||
isCustomerServicePush,
|
||||
sameHex,
|
||||
wechatSignature,
|
||||
xmlField,
|
||||
} from '../../common/wechat-msg';
|
||||
import { InjectConfig, type AppConfig } from '../../config/config';
|
||||
import { PaymentsService } from './payments.service';
|
||||
|
||||
@@ -73,6 +79,12 @@ export class PaymentsController {
|
||||
res.type(body.trimStart().startsWith('<') ? 'application/xml' : 'application/json').send(answer);
|
||||
return;
|
||||
}
|
||||
// 客服消息走的也是这个地址:用户点客服按钮(user_enter_tempsession)或给客服发消息。
|
||||
// 不单独分流的话,这些推送只会被记成「未识别的事件」,用户也等不到任何回应。
|
||||
if (isCustomerServicePush(body)) {
|
||||
res.type('text/plain').send(await this.payments.handleCustomerService(body));
|
||||
return;
|
||||
}
|
||||
// 地址会收到全部事件(投诉、风控、代币等),其余统一回 success 表示已收到。
|
||||
// 先落一条日志,否则这类事件事后完全查不到痕迹。
|
||||
await this.payments.logIgnoredNotify(body);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { XMLParser } from 'fast-xml-parser';
|
||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||
import { decryptSecret } from '../../common/crypto';
|
||||
import { AppError, ErrorCode } from '../../common/errors';
|
||||
import { cdata, eventOf, fieldOf } from '../../common/wechat-msg';
|
||||
import { cdata, customerServiceMsgTypeLabel, eventOf, fieldOf } from '../../common/wechat-msg';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { WxPayService } from './wxpay.service';
|
||||
|
||||
@@ -13,6 +13,10 @@ export const OrderStatus = {
|
||||
DELIVERED: 2,
|
||||
REFUNDED: 3,
|
||||
CLOSED: 4,
|
||||
/** 后台手动发放权益,不是支付单 */
|
||||
ADMIN_GRANT: 5,
|
||||
/** 后台手动回收权益,不是支付单 */
|
||||
ADMIN_REVOKE: 6,
|
||||
} as const;
|
||||
|
||||
interface DeliverNotify {
|
||||
@@ -56,8 +60,15 @@ const STATUS_CN: Record<number, string> = {
|
||||
[OrderStatus.DELIVERED]: '已开通',
|
||||
[OrderStatus.REFUNDED]: '已退款',
|
||||
[OrderStatus.CLOSED]: '已关闭',
|
||||
[OrderStatus.ADMIN_GRANT]: '后台主动发放',
|
||||
[OrderStatus.ADMIN_REVOKE]: '后台主动回收',
|
||||
};
|
||||
|
||||
/** 用户点客服按钮、进入客服会话时微信推来的事件名。 */
|
||||
const CUSTOMER_SERVICE_ENTER_EVENT = 'user_enter_tempsession';
|
||||
/** 用户发来的客服消息在日志里的前缀,后面跟 MsgType(text / image …)。 */
|
||||
const CUSTOMER_SERVICE_MSG_PREFIX = 'cs_message:';
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
@@ -334,6 +345,50 @@ export class PaymentsService {
|
||||
return delivered;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理客服消息推送:只记录「谁来访了一次」,不做任何回复。
|
||||
*
|
||||
* 为什么在这里:小程序后台只有一个「消息推送 URL」,客服消息和支付推送走的是
|
||||
* 同一个地址。不单独分流的话,这些推送只会被记成「未识别的事件」,
|
||||
* 后台既不知道是谁点了客服,也不知道来过几次。
|
||||
*/
|
||||
async handleCustomerService(rawBody: string): Promise<string> {
|
||||
const entered = eventOf(rawBody) === CUSTOMER_SERVICE_ENTER_EVENT;
|
||||
const msgType = fieldOf(rawBody, 'MsgType');
|
||||
const openid = fieldOf(rawBody, 'FromUserName');
|
||||
const content = fieldOf(rawBody, 'Content');
|
||||
const eventName = entered
|
||||
? CUSTOMER_SERVICE_ENTER_EVENT
|
||||
: `${CUSTOMER_SERVICE_MSG_PREFIX}${msgType || 'unknown'}`;
|
||||
|
||||
const logId = await this.logNotify(rawBody, eventName);
|
||||
try {
|
||||
const user = openid
|
||||
? await this.prisma.active.user.findUnique({
|
||||
where: { openid },
|
||||
select: { id: true, nickname: true },
|
||||
})
|
||||
: null;
|
||||
const who = user
|
||||
? `用户 #${user.id} ${user.nickname ?? ''}`.trim()
|
||||
: `未知用户(openid=${openid || '空'})`;
|
||||
// 进会话就是一次到访;发消息也算,顺带把消息类型和内容记下来
|
||||
const detail = entered
|
||||
? ''
|
||||
: `|发来${customerServiceMsgTypeLabel(msgType)}${content ? `:${content.slice(0, 60)}` : ''}`;
|
||||
|
||||
await this.markNotify(logId, 1, `${who} 进行了一次客服访问${detail}`.slice(0, 255));
|
||||
this.logger.log(`客服访问 ${eventName} ${who}${detail}`);
|
||||
return 'success';
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`客服消息处理失败:${message}`);
|
||||
await this.markNotify(logId, 2, `客服访问记录失败:${message}`.slice(0, 255));
|
||||
// 回 success:微信那边只看「收到没有」,处理失败不必让它重推
|
||||
return 'success';
|
||||
}
|
||||
}
|
||||
|
||||
/** 取用户 sessionKey(明文)。不存在时提示重新登录。 */
|
||||
async requireSessionKey(userId: number): Promise<string> {
|
||||
const user = await this.prisma.active.user.findUnique({ where: { id: userId } });
|
||||
@@ -377,9 +432,10 @@ export class PaymentsService {
|
||||
]);
|
||||
}
|
||||
|
||||
private async logNotify(rawXml: string): Promise<number> {
|
||||
/** 记一条原始推送。event 默认从报文里取,取不到时允许调用方指定(客服消息没有 Event)。 */
|
||||
private async logNotify(rawXml: string, event?: string): Promise<number> {
|
||||
const row = await this.prisma.active.paymentNotifyLog.create({
|
||||
data: { raw_body: rawXml, event: eventOf(rawXml) || null },
|
||||
data: { raw_body: rawXml, event: event ?? (eventOf(rawXml) || null) },
|
||||
});
|
||||
return row.id;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user