支付通路
This commit is contained in:
1 parent
c7f43e30a9
commit
85b0e70907
26 files changed
+1324
-74
No files matched your search
+109
-14
@@ -1,14 +1,44 @@
|
||||
// pages/buy/buy.js
|
||||
//
|
||||
// 商业化页面:所有购买入口都落到这里。
|
||||
// 职责是「确认订单 → 拉起微信支付 → 确认发货」,课程详情不在这里展示。
|
||||
// UI 待设计,wxml 现在只有一个占位块,支付链路已经就位。
|
||||
// 职责是「确认订单 → 拉起微信支付 → 确认发货」。
|
||||
//
|
||||
// 两种进入方式:
|
||||
// - 带 ?code=xxx(从课程详情页进来):只显示这一门
|
||||
// - 不带参数:列出全部课程,一门一个购买按钮
|
||||
const api = require('../../utils/api');
|
||||
const format = require('../../utils/format');
|
||||
const store = require('../../utils/store');
|
||||
|
||||
const POLL_INTERVAL = 1500;
|
||||
const POLL_MAX = 8;
|
||||
// 微信的发货推送实测要 13~17 秒才到,原来只等 12 秒,用户多半看到「可能稍有延迟」而不是「购买成功」
|
||||
const POLL_MAX = 20;
|
||||
|
||||
/** iPhone 上跑 Apple 支付要求微信客户端 ≥ 这个版本 */
|
||||
const IOS_MIN_WECHAT = '8.0.68';
|
||||
|
||||
/** 当前微信客户端版本号,取不到就返回空串。 */
|
||||
function clientVersion() {
|
||||
try {
|
||||
const info = typeof wx.getAppBaseInfo === 'function' ? wx.getAppBaseInfo() : wx.getSystemInfoSync();
|
||||
return (info && info.version) || '';
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 版本号比大小:'8.0.68' vs '8.0.5' → 正数表示前者新。 */
|
||||
function compareVersion(a, b) {
|
||||
const left = String(a).split('.').map(Number);
|
||||
const right = String(b).split('.').map(Number);
|
||||
const len = Math.max(left.length, right.length);
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
const l = left[i] || 0;
|
||||
const r = right[i] || 0;
|
||||
if (l !== r) return l - r;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -19,17 +49,17 @@ Page({
|
||||
title: '',
|
||||
coverUrl: '',
|
||||
priceYuan: '',
|
||||
list: [],
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
const code = options && options.code;
|
||||
// 没带课程号说明是误入(比如直接输路径打开),不该停在这个页面
|
||||
if (!code) {
|
||||
this.rejectOut('缺少课程参数');
|
||||
if (code) {
|
||||
this.setData({ code: code });
|
||||
this.load(code);
|
||||
return;
|
||||
}
|
||||
this.setData({ code: code });
|
||||
this.load(code);
|
||||
this.loadList();
|
||||
},
|
||||
|
||||
load(code) {
|
||||
@@ -53,6 +83,27 @@ Page({
|
||||
});
|
||||
},
|
||||
|
||||
/** 不带课程号进来:列出全部课程,让用户自己挑。 */
|
||||
loadList() {
|
||||
api
|
||||
.getCourses()
|
||||
.then((courses) => {
|
||||
this.setData({
|
||||
loading: false,
|
||||
list: (courses || []).map((c) => ({
|
||||
code: c.code,
|
||||
title: c.title,
|
||||
priceYuan: format.fenToYuan(c.priceFen),
|
||||
owned: c.owned,
|
||||
})),
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
this.setData({ loading: false });
|
||||
wx.showToast({ title: (err && err.message) || '课程加载失败', icon: 'none' });
|
||||
});
|
||||
},
|
||||
|
||||
/** 进错页面(没带课程、课程不存在、已经买过):提示一句然后退出去。 */
|
||||
rejectOut(message) {
|
||||
wx.showToast({ title: message, icon: 'none' });
|
||||
@@ -68,9 +119,14 @@ Page({
|
||||
wx.switchTab({ url: '/pages/kecheng/kecheng' });
|
||||
},
|
||||
|
||||
/** 购买:下单 → 拉起支付 → 轮询订单状态确认发货。 */
|
||||
buyNow() {
|
||||
if (this.data.buying || !this.data.code) return;
|
||||
/**
|
||||
* 购买:下单 → 拉起支付 → 轮询订单状态确认发货。
|
||||
* 课程号优先取按钮上的 data-code(列表模式),没有就用页面参数(单课程模式)。
|
||||
*/
|
||||
buyNow(e) {
|
||||
const code =
|
||||
(e && e.currentTarget && e.currentTarget.dataset && e.currentTarget.dataset.code) || this.data.code;
|
||||
if (this.data.buying || !code) return;
|
||||
|
||||
// 购买的前置条件:用户主动使用过登录功能。静默登录不算。
|
||||
if (!store.isLoggedIn()) {
|
||||
@@ -92,11 +148,14 @@ Page({
|
||||
return;
|
||||
}
|
||||
|
||||
// iOS 版本预检放在下单之前:版本不够时不该先建出一笔待支付订单
|
||||
if (!this.checkIosVersion()) return;
|
||||
|
||||
this.setData({ buying: true });
|
||||
wx.showLoading({ title: '发起支付', mask: true });
|
||||
|
||||
api
|
||||
.createOrder(this.data.code)
|
||||
.createOrder(code)
|
||||
.then((order) => {
|
||||
wx.hideLoading();
|
||||
return api.requestVirtualPayment(order.payData).then(() => order.outTradeNo);
|
||||
@@ -107,10 +166,41 @@ Page({
|
||||
this.setData({ buying: false });
|
||||
// 用户主动取消不算错误
|
||||
if (err && err.errMsg && err.errMsg.indexOf('cancel') >= 0) return;
|
||||
wx.showToast({ title: (err && err.message) || '支付失败', icon: 'none' });
|
||||
// 拉起支付失败时微信给的是 errMsg/errno,服务端报错给的是 message。
|
||||
// 原来只弹「支付失败」,把排查线索全丢了,所以原样带出来。
|
||||
const detail = (err && (err.errMsg || err.message)) || '未知错误';
|
||||
const errno = err && (err.errno || err.errCode);
|
||||
wx.showModal({
|
||||
title: '支付失败',
|
||||
content: errno ? detail + '\n错误码:' + errno : detail,
|
||||
showCancel: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* iPhone 上 Apple 支付要求微信 ≥ 8.0.68,低了拉不起来。
|
||||
* 只在 iOS 上校验;安卓等没有这个限制,取不到版本号也不拦(交给微信自己报错)。
|
||||
*/
|
||||
checkIosVersion() {
|
||||
if (api.devicePlatform() !== 'ios') return true;
|
||||
const version = clientVersion();
|
||||
if (!version || compareVersion(version, IOS_MIN_WECHAT) >= 0) return true;
|
||||
|
||||
wx.showModal({
|
||||
title: '请先升级微信',
|
||||
content:
|
||||
'iPhone 上购买课程需要微信 ' +
|
||||
IOS_MIN_WECHAT +
|
||||
' 及以上版本,你当前是 ' +
|
||||
version +
|
||||
'。请到 App Store 更新微信后重试。',
|
||||
showCancel: false,
|
||||
confirmText: '知道了',
|
||||
});
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* 支付成功后权益由服务端发货推送发放,存在秒级延迟,这里轮询确认。
|
||||
* 即便轮询全部超时也不会丢单:服务端有定时查单兜底。
|
||||
@@ -123,7 +213,12 @@ Page({
|
||||
if (order.status === 2) {
|
||||
this.setData({ buying: false, owned: true });
|
||||
wx.showToast({ title: '购买成功', icon: 'success', mask: true });
|
||||
setTimeout(() => this.back(), 1500);
|
||||
// 单课程模式从详情页进来,买完退回去;列表模式留在原地刷新
|
||||
if (this.data.code) {
|
||||
setTimeout(() => this.back(), 1500);
|
||||
} else {
|
||||
this.loadList();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (attempts >= POLL_MAX || order.status === 3 || order.status === 4) {
|
||||
|
||||
@@ -1,7 +1,34 @@
|
||||
<!--pages/buy/buy.wxml-->
|
||||
|
||||
<!-- UI 待设计。支付链路(buyNow / waitForDelivery)已经在 buy.js 里就位,
|
||||
设计稿定了直接替换下面这个占位块即可。 -->
|
||||
<view class="buy-page">
|
||||
<text class="buy-placeholder">购买页(待设计)</text>
|
||||
<!-- 带 ?code= 进来就只买这一门,否则列出全部课程、一门一个购买按钮。
|
||||
注意别在 wx:if 和 wx:else 之间插注释。 -->
|
||||
<view class="buy-single" wx:if="{{code}}">
|
||||
<text class="buy-title">{{title}}</text>
|
||||
<text class="buy-price">¥{{priceYuan}}</text>
|
||||
<button
|
||||
class="buy-btn"
|
||||
disabled="{{buying || owned}}"
|
||||
bindtap="buyNow"
|
||||
>{{owned ? '已拥有' : (buying ? '支付中…' : '立即购买')}}</button>
|
||||
</view>
|
||||
|
||||
<view class="buy-list" wx:else>
|
||||
<view class="buy-item" wx:for="{{list}}" wx:key="code">
|
||||
<view class="buy-item-main">
|
||||
<text class="buy-item-title">{{item.title}}</text>
|
||||
<text class="buy-item-price">¥{{item.priceYuan}}</text>
|
||||
</view>
|
||||
<button
|
||||
class="buy-btn buy-btn-mini"
|
||||
size="mini"
|
||||
disabled="{{buying || item.owned}}"
|
||||
data-code="{{item.code}}"
|
||||
bindtap="buyNow"
|
||||
>{{item.owned ? '已拥有' : '购买'}}</button>
|
||||
</view>
|
||||
<view class="buy-empty" wx:if="{{!loading && list.length === 0}}">
|
||||
<text>暂无可购买的课程</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -1,14 +1,106 @@
|
||||
/* pages/buy/buy.wxss */
|
||||
|
||||
/* 占位样式,UI 定稿后连同 wxml 一起替换 */
|
||||
.buy-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 70vh;
|
||||
min-height: 100vh;
|
||||
background: #f6f7f9;
|
||||
padding: 24rpx 24rpx 48rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.buy-placeholder {
|
||||
color: #999;
|
||||
font-size: 28rpx;
|
||||
/* ── 单课程 ───────────────────────────────────────────── */
|
||||
.buy-single {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 48rpx 32rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.buy-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.buy-price {
|
||||
margin-top: 24rpx;
|
||||
font-size: 44rpx;
|
||||
color: #ff4d4f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── 课程列表 ─────────────────────────────────────────── */
|
||||
.buy-list {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.buy-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28rpx 24rpx;
|
||||
border-bottom: 1rpx solid #eee;
|
||||
}
|
||||
|
||||
.buy-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.buy-item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-right: 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.buy-item-title {
|
||||
font-size: 28rpx;
|
||||
color: #222;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.buy-item-price {
|
||||
margin-top: 8rpx;
|
||||
font-size: 26rpx;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.buy-empty {
|
||||
padding: 80rpx 0;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
/* ── 按钮 ─────────────────────────────────────────────── */
|
||||
.buy-btn {
|
||||
margin-top: 40rpx;
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
font-size: 30rpx;
|
||||
border-radius: 44rpx;
|
||||
line-height: 2.6;
|
||||
}
|
||||
|
||||
.buy-btn-mini {
|
||||
margin-top: 0;
|
||||
flex: none;
|
||||
font-size: 26rpx;
|
||||
line-height: 2.4;
|
||||
padding: 0 30rpx;
|
||||
}
|
||||
|
||||
.buy-btn[disabled] {
|
||||
background: #d8d8d8;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 去掉微信按钮自带的那圈边框 */
|
||||
.buy-btn::after {
|
||||
border: none;
|
||||
}
|
||||
+19
-2
@@ -20,6 +20,19 @@ function staticUrl(path) {
|
||||
return config.STATIC_BASE_URL.replace(/\/+$/, '') + '/' + String(path).replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 下单设备。平台按设备路由支付渠道(iOS 走 Apple 支付,只能用户自己找 App Store 退),
|
||||
* 服务端靠这个字段判断一笔单能不能主动退款,所以下单时要一起报上去。
|
||||
*/
|
||||
function platform() {
|
||||
try {
|
||||
if (typeof wx.getDeviceInfo === 'function') return wx.getDeviceInfo().platform || 'unknown';
|
||||
return wx.getSystemInfoSync().platform || 'unknown';
|
||||
} catch (err) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeError(body, statusCode) {
|
||||
if (body && typeof body.code === 'number') {
|
||||
return { code: body.code, message: body.message || '请求失败', requestId: body.requestId };
|
||||
@@ -196,7 +209,11 @@ const api = {
|
||||
|
||||
// ── 订单与支付 ──────────────────────────────────────────────────────
|
||||
createOrder: (courseCode) =>
|
||||
request({ url: '/v1/orders', method: 'POST', data: { courseCode: courseCode } }),
|
||||
request({
|
||||
url: '/v1/orders',
|
||||
method: 'POST',
|
||||
data: { courseCode: courseCode, terminal: platform() },
|
||||
}),
|
||||
getOrder: (outTradeNo) => request({ url: '/v1/orders/' + outTradeNo }),
|
||||
};
|
||||
|
||||
@@ -219,4 +236,4 @@ function requestVirtualPayment(payData) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = Object.assign(api, { requestVirtualPayment });
|
||||
module.exports = Object.assign(api, { requestVirtualPayment, devicePlatform: platform });
|
||||
@@ -14,6 +14,8 @@ const FIXED_NAV = [
|
||||
{ href: '/', label: '数据看板' },
|
||||
{ href: '/users', label: '用户' },
|
||||
{ href: '/orders', label: '订单' },
|
||||
{ href: '/refund-requests', label: '退款申请' },
|
||||
{ href: '/notify-logs', label: '通知日志' },
|
||||
{ href: '/settings', label: '系统配置' },
|
||||
];
|
||||
|
||||
@@ -263,4 +265,3 @@ function NavItem({ href, label, active }: { href: string; label: string; active:
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/datetime';
|
||||
import { Button, Card, Empty, Notice } from '@/components/ui';
|
||||
|
||||
interface NotifyLogRow {
|
||||
id: number;
|
||||
event: string | null;
|
||||
event_label: string;
|
||||
/** 0 收到但没处理 / 1 处理成功 / 2 处理失败 */
|
||||
processed: number;
|
||||
err_msg: string | null;
|
||||
order_no: string | null;
|
||||
raw_body: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const RESULT: Record<number, { text: string; className: string }> = {
|
||||
0: { text: '已忽略', className: 'text-gray-500' },
|
||||
1: { text: '成功', className: 'text-green-600' },
|
||||
2: { text: '失败', className: 'text-red-600' },
|
||||
};
|
||||
|
||||
export default function NotifyLogsPage() {
|
||||
const [items, setItems] = useState<NotifyLogRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback((nextPage: number) => {
|
||||
setError('');
|
||||
api
|
||||
.listNotifyLogs(nextPage, PAGE_SIZE)
|
||||
.then((res) => {
|
||||
setItems(res.items as unknown as NotifyLogRow[]);
|
||||
setTotal(res.total);
|
||||
setPage(res.page);
|
||||
})
|
||||
.catch((err: { message: string }) => setError(err.message));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load(1);
|
||||
}, [load]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold text-gray-900">通知日志</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
微信服务器发给我们的每一条推送都在这里:发货、退款问询、退款成功、投诉、风控等。
|
||||
用来确认「这条通知到底有没有来、我们是怎么处理的」。没收到推送不代表订单有问题——
|
||||
平台未必每笔都通知(比如 iOS 退款,苹果可能自己判定不发问询)。
|
||||
</p>
|
||||
<Notice message={error} />
|
||||
|
||||
<Card>
|
||||
<div className="mb-4 text-sm text-gray-500">共 {total} 条通知</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<Empty />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-left text-xs text-gray-500">
|
||||
<th className="px-2 py-2">时间</th>
|
||||
<th className="px-2 py-2">通知类型</th>
|
||||
<th className="px-2 py-2">处理结果</th>
|
||||
<th className="px-2 py-2">关联订单</th>
|
||||
<th className="px-2 py-2">说明 / 原始报文</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((r) => {
|
||||
const result = RESULT[r.processed] ?? { text: String(r.processed), className: '' };
|
||||
return (
|
||||
<tr key={r.id} className="border-b border-gray-100 align-top hover:bg-gray-50">
|
||||
<td className="px-2 py-2 text-gray-500">{formatDateTime(r.created_at)}</td>
|
||||
<td className="px-2 py-2">
|
||||
{r.event_label}
|
||||
<div className="font-mono text-xs text-gray-400">{r.event || '(无 Event 字段)'}</div>
|
||||
</td>
|
||||
<td className={`px-2 py-2 ${result.className}`}>{result.text}</td>
|
||||
<td className="px-2 py-2 font-mono text-xs">{r.order_no || '—'}</td>
|
||||
<td className="px-2 py-2">
|
||||
{r.err_msg && <div className="text-gray-600">{r.err_msg}</div>}
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer text-xs text-blue-600">查看原始报文</summary>
|
||||
<pre className="mt-1 max-w-xl overflow-x-auto whitespace-pre-wrap break-all rounded bg-gray-50 p-2 text-xs text-gray-600">
|
||||
{r.raw_body}
|
||||
</pre>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-gray-500">
|
||||
<span>
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" disabled={page <= 1} onClick={() => load(page - 1)}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="ghost" disabled={page >= totalPages} onClick={() => load(page + 1)}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { ORDER_STATUS_TEXT } from '@/lib/resources';
|
||||
import { formatDateTime } from '@/lib/datetime';
|
||||
import { Button, Card, Empty, Input, Notice, Select } from '@/components/ui';
|
||||
|
||||
interface OrderRow {
|
||||
@@ -11,6 +12,12 @@ interface OrderRow {
|
||||
wx_order_id: string | null;
|
||||
status: number;
|
||||
amount_fen: number;
|
||||
/** 下单设备:ios 走 Apple 支付,不能主动退款 */
|
||||
terminal: string | null;
|
||||
/** 管理员已同意退款的时间;非空表示 iOS 退款问询会放过 */
|
||||
refund_consent_at: string | null;
|
||||
/** 已向微信发起退款的时间;非空且未退款完成时显示「退款中」 */
|
||||
refund_requested_at: string | null;
|
||||
nickname: string | null;
|
||||
openid: string;
|
||||
course_title: string;
|
||||
@@ -22,6 +29,16 @@ interface OrderRow {
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/** 微信退款接口只认这几个枚举值,不能传自由文本 */
|
||||
const REFUND_REASONS = [
|
||||
{ value: 3, label: '3 用户主动退款' },
|
||||
{ value: 0, label: '0 暂无描述' },
|
||||
{ value: 1, label: '1 产品问题,影响使用或效果不佳' },
|
||||
{ value: 2, label: '2 售后问题,无法满足需求' },
|
||||
{ value: 4, label: '4 价格问题' },
|
||||
{ value: 5, label: '5 其他原因' },
|
||||
];
|
||||
|
||||
export default function OrdersPage() {
|
||||
const [items, setItems] = useState<OrderRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -30,7 +47,9 @@ export default function OrdersPage() {
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [refundTarget, setRefundTarget] = useState<OrderRow | null>(null);
|
||||
const [consentTarget, setConsentTarget] = useState<OrderRow | null>(null);
|
||||
const [reason, setReason] = useState('用户申请退款');
|
||||
const [reasonCode, setReasonCode] = useState('3');
|
||||
|
||||
const load = useCallback((nextPage: number, nextStatus: string) => {
|
||||
setError('');
|
||||
@@ -53,9 +72,9 @@ export default function OrdersPage() {
|
||||
setError('');
|
||||
setNotice('');
|
||||
api
|
||||
.refundOrder(refundTarget.id, reason)
|
||||
.refundOrder(refundTarget.id, reason, Number(reasonCode))
|
||||
.then(() => {
|
||||
setNotice('退款请求已提交。iOS 端需用户自行向 App Store 申请,开发者无法发起。');
|
||||
setNotice('退款请求已提交。退款完成后订单会自动变为「已退款」,并收回课程权益。');
|
||||
setRefundTarget(null);
|
||||
load(page, status);
|
||||
})
|
||||
@@ -64,6 +83,43 @@ export default function OrdersPage() {
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
/** iOS 订单只能「允许」——打标记,钱的事由苹果处理,我们发起不了退款。 */
|
||||
const isIos = (order: OrderRow) => order.terminal === 'ios';
|
||||
|
||||
/**
|
||||
* 列表里显示的状态。
|
||||
* 退款是异步的:微信受理后要等它推 xpay_refund_notify 才会变成「已退款」,
|
||||
* 中间这段必须显示「退款中」,否则看起来像没退成功,管理员会再点一次。
|
||||
*/
|
||||
const statusOf = (order: OrderRow) => {
|
||||
if (order.status === 3) return { text: '已退款', className: 'text-red-600' };
|
||||
if (order.refund_requested_at) return { text: '退款中', className: 'text-amber-600' };
|
||||
return {
|
||||
text: ORDER_STATUS_TEXT[order.status] ?? String(order.status),
|
||||
className: '',
|
||||
};
|
||||
};
|
||||
|
||||
/** 打 / 取消「允许退款」。只改标记,不发起任何退款动作。 */
|
||||
const submitConsent = () => {
|
||||
if (!consentTarget) return;
|
||||
const order = consentTarget;
|
||||
setError('');
|
||||
setNotice('');
|
||||
api
|
||||
.setRefundConsent(order.id, !order.refund_consent_at)
|
||||
.then(() => {
|
||||
setNotice(
|
||||
order.refund_consent_at
|
||||
? '已取消「允许退款」。用户再申请时我们会拒绝。'
|
||||
: '已允许退款。请让用户现在去 App Store 申请,我们会在问询环节放行。',
|
||||
);
|
||||
setConsentTarget(null);
|
||||
load(page, status);
|
||||
})
|
||||
.catch((err: { message: string }) => setError(err.message));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold text-gray-900">订单</h1>
|
||||
@@ -103,6 +159,7 @@ export default function OrdersPage() {
|
||||
<th className="px-2 py-2">用户</th>
|
||||
<th className="px-2 py-2">课程</th>
|
||||
<th className="px-2 py-2">金额</th>
|
||||
<th className="px-2 py-2">终端</th>
|
||||
<th className="px-2 py-2">状态</th>
|
||||
<th className="px-2 py-2">下单时间</th>
|
||||
<th className="px-2 py-2 text-right">操作</th>
|
||||
@@ -112,17 +169,28 @@ export default function OrdersPage() {
|
||||
{items.map((o) => (
|
||||
<tr key={o.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-2 py-2 font-mono text-xs">{o.out_trade_no}</td>
|
||||
<td className="px-2 py-2">{o.nickname || o.openid.slice(0, 8)}</td>
|
||||
<td className="px-2 py-2">
|
||||
<div>{o.nickname || <span className="text-gray-400">游客</span>}</div>
|
||||
<div className="font-mono text-xs text-gray-400">{o.openid}</div>
|
||||
</td>
|
||||
<td className="px-2 py-2">{o.course_title}</td>
|
||||
<td className="px-2 py-2">¥{(o.amount_fen / 100).toFixed(2)}</td>
|
||||
<td className="px-2 py-2">{ORDER_STATUS_TEXT[o.status] ?? o.status}</td>
|
||||
<td className="px-2 py-2 text-gray-500">{String(o.created_at).slice(0, 16).replace('T', ' ')}</td>
|
||||
<td className="px-2 py-2 text-gray-500">{o.terminal || '—'}</td>
|
||||
<td className={`px-2 py-2 ${statusOf(o).className}`}>{statusOf(o).text}</td>
|
||||
<td className="px-2 py-2 text-gray-500">{formatDateTime(o.created_at)}</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
{o.status === 2 && (
|
||||
<Button variant="danger" onClick={() => setRefundTarget(o)}>
|
||||
退款
|
||||
</Button>
|
||||
)}
|
||||
{o.status === 2 &&
|
||||
(o.refund_requested_at ? (
|
||||
<span className="text-xs text-amber-600">退款中…</span>
|
||||
) : isIos(o) ? (
|
||||
<Button variant="ghost" onClick={() => setConsentTarget(o)}>
|
||||
{o.refund_consent_at ? '取消允许退款' : '允许退款'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="danger" onClick={() => setRefundTarget(o)}>
|
||||
直接退款
|
||||
</Button>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -151,9 +219,17 @@ export default function OrdersPage() {
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-5 shadow-xl">
|
||||
<h3 className="mb-3 text-base font-semibold">发起退款</h3>
|
||||
<p className="mb-3 text-sm text-gray-600">
|
||||
订单 {refundTarget.out_trade_no},金额 ¥{(refundTarget.amount_fen / 100).toFixed(2)}
|
||||
订单 {refundTarget.out_trade_no},金额 ¥{(refundTarget.amount_fen / 100).toFixed(2)},
|
||||
{refundTarget.terminal || '未知终端'}
|
||||
</p>
|
||||
<Input value={reason} onChange={setReason} placeholder="退款原因" />
|
||||
<p className="mb-3 text-sm text-gray-600">
|
||||
这会<span className="font-semibold text-red-600">真的把钱退回去</span>
|
||||
,退款完成后订单变为「已退款」并收回课程权益。
|
||||
</p>
|
||||
<label className="mb-1 block text-xs text-gray-500">退款原因分类(微信要求选一类)</label>
|
||||
<Select value={reasonCode} onChange={setReasonCode} options={REFUND_REASONS} />
|
||||
<label className="mb-1 mt-3 block text-xs text-gray-500">备注(写给自己看,会随退款一起提交存档)</label>
|
||||
<Input value={reason} onChange={setReason} placeholder="例如:用户联系客服申请" />
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setRefundTarget(null)}>
|
||||
取消
|
||||
@@ -165,7 +241,33 @@ export default function OrdersPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{consentTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-5 shadow-xl">
|
||||
<h3 className="mb-3 text-base font-semibold">
|
||||
{consentTarget.refund_consent_at ? '取消允许退款' : '允许退款'}
|
||||
</h3>
|
||||
<p className="mb-3 text-sm text-gray-600">
|
||||
订单 {consentTarget.out_trade_no},金额 ¥{(consentTarget.amount_fen / 100).toFixed(2)},
|
||||
{consentTarget.terminal || '未知终端'}(Apple 支付)
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
{consentTarget.refund_consent_at
|
||||
? '取消后,用户再发起退款申请,我们会在苹果问询环节拒绝。'
|
||||
: '不发起退款、也不动钱,只打一个标记。请先和用户沟通好,再让他在 App Store 申请退款;那时我们会放行。顺序反了无效。'}
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setConsentTarget(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="danger" onClick={submitConsent}>
|
||||
{consentTarget.refund_consent_at ? '确认取消' : '确认允许'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { ORDER_STATUS_TEXT } from '@/lib/resources';
|
||||
import { formatDateTime } from '@/lib/datetime';
|
||||
import { Button, Card, Empty, Notice } from '@/components/ui';
|
||||
|
||||
interface RefundRequestRow {
|
||||
id: number;
|
||||
pay_order_id: string;
|
||||
out_trade_no: string | null;
|
||||
nickname: string | null;
|
||||
openid: string | null;
|
||||
course_title: string | null;
|
||||
amount_fen: number | null;
|
||||
terminal: string | null;
|
||||
order_status: number | null;
|
||||
reason: string | null;
|
||||
provide_status: string | null;
|
||||
our_result: number;
|
||||
our_evidence: string | null;
|
||||
query_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/** 我们答复时看到的发货状态 */
|
||||
const PROVIDE_STATUS_TEXT: Record<string, string> = {
|
||||
'0': '未发货',
|
||||
'1': '已发货',
|
||||
'2': '发货中',
|
||||
};
|
||||
|
||||
export default function RefundRequestsPage() {
|
||||
const [items, setItems] = useState<RefundRequestRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback((nextPage: number) => {
|
||||
setError('');
|
||||
api
|
||||
.listRefundRequests(nextPage, PAGE_SIZE)
|
||||
.then((res) => {
|
||||
setItems(res.items as unknown as RefundRequestRow[]);
|
||||
setTotal(res.total);
|
||||
setPage(res.page);
|
||||
})
|
||||
.catch((err: { message: string }) => setError(err.message));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load(1);
|
||||
}, [load]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold text-gray-900">退款申请</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Apple 用户去 App Store 申请退款后,平台会向我们发退款问询。问询要求 3 秒内答复,所以这里是「我们已经怎么答的」,
|
||||
不是待办。Apple 最终退了钱的话,对应订单会变成「已退款」,权益同时被收回。
|
||||
</p>
|
||||
<Notice message={error} />
|
||||
|
||||
<Card>
|
||||
<div className="mb-4 text-sm text-gray-500">共 {total} 条申请</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<Empty />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-left text-xs text-gray-500">
|
||||
<th className="px-2 py-2">申请时间</th>
|
||||
<th className="px-2 py-2">用户</th>
|
||||
<th className="px-2 py-2">订单</th>
|
||||
<th className="px-2 py-2">课程 / 金额</th>
|
||||
<th className="px-2 py-2">申请原因</th>
|
||||
<th className="px-2 py-2">我们的答复</th>
|
||||
<th className="px-2 py-2">最终结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((r) => (
|
||||
<tr key={r.id} className="border-b border-gray-100 align-top hover:bg-gray-50">
|
||||
<td className="px-2 py-2 text-gray-500">
|
||||
{formatDateTime(r.created_at)}
|
||||
{r.query_count > 1 && <div className="text-xs text-gray-400">问询 ×{r.query_count}</div>}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<div>{r.nickname || <span className="text-gray-400">游客</span>}</div>
|
||||
{r.openid && <div className="font-mono text-xs text-gray-400">{r.openid}</div>}
|
||||
</td>
|
||||
<td className="px-2 py-2 font-mono text-xs">
|
||||
{r.out_trade_no || <span className="text-gray-400">未匹配到订单</span>}
|
||||
<div className="text-gray-400">{r.pay_order_id}</div>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{r.course_title || '—'}
|
||||
{r.amount_fen !== null && (
|
||||
<div className="text-gray-500">¥{(r.amount_fen / 100).toFixed(2)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-gray-600">{r.reason || '—'}</td>
|
||||
<td className="px-2 py-2">
|
||||
<span className={r.our_result === 1 ? 'text-red-600' : 'text-green-600'}>
|
||||
{r.our_result === 1 ? '建议拒绝' : '建议退款'}
|
||||
</span>
|
||||
<div className="text-xs text-gray-400">
|
||||
平台发货状态:{PROVIDE_STATUS_TEXT[r.provide_status ?? ''] ?? '未知'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">{r.our_evidence}</div>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{r.order_status === null ? (
|
||||
<span className="text-gray-400">—</span>
|
||||
) : (
|
||||
<span className={r.order_status === 3 ? 'text-red-600' : 'text-gray-600'}>
|
||||
{ORDER_STATUS_TEXT[r.order_status] ?? r.order_status}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-gray-500">
|
||||
<span>
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" disabled={page <= 1} onClick={() => load(page - 1)}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="ghost" disabled={page >= totalPages} onClick={() => load(page + 1)}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button, Card, Empty, Input, Modal, Notice, Select } from '@/components/ui';
|
||||
import { formatDate } from '@/lib/datetime';
|
||||
|
||||
interface UserRow {
|
||||
id: number;
|
||||
@@ -157,6 +158,7 @@ export default function UsersPage() {
|
||||
<tr className="border-b border-gray-200 text-left text-xs text-gray-500">
|
||||
<th className="px-2 py-2">ID</th>
|
||||
<th className="px-2 py-2">昵称</th>
|
||||
<th className="px-2 py-2">openid</th>
|
||||
<th className="px-2 py-2">已购课程</th>
|
||||
<th className="px-2 py-2">状态</th>
|
||||
<th className="px-2 py-2">最后登录</th>
|
||||
@@ -168,12 +170,13 @@ export default function UsersPage() {
|
||||
<tr key={u.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-2 py-2 text-gray-400">{u.id}</td>
|
||||
<td className="px-2 py-2">{u.nickname || <span className="text-gray-400">游客</span>}</td>
|
||||
<td className="px-2 py-2 font-mono text-xs text-gray-500">{u.openid}</td>
|
||||
<td className="px-2 py-2">{u.entitlement_count}</td>
|
||||
<td className="px-2 py-2">
|
||||
{u.status === 1 ? <span className="text-green-600">正常</span> : <span className="text-red-600">封禁</span>}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-gray-500">
|
||||
{u.last_login_at ? String(u.last_login_at).slice(0, 10) : '—'}
|
||||
{formatDate(u.last_login_at)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<Button variant="ghost" onClick={() => openEdit(u)}>
|
||||
@@ -286,4 +289,3 @@ export default function UsersPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,8 +145,23 @@ export const api = {
|
||||
listOrders: (page: number, pageSize: number, status?: number) =>
|
||||
request<Paged<Record<string, unknown>>>(`/admin/v1/orders${qs({ page, pageSize, status })}`),
|
||||
|
||||
refundOrder: (id: number, reason: string) =>
|
||||
request<unknown>(`/admin/v1/orders/${id}/refund`, { method: 'POST', body: JSON.stringify({ reason }) }),
|
||||
listRefundRequests: (page: number, pageSize: number) =>
|
||||
request<Paged<Record<string, unknown>>>(`/admin/v1/refund-requests${qs({ page, pageSize })}`),
|
||||
|
||||
listNotifyLogs: (page: number, pageSize: number) =>
|
||||
request<Paged<Record<string, unknown>>>(`/admin/v1/notify-logs${qs({ page, pageSize })}`),
|
||||
|
||||
refundOrder: (id: number, reason: string, reasonCode: number) =>
|
||||
request<unknown>(`/admin/v1/orders/${id}/refund`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason, reasonCode: String(reasonCode) }),
|
||||
}),
|
||||
|
||||
setRefundConsent: (id: number, consent: boolean) =>
|
||||
request<unknown>(`/admin/v1/orders/${id}/refund-consent`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ consent }),
|
||||
}),
|
||||
|
||||
listEntitlements: (userId: number) => request<Array<Record<string, unknown>>>(`/admin/v1/entitlements${qs({ userId })}`),
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 后台统一按北京时间展示。
|
||||
*
|
||||
* 后端返回的是 ISO 字符串(UTC,带 Z),直接切片会显示成 UTC——
|
||||
* 中午 13:23 会显示成 05:23。所有时间展示都走这里。不要再用 slice。
|
||||
*/
|
||||
const TIME_ZONE = 'Asia/Shanghai';
|
||||
|
||||
function partsOf(value: unknown) {
|
||||
const date = new Date(String(value));
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
const parts = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: TIME_ZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(date);
|
||||
const pick = (type: string) => parts.find((p) => p.type === type)?.value ?? '';
|
||||
return { y: pick('year'), m: pick('month'), d: pick('day'), h: pick('hour'), mi: pick('minute') };
|
||||
}
|
||||
|
||||
/** ISO → 2026-09-30 13:23 */
|
||||
export function formatDateTime(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
const p = partsOf(value);
|
||||
return p ? `${p.y}-${p.m}-${p.d} ${p.h}:${p.mi}` : String(value).slice(0, 16).replace('T', ' ');
|
||||
}
|
||||
|
||||
/** ISO → 2026-09-30 */
|
||||
export function formatDate(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
const p = partsOf(value);
|
||||
return p ? `${p.y}-${p.m}-${p.d}` : String(value).slice(0, 10);
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"fileNames":["./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts",".Line truncated
|
||||
{"fileNames":["./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts",".Line truncated
|
||||
@@ -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;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- 已向微信发起退款的时间。退款是异步的,微信退完才推 xpay_refund_notify 回写「已退款」,
|
||||
-- 中间靠这一列显示「退款中」,避免管理员重复点退款。
|
||||
ALTER TABLE `orders` ADD COLUMN `refund_requested_at` DATETIME(3) NULL;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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[普通文本]]>');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user