客服推送处理+后台操作

This commit is contained in:
Yeomai committed 2026-09-30 20:57:47 +08:00
1 parent b652132957
commit a81c637a98
11 files changed
+302 -54

No files matched your search

@@ -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;
}