支付通路

This commit is contained in:
tianmo committed 2026-09-30 15:08:16 +08:00
1 parent c7f43e30a9
commit 85b0e70907
26 files changed
+1324 -74

No files matched your search

@@ -0,0 +1,3 @@
-- 下单设备:由小程序上报 wx.getDeviceInfo().platform。
-- 用途是判断这笔单的支付渠道(iOS 走 Apple 支付,只能用户自己找 App Store 退)。
ALTER TABLE `orders` ADD COLUMN `terminal` VARCHAR(16) NULL;
@@ -0,0 +1,19 @@
-- 退款申请:iOS 用户向 Apple 申请退款后,平台发来的退款问询落库,供后台查看
CREATE TABLE `refund_requests` (
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
`pay_order_id` VARCHAR(64) NOT NULL,
`order_id` INTEGER UNSIGNED NULL,
`reason` VARCHAR(255) NULL,
`provide_status` VARCHAR(4) NULL,
`our_result` TINYINT NOT NULL,
`our_evidence` VARCHAR(255) NULL,
`query_count` INTEGER UNSIGNED NOT NULL DEFAULT 1,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `refund_requests_pay_order_id_key`(`pay_order_id`),
INDEX `refund_requests_created_at_idx`(`created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `refund_requests` ADD CONSTRAINT `refund_requests_order_id_fkey` FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- 管理员「同意退款」标记。iOS 退款问询时,标了的订单直接放过。
-- 是事前授权:标记必须发生在苹果下一次问询之前。
ALTER TABLE `orders` ADD COLUMN `refund_consent_at` DATETIME(3) NULL;
@@ -0,0 +1,3 @@
-- 已向微信发起退款的时间。退款是异步的,微信退完才推 xpay_refund_notify 回写「已退款」,
-- 中间靠这一列显示「退款中」,避免管理员重复点退款。
ALTER TABLE `orders` ADD COLUMN `refund_requested_at` DATETIME(3) NULL;
+42 -1
View File
@@ -241,6 +241,15 @@ model Order {
status Int @db.TinyInt
/// 固定 0(正式环境)
env Int @default(0) @db.TinyInt
/// 下单设备,由小程序上报 wx.getDeviceInfo().platform:ios / android / windows / devtools...
/// 平台按设备路由支付渠道(iOS 走 Apple 支付),所以它决定这笔单能不能主动退款
terminal String? @db.VarChar(16)
/// 管理员已同意退款的时间。iOS 用户申请退款时苹果会发问询,看到这一列非空就直接放过。
/// 这是「事前授权」:必须在下一次问询到达前标记,标记之后用户再去 App Store 申请才有效。
refund_consent_at DateTime? @map("refund_consent_at")
/// 已向微信发起退款的时间。退款是异步的——微信退完才推 xpay_refund_notify 回写「已退款」,
/// 中间这段时间靠它显示「退款中」,避免重复点退款。
refund_requested_at DateTime? @map("refund_requested_at")
/// 微信平台单号,发货幂等依据
wx_order_id String? @unique @map("wx_order_id") @db.VarChar(64)
/// 下单时透传字段,发货通知原样带回
@@ -253,11 +262,44 @@ model Order {
user User @relation(fields: [user_id], references: [id])
course Course @relation(fields: [course_id], references: [id])
refund_requests RefundRequest[]
@@index([user_id, created_at])
@@index([status, created_at])
@@map("orders")
}
/// 退款申请。
///
/// Apple 支付不允许开发者主动退款,用户得去 App Store 申请;申请后 Apple 会向我们发
/// 退款问询(xpay_subscribe_ios_refund_query_notify),必须 3 秒内表态,所以决策是自动的。
/// 这张表把每次问询和我们的答复记下来,供后台查看。
/// 微信支付渠道(安卓等)没有用户申请入口,所以这里基本只有 iOS 会产生记录。
model RefundRequest {
id Int @id @default(autoincrement()) @db.UnsignedInt
/// 问询里的支付订单号。平台会重复问询,用它做幂等
pay_order_id String @unique @map("pay_order_id") @db.VarChar(64)
/// 匹配到的本地订单;查不到时为空
order_id Int? @map("order_id") @db.UnsignedInt
/// 用户申请退款的原因
reason String? @db.VarChar(255)
/// 推送里的发货状态:0 未发货 / 1 已发货 / 2 发货中
provide_status String? @map("provide_status") @db.VarChar(4)
/// 我们的答复:0 建议退款 / 1 拒绝退款
our_result Int @map("our_result") @db.TinyInt
/// 答复依据,写给人看
our_evidence String? @map("our_evidence") @db.VarChar(255)
/// 平台重复问询的次数
query_count Int @default(1) @map("query_count") @db.UnsignedInt
created_at DateTime @default(now()) @map("created_at")
updated_at DateTime @updatedAt @map("updated_at")
order Order? @relation(fields: [order_id], references: [id])
@@index([created_at])
@@map("refund_requests")
}
/// 发货推送原始记录,用于幂等、重放与排查
model PaymentNotifyLog {
id Int @id @default(autoincrement()) @db.UnsignedInt
@@ -306,4 +348,3 @@ model AdminUser {
@@map("admin_users")
}
+35 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { decryptWechatMessage, sameHex, wechatSignature, xmlField } from './wechat-msg';
import { cdata, decryptWechatMessage, eventOf, fieldOf, sameHex, wechatSignature, xmlField } from './wechat-msg';
// 用例全部取自微信官方文档「消息推送」的签名与加解密示例,改坏签名算法会立刻失败。
describe('wechat-msg', () => {
@@ -35,4 +35,38 @@ describe('wechat-msg', () => {
expect(sameHex('ab', 'abc')).toBe(false);
expect(sameHex('', '')).toBe(false);
});
it('eventOf 同时认 XML 和 JSON 两种推送', () => {
expect(eventOf('<xml><Event><![CDATA[xpay_goods_deliver_notify]]></Event></xml>')).toBe(
'xpay_goods_deliver_notify',
);
expect(eventOf('{"Event":"xpay_subscribe_ios_refund_query_notify","pay_order_id":"T1"}')).toBe(
'xpay_subscribe_ios_refund_query_notify',
);
expect(eventOf('<xml><MsgType>event</MsgType></xml>')).toBe('');
expect(eventOf('not json and not xml')).toBe('');
});
it('fieldOf 从真实问询 XML 里取字段', () => {
// 这段是生产环境真实收到的报文(截取自 payment_notify_logs)
const xml =
'<xml><MsgType><![CDATA[event]]></MsgType>' +
'<Event><![CDATA[xpay_subscribe_ios_refund_query_notify]]></Event>' +
'<product_id><![CDATA[10003]]></product_id>' +
'<refund_request_reason><![CDATA[UNINTENDED_PURCHASE]]></refund_request_reason>' +
'<provide_status><![CDATA[1]]></provide_status>' +
'<pay_order_id><![CDATA[T17907447014331121]]></pay_order_id></xml>';
expect(fieldOf(xml, 'pay_order_id')).toBe('T17907447014331121');
expect(fieldOf(xml, 'provide_status')).toBe('1');
expect(fieldOf(xml, 'refund_request_reason')).toBe('UNINTENDED_PURCHASE');
expect(fieldOf(xml, 'product_id')).toBe('10003');
expect(fieldOf(xml, '不存在的字段')).toBe('');
// JSON 也照样能取
expect(fieldOf('{"pay_order_id":"T1"}', 'pay_order_id')).toBe('T1');
});
it('cdata 会挡住能撑破 XML 的 ]]>', () => {
expect(cdata('a]]>b')).toBe('<![CDATA[a]] >b]]>');
expect(cdata('普通文本')).toBe('<![CDATA[普通文本]]>');
});
});
+40
View File
@@ -25,6 +25,46 @@ export function xmlField(xml: string, field: string): string {
return match ? match[1]!.trim() : '';
}
/**
* 取推送的事件名。发货/退款推送是 XML,iOS 退款问询是 JSON,两种都要认。
* 认不出来就返回空串——调用方据此记「无 Event」。
*/
export function eventOf(body: string): string {
const fromXml = xmlField(body, 'Event');
if (fromXml) return fromXml;
try {
const parsed = JSON.parse(body) as { Event?: unknown; event?: unknown };
const value = parsed.Event ?? parsed.event;
return typeof value === 'string' ? value : '';
} catch {
return '';
}
}
/**
* 取推送里的任意字段,XML 和 JSON 都认。
*
* 为什么需要:文档把 iOS 退款问询的报文体写成了 JSON 对象,但实际推过来的是
* 明文模式的标准 XML 信封(`<pay_order_id><![CDATA[...]]></pay_order_id>`)。
* 只按 JSON 解析会抛异常,整个问询处理直接掉进兜底分支。
*/
export function fieldOf(body: string, field: string): string {
const fromXml = xmlField(body, field);
if (fromXml) return fromXml;
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
const value = parsed[field];
return value === undefined || value === null ? '' : String(value);
} catch {
return '';
}
}
/** 把值塞进 CDATA 安全地用于回包。 */
export function cdata(value: string): string {
return `<![CDATA[${value.replace(/]]>/g, ']] >')}]]>`;
}
/**
* 安全模式:AES-256-CBC。
* 密钥 = Base64(EncodingAESKey + '='),IV 取密钥前 16 字节。
@@ -22,7 +22,12 @@ const grantSchema = z.object({
expireAt: z.coerce.date().nullish(),
note: z.string().max(255).optional(),
});
const refundSchema = z.object({ reason: z.string().min(1).max(100) });
const refundSchema = z.object({
reason: z.string().min(1).max(100),
/** 微信只认这几个值,缺省按「用户主动退款」 */
reasonCode: z.enum(['0', '1', '2', '3', '4', '5']).default('3'),
});
const refundConsentSchema = z.object({ consent: z.boolean() });
const dataSourceSchema = z.object({ env: z.enum(['production', 'develop']) });
/** 管理员登录。必须公开,否则登录接口本身会被守卫拦住。 */
@@ -169,7 +174,18 @@ export class AdminController {
refundOrder(@AdminEnvParam() env: DataSourceEnv, @Param('id', ParseIntPipe) id: number, @Body() body: unknown) {
const parsed = refundSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('请填写退款原因');
return this.admin.refundOrder(env, id, parsed.data.reason);
return this.admin.refundOrder(env, id, parsed.data.reason, parsed.data.reasonCode);
}
@Post('orders/:id/refund-consent')
setRefundConsent(
@AdminEnvParam() env: DataSourceEnv,
@Param('id', ParseIntPipe) id: number,
@Body() body: unknown,
) {
const parsed = refundConsentSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('参数错误');
return this.admin.setRefundConsent(env, id, parsed.data.consent);
}
@Get('entitlements')
@@ -177,6 +193,24 @@ export class AdminController {
return this.admin.listEntitlements(env, userId);
}
@Get('refund-requests')
listRefundRequests(
@AdminEnvParam() env: DataSourceEnv,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.admin.listRefundRequests(env, Number(page) || 1, Number(pageSize) || 20);
}
@Get('notify-logs')
listNotifyLogs(
@AdminEnvParam() env: DataSourceEnv,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.admin.listNotifyLogs(env, Number(page) || 1, Number(pageSize) || 20);
}
@Post('entitlements')
grantEntitlement(@AdminEnvParam() env: DataSourceEnv, @Body() body: unknown) {
const parsed = grantSchema.safeParse(body);
@@ -5,11 +5,22 @@ 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 { PrismaService } from '../../prisma/prisma.service';
import { OrderStatus, PaymentsService } from '../payments/payments.service';
import { WxPayService } from '../payments/wxpay.service';
import { READONLY_RESOURCES, RESOURCES, type ResourceDef } from './resource-registry';
/** 微信推送事件的中文名,给后台「通知日志」页显示。 */
const NOTIFY_EVENT_LABEL: Record<string, string> = {
xpay_goods_deliver_notify: '发货推送',
xpay_subscribe_ios_refund_query_notify: 'iOS 退款问询',
xpay_refund_notify: '退款成功通知',
xpay_coin_pay_notify: '代币支付推送',
xpay_complaint_notify: '用户投诉',
xpay_wxpay_callback_notify: '微信支付风控事件',
};
/** 通用委托接口。用最小结构约束代替 any,保持 strict 校验通过。 */
interface Delegate {
findMany: (args?: unknown) => Promise<unknown[]>;
@@ -256,6 +267,9 @@ export class AdminService {
wx_order_id: o.wx_order_id,
status: o.status,
amount_fen: o.amount_fen,
terminal: o.terminal,
refund_consent_at: o.refund_consent_at,
refund_requested_at: o.refund_requested_at,
nickname: o.user.nickname,
openid: o.user.openid,
course_title: o.course.title,
@@ -270,23 +284,161 @@ export class AdminService {
};
}
/**
* 通知日志:微信发来的每一条推送(发货、退款问询、退款成功、投诉、风控…)。
* 用来回答「这条通知到底来没来、我们怎么处理的」。
*/
async listNotifyLogs(env: DataSourceEnv, page = 1, pageSize = 20) {
const db = this.prisma.clientFor(env);
const size = Math.min(Math.max(pageSize, 1), 100);
const current = Math.max(page, 1);
const [items, total] = await db.$transaction([
db.paymentNotifyLog.findMany({
orderBy: { id: 'desc' },
skip: (current - 1) * size,
take: size,
}),
db.paymentNotifyLog.count(),
]);
return {
items: items.map((r) => {
const body = r.raw_body ?? '';
return {
id: r.id,
event: r.event,
event_label: NOTIFY_EVENT_LABEL[r.event ?? ''] ?? '未识别的事件',
processed: r.processed,
err_msg: r.err_msg,
// 发货推送带 OutTradeNo,退款问询带 pay_order_id,统一抽成一个「关联订单」
order_no: fieldOf(body, 'OutTradeNo') || fieldOf(body, 'pay_order_id') || r.out_trade_no || null,
raw_body: body.length > 2000 ? body.slice(0, 2000) + '\n…(已截断)' : body,
created_at: r.created_at,
};
}),
total,
page: current,
pageSize: size,
};
}
/**
* 退款申请列表。
*
* 记录来自 iOS 退款问询(Apple 不允许开发者主动退,用户去 App Store 申请后平台问询我们)。
* 问询必须 3 秒内应答,所以答复是自动的;这里只做「看得见」。
* Apple 最终是否退款没有单独的推送——订单状态变成「已退款」就说明退了。
*/
async listRefundRequests(env: DataSourceEnv, page = 1, pageSize = 20) {
const db = this.prisma.clientFor(env);
const size = Math.min(Math.max(pageSize, 1), 100);
const current = Math.max(page, 1);
const [items, total] = await db.$transaction([
db.refundRequest.findMany({
orderBy: { created_at: 'desc' },
skip: (current - 1) * size,
take: size,
include: {
order: {
select: {
out_trade_no: true,
status: true,
amount_fen: true,
terminal: true,
user: { select: { nickname: true, openid: true } },
course: { select: { title: true } },
},
},
},
}),
db.refundRequest.count(),
]);
return {
items: items.map((r) => ({
id: r.id,
pay_order_id: r.pay_order_id,
out_trade_no: r.order?.out_trade_no ?? null,
nickname: r.order?.user?.nickname ?? null,
openid: r.order?.user?.openid ?? null,
course_title: r.order?.course?.title ?? null,
amount_fen: r.order?.amount_fen ?? null,
terminal: r.order?.terminal ?? null,
order_status: r.order?.status ?? null,
reason: r.reason,
provide_status: r.provide_status,
our_result: r.our_result,
our_evidence: r.our_evidence,
query_count: r.query_count,
created_at: r.created_at,
})),
total,
page: current,
pageSize: size,
};
}
/**
* 打 / 取消「同意退款」标记。
*
* 用途:iOS 用户申请退款时苹果会发问询,必须 3 秒内表态、来不及人工介入。
* 所以这是**事前授权**——管理员先跟用户沟通好、在后台标上,用户再去 App Store 申请,
* 那次问询我们就会放过。标晚了没用。
*/
async setRefundConsent(env: DataSourceEnv, orderId: number, consent: boolean) {
const db = this.prisma.clientFor(env);
const order = await db.order.findUnique({ where: { id: orderId }, select: { id: true } });
if (!order) throw AppError.notFound('订单不存在');
const updated = await db.order.update({
where: { id: orderId },
data: { refund_consent_at: consent ? new Date() : null },
select: { id: true, out_trade_no: true, refund_consent_at: true },
});
return {
id: updated.id,
out_trade_no: updated.out_trade_no,
refund_consent: updated.refund_consent_at !== null,
refund_consent_at: updated.refund_consent_at,
};
}
/** 发起退款(仅非 iOS 终端)。退款成功由退款推送回写订单状态。 */
async refundOrder(env: DataSourceEnv, orderId: number, reason: string) {
async refundOrder(env: DataSourceEnv, orderId: number, reason: string, reasonCode: string) {
// 开发库的订单在微信侧不存在,发退款请求必然失败,直接拦住
if (env === 'develop') {
throw AppError.badRequest('当前数据源是开发库,订单在微信侧不存在,无法退款');
}
const order = await this.prisma.clientFor(env).order.findUnique({ where: { id: orderId }, include: { user: true } });
const db = this.prisma.clientFor(env);
const order = await db.order.findUnique({ where: { id: orderId }, include: { user: true } });
if (!order) throw AppError.notFound('订单不存在');
if (order.status !== OrderStatus.DELIVERED) throw AppError.conflict('只有已开通的订单可以退款');
// 沙箱单在微信侧是另一套账,用现网的密钥查不到、也退不了
if (order.env !== this.wxpay.env) {
throw AppError.conflict(
`该订单是${order.env === 1 ? '沙箱' : '现网'}环境创建的,当前服务端是${this.wxpay.env === 1 ? '沙箱' : '现网'}环境,无法退款`,
);
}
const result = await this.wxpay.refundOrder({
outTradeNo: order.out_trade_no,
openid: order.user.openid,
refundOrderId: `R${order.out_trade_no}`,
refundAmountFen: order.amount_fen,
reason: reason.slice(0, 100),
refundFeeFen: order.amount_fen,
reasonCode,
// 微信只要枚举值,管理员写的文字放进 biz_meta,事后能查回来
bizMeta: JSON.stringify({ reason: reason.slice(0, 200), source: 'admin' }),
});
// 退款是异步的:微信只是受理了,钱到账后才会推 xpay_refund_notify 回写「已退款」。
// 先记下发起时间,后台据此显示「退款中」,也避免管理员重复点。
await db.order.update({
where: { id: order.id },
data: { refund_requested_at: new Date() },
});
return { submitted: true, providerResponse: result };
}
@@ -7,6 +7,8 @@ import { OrdersService } from './orders.service';
const createSchema = z.object({
courseCode: z.string().min(1),
quantity: z.coerce.number().int().min(1).max(10).optional(),
/** 下单设备,小程序上报;缺省不影响下单,只是后台看不到渠道 */
terminal: z.string().max(16).optional(),
});
@Controller('v1/orders')
@@ -17,7 +19,12 @@ export class OrdersController {
create(@CurrentUser() user: AuthPrincipal, @Body() body: unknown) {
const parsed = createSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('参数错误:缺少 courseCode');
return this.orders.create(user.id, parsed.data.courseCode, parsed.data.quantity ?? 1);
return this.orders.create(
user.id,
parsed.data.courseCode,
parsed.data.quantity ?? 1,
parsed.data.terminal ?? null,
);
}
@Get()
@@ -30,4 +37,3 @@ export class OrdersController {
return this.orders.status(user.id, outTradeNo);
}
}
@@ -28,7 +28,12 @@ export class OrdersService {
* - signData 是要原样传给客户端的字符串,签名基于同一份字符串
* - goodsPrice 单位是分,必须与 MP 后台道具价格一致
*/
async create(userId: number, courseCode: string, quantity = 1): Promise<{ outTradeNo: string; payData: PayData }> {
async create(
userId: number,
courseCode: string,
quantity = 1,
terminal: string | null = null,
): Promise<{ outTradeNo: string; payData: PayData }> {
if (!this.wxpay.enabled) {
throw new AppError(ErrorCode.PAY_NOT_ENABLED, '支付尚未开通,请联系管理员', 503);
}
@@ -72,6 +77,7 @@ export class OrdersService {
amount_fen: amountFen,
status: OrderStatus.CREATED,
env: this.wxpay.env,
terminal,
attach: `course:${course.code}`,
},
});
@@ -112,6 +118,7 @@ export class OrdersService {
status: order.status,
statusText: STATUS_TEXT[order.status] ?? '未知',
amountFen: order.amount_fen,
terminal: order.terminal,
courseCode: order.course.code,
courseTitle: order.course.title,
createdAt: order.created_at,
@@ -134,6 +141,7 @@ export class OrdersService {
status: o.status,
statusText: STATUS_TEXT[o.status] ?? '未知',
amountFen: o.amount_fen,
terminal: o.terminal,
courseCode: o.course.code,
courseTitle: o.course.title,
createdAt: o.created_at,
@@ -66,7 +66,16 @@ export class PaymentsController {
res.type('application/xml').send(await this.payments.handleRefundNotify(body));
return;
}
// 地址会收到全部事件(投诉、风控、代币等),其余统一回 success 表示已收到
// iOS 退款问询必须 3 秒内表态,否则微信向 Apple 回「不确定」
if (body.includes('xpay_subscribe_ios_refund_query_notify')) {
const answer = await this.payments.handleIosRefundQuery(body);
// 来的是 XML 信封就回 XML,来的是 JSON 就回 JSON
res.type(body.trimStart().startsWith('<') ? 'application/xml' : 'application/json').send(answer);
return;
}
// 地址会收到全部事件(投诉、风控、代币等),其余统一回 success 表示已收到。
// 先落一条日志,否则这类事件事后完全查不到痕迹。
await this.payments.logIgnoredNotify(body);
res.type('text/plain').send('success');
}
@@ -3,6 +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 { PrismaService } from '../../prisma/prisma.service';
import { WxPayService } from './wxpay.service';
@@ -18,7 +19,7 @@ interface DeliverNotify {
Event?: string;
OpenId?: string;
OutTradeNo?: string;
WeChatPayInfo?: { MchOrderNo?: string };
WeChatPayInfo?: { MchOrderNo?: string; TransactionId?: string };
GoodsInfo?: { ProductId?: string; Quantity?: number };
}
@@ -37,6 +38,26 @@ interface RefundNotify {
RetMsg?: string;
}
/** iOS 退款问询(JSON 报文)。 */
interface RefundQueryNotify {
/** 退款对应的支付订单号 */
pay_order_id?: string;
/** 0 未发货 / 1 已发货 / 2 发货中 */
provide_status?: string;
refund_request_reason?: string;
product_id?: string;
channel_bill?: string;
}
/** 订单状态中文名。不走 orders.service 的导出,避免两个模块循环依赖。 */
const STATUS_CN: Record<number, string> = {
[OrderStatus.CREATED]: '待支付',
[OrderStatus.PAID]: '已支付未发货',
[OrderStatus.DELIVERED]: '已开通',
[OrderStatus.REFUNDED]: '已退款',
[OrderStatus.CLOSED]: '已关闭',
};
@Injectable()
export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
@@ -69,9 +90,16 @@ export class PaymentsService {
}
const outTradeNo = notify.OutTradeNo ?? '';
const wxOrderId = notify.WeChatPayInfo?.MchOrderNo ?? '';
const openid = notify.OpenId ?? '';
if (!outTradeNo || !wxOrderId) throw new Error('推送缺少 OutTradeNo 或 MchOrderNo');
if (!outTradeNo) throw new Error('推送缺少 OutTradeNo');
// 平台单号只用于幂等。文档说 WeChatPayInfo「非微信支付渠道可能没有」,
// 也就是 Apple 支付的推送可能不带它——那样按原样判失败,iOS 用户付了钱也开不了课。
// 取不到就用订单号兜底,保证照常发货。
const wxOrderId =
notify.WeChatPayInfo?.MchOrderNo ||
notify.WeChatPayInfo?.TransactionId ||
`local:${outTradeNo}`;
const order = await this.prisma.active.order.findUnique({
where: { out_trade_no: outTradeNo },
@@ -156,6 +184,106 @@ export class PaymentsService {
}
}
/**
* 收到但不由我们处理的推送也要留痕。
* 之前这类事件被静默丢掉,连日志都没有,事后完全看不出它来过。
*/
async logIgnoredNotify(rawBody: string): Promise<void> {
const event = eventOf(rawBody) || '(无 Event)';
await this.prisma.active.paymentNotifyLog.create({
data: { raw_body: rawBody, event, processed: 0, err_msg: `收到未处理的推送:${event}`.slice(0, 255) },
});
}
/**
* iOS 退款问询(xpay_subscribe_ios_refund_query_notify)。
*
* Apple 支付不能由开发者主动退款,用户去 App Store 申请后,Apple 会向我们发问询,
* 我们必须表态:0 = 放过(建议退款),1 = 拦截(拒绝退款),并给决策凭据。
* 连续 3 次、每次 3 秒内没答,微信就向 Apple 回「不确定」,等于弃权。
*
* 策略:以我们自己的订单状态为准——已发货就拦(用户已经拿到权益),其余放过。
* 真退了钱平台还会推 xpay_refund_notify,那时再收回权益,所以「放过」不会让自己吃亏。
*/
async handleIosRefundQuery(rawBody: string): Promise<string> {
const logId = await this.logNotify(rawBody);
let payOrderId = '';
try {
// 文档写的是 JSON,实际推过来的是明文 XML 信封,两种都按字段名取
payOrderId = fieldOf(rawBody, 'pay_order_id');
const provideStatus = fieldOf(rawBody, 'provide_status');
const reason = fieldOf(rawBody, 'refund_request_reason');
// pay_order_id 是「支付订单号」,我们自己的 out_trade_no 和平台单号都试一遍
const order = payOrderId
? await this.prisma.active.order.findFirst({
where: { OR: [{ out_trade_no: payOrderId }, { wx_order_id: payOrderId }] },
select: { id: true, out_trade_no: true, status: true, refund_consent_at: true },
})
: null;
// 查得到订单就信自己的状态;查不到才退回用推送里的发货状态
const delivered = order ? order.status === OrderStatus.DELIVERED : provideStatus === '1';
// 管理员提前打过「同意退款」的,一律放过——用户是跟你沟通过的
const consented = Boolean(order?.refund_consent_at);
const reject = delivered && !consented;
const resultCode = reject ? 1 : 0;
const resultInfo = reject ? '已发货,拒绝退款' : consented ? '管理员已同意退款' : '未发货,同意退款';
const evidence = consented
? `管理员已于 ${order!.refund_consent_at!.toISOString()} 同意该订单退款`
: order
? `本地订单 ${order.out_trade_no} 状态为${STATUS_CN[order.status] ?? order.status}${delivered ? ',用户已获得课程权益' : ',尚未发放权益'}`
: `未找到对应订单(pay_order_id=${payOrderId || '空'}),按未发货处理`;
// 落一条「退款申请」供后台查看。平台会重复问询,同一个支付订单号只累加次数。
if (payOrderId) {
const record = {
order_id: order?.id ?? null,
reason: reason.slice(0, 255) || null,
provide_status: provideStatus || null,
our_result: resultCode,
our_evidence: evidence.slice(0, 255),
};
await this.prisma.active.refundRequest.upsert({
where: { pay_order_id: payOrderId },
create: { pay_order_id: payOrderId, ...record },
update: { ...record, query_count: { increment: 1 } },
});
}
await this.markNotify(
logId,
1,
`退款问询→${reject ? '拒绝' : '放过'} | ${payOrderId} | ${reason}`.slice(0, 255),
);
this.logger.log(`iOS 退款问询 ${payOrderId || '-'} → ${resultInfo}`);
return this.refundQueryAnswer(rawBody, resultCode, resultInfo, evidence);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`退款问询处理失败:${message}`);
await this.markNotify(logId, 2, `退款问询处理失败:${message}`.slice(0, 255));
// 自己出错时不要替用户做决定:退回「放过」,真退了钱还有退款推送兜底收回权益
return this.refundQueryAnswer(
rawBody,
0,
'处理异常,不做拦截',
`服务端处理问询异常:${message}`.slice(0, 200),
);
}
}
/**
* 按来件格式回包:来的是 XML 就回 XML,来的是 JSON 就回 JSON。
* 文档只列了三个字段,没说报文体格式;明文模式下实际推来的是 XML 信封,
* 所以对称回 XML 最稳。
*/
private refundQueryAnswer(request: string, code: number, info: string, evidence: string): string {
if (request.trimStart().startsWith('<')) {
return `<xml><result_code>${code}</result_code><result_info>${cdata(info)}</result_info><evidence>${cdata(evidence)}</evidence></xml>`;
}
return JSON.stringify({ result_code: code, result_info: info, evidence });
}
/**
* 兜底查单:补发「已支付但没收到推送」的订单。
* 由定时任务每 5 分钟调用一次。
@@ -175,8 +303,17 @@ export class PaymentsService {
try {
const result = await this.wxpay.queryOrder(order.out_trade_no, order.user.openid);
const wxOrderId = result.wx_order_id ?? result.order?.wx_order_id;
const isPaid = result.status === 2 || result.order?.status === 2 || Boolean(wxOrderId);
if (!isPaid) continue;
// 只认「已支付待发货 / 发货中 / 已发货」这三种状态。
// 不能用「有没有 wx_order_id」当判据——已关闭(6)和已退款(5)的单也有单号,
// 那样会把关闭的沙箱单当成已支付补发出去(真实发生过)。
const status = result.order?.status;
const isPaid = status === 2 || status === 3 || status === 4;
if (!isPaid) {
if (status !== undefined && status !== 1) {
this.logger.warn(`兜底查单跳过 ${order.out_trade_no}:微信侧状态 ${status}`);
}
continue;
}
await this.grantEntitlement(
order.user_id,
@@ -240,7 +377,9 @@ export class PaymentsService {
}
private async logNotify(rawXml: string): Promise<number> {
const row = await this.prisma.active.paymentNotifyLog.create({ data: { raw_body: rawXml } });
const row = await this.prisma.active.paymentNotifyLog.create({
data: { raw_body: rawXml, event: eventOf(rawXml) || null },
});
return row.id;
}
@@ -5,6 +5,19 @@ import { AppError } from '../../common/errors';
const XPAY_BASE = 'https://api.weixin.qq.com';
/** query_order 返回的订单状态。 */
const XPAY_STATUS: Record<number, string> = {
0: '初始化',
1: '创建成功未支付',
2: '已支付待发货',
3: '发货中',
4: '已发货',
5: '已退款',
6: '已关闭',
7: '退款失败',
8: '用户退款完成',
};
/**
* 微信小程序虚拟支付客户端。
*
@@ -21,6 +34,9 @@ const XPAY_BASE = 'https://api.weixin.qq.com';
export class WxPayService {
private readonly logger = new Logger(WxPayService.name);
/** access_token 缓存。有效期 7200 秒,提前 5 分钟换新的。 */
private tokenCache: { value: string; expiresAt: number } | null = null;
constructor(@Inject(APP_CONFIG) private readonly cfg: AppConfig) {}
/** 当前支付环境:0 现网 / 1 沙箱。 */
@@ -51,6 +67,46 @@ export class WxPayService {
return createHmac('sha256', sessionKey).update(postBody).digest('hex');
}
/**
* 小程序 access_token。
*
* 这些 /xpay/* 服务端接口要求 URL 上同时带 access_token 和 pay_sig:
* https://api.weixin.qq.com/xpay/query_order?access_token=xxx&pay_sig=xxx
* 只带 pay_sig 会返回 41001 access_token missing——查单和退款都会静默失败。
*/
private async accessToken(): Promise<string> {
const now = Date.now();
if (this.tokenCache && this.tokenCache.expiresAt > now) return this.tokenCache.value;
const url =
`${XPAY_BASE}/cgi-bin/token?grant_type=client_credential` +
`&appid=${encodeURIComponent(this.cfg.WX_APPID)}` +
`&secret=${encodeURIComponent(this.cfg.WX_APPSECRET)}`;
const res = await fetch(url);
const json = (await res.json()) as {
access_token?: string;
expires_in?: number;
errcode?: number;
errmsg?: string;
};
if (!json.access_token) {
throw new AppError(
4002,
`获取 access_token 失败:${json.errcode ?? '-'} ${json.errmsg ?? ''}`.trim(),
502,
);
}
const ttl = Math.max((json.expires_in ?? 7200) - 300, 60);
this.tokenCache = { value: json.access_token, expiresAt: now + ttl * 1000 };
return json.access_token;
}
/** 拼出带 access_token 与 pay_sig 的接口地址。 */
private async signedUrl(uri: string, postBody: string): Promise<string> {
const token = await this.accessToken();
return `${XPAY_BASE}${uri}?access_token=${encodeURIComponent(token)}&pay_sig=${this.calcPaySig(uri, postBody)}`;
}
/**
* 查单。用于发货推送丢失时兜底补发。
* 参数名是 order_id(传自己的 outTradeNo),不是 out_trade_no。
@@ -58,9 +114,8 @@ export class WxPayService {
async queryOrder(outTradeNo: string, openid: string): Promise<XpayQueryResult> {
const uri = '/xpay/query_order';
const body = JSON.stringify({ openid, env: this.env, order_id: outTradeNo });
const paySig = this.calcPaySig(uri, body);
const res = await fetch(`${XPAY_BASE}${uri}?pay_sig=${paySig}`, {
const res = await fetch(await this.signedUrl(uri, body), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
@@ -82,28 +137,67 @@ export class WxPayService {
outTradeNo: string;
openid: string;
refundOrderId: string;
refundAmountFen: number;
reason: string;
refundFeeFen: number;
/** 0-5,微信只认这几个枚举值 */
reasonCode: string;
/** 商家自定义数据,必填,退款原因的文字记在这里,query_order 可原样查回 */
bizMeta: string;
}): Promise<Record<string, unknown>> {
if (!this.enabled) throw new AppError(4002, '虚拟支付未配置', 503);
// left_fee 是「当前单剩余可退金额」,接口必填,只能先查单拿到。
// 千万不要拿订单金额兜底——那等于编一个数发给微信,会被 268490016 打回来,
// 而且把「这单根本不能退」这个真实原因盖掉了。
const queried = await this.queryOrder(input.outTradeNo, input.openid);
const status = queried.order?.status;
const statusText = status === undefined ? '未知' : XPAY_STATUS[status] ?? String(status);
const leftFee = queried.order?.left_fee;
if (leftFee === undefined || leftFee === null) {
throw new AppError(
4002,
`微信侧订单状态是「${statusText}」,没有可退金额,无法退款`,
409,
);
}
if (leftFee <= 0) {
throw new AppError(4002, `该订单可退金额为 0(微信侧状态:${statusText}),无法退款`, 409);
}
if (input.refundFeeFen > leftFee) {
throw new AppError(
4002,
`退款金额 ${input.refundFeeFen} 分超过可退金额 ${leftFee} 分,无法退款`,
409,
);
}
const uri = '/xpay/refund_order';
// 字段名和取值都必须严格照文档,少一个必填字段整笔退款就被拒
const body = JSON.stringify({
openid: input.openid,
env: this.env,
order_id: input.outTradeNo,
refund_order_id: input.refundOrderId,
refund_amount: input.refundAmountFen,
refund_reason: input.reason,
left_fee: leftFee,
refund_fee: input.refundFeeFen,
biz_meta: input.bizMeta.slice(0, 1024),
refund_reason: input.reasonCode,
/** 1-人工客服退款:后台管理员发起的都属于这一类 */
req_from: '1',
env: this.env,
});
const paySig = this.calcPaySig(uri, body);
const res = await fetch(`${XPAY_BASE}${uri}?pay_sig=${paySig}`, {
const res = await fetch(await this.signedUrl(uri, body), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
});
return (await res.json()) as Record<string, unknown>;
const json = (await res.json()) as { errcode?: number; errmsg?: string };
// 以前不看返回码,微信拒了也照样告诉后台「已提交」,退款就这么静默消失了
if (json.errcode && json.errcode !== 0) {
throw new AppError(4002, `微信退款接口拒绝:${json.errcode} ${json.errmsg ?? ''}`.trim(), 502);
}
return json as Record<string, unknown>;
}
}
@@ -115,6 +209,8 @@ export interface XpayQueryResult {
wx_order_id?: string;
status?: number;
paid_time?: number;
/** 当前单剩余可退金额,单位分 */
left_fee?: number;
};
status?: number;
wx_order_id?: string;