feat(order): 管理端支持微信小程序发货信息同步、人工补报与审计日志

This commit is contained in:
pikachu1995@126.com
2026-11-11 22:41:10 +08:00
parent 402c9c9ebb
commit 60de941749
7 changed files with 297 additions and 7 deletions

View File

@@ -78,6 +78,15 @@ export const getOrderList = (params) => {
return getRequest(`/order/order`, params)
}
// 小程序专用接口由服务端固定来源与微信支付范围,并回填本地上报结果。
export const getWechatOrderList = (params) => getRequest('/order/order/wechat', params)
// 状态统计与专用列表共用上报状态筛选。
export const getWechatOrderNum = (params) => getRequest('/order/order/wechat/orderNum', params)
// 人工补报复用支付流水锁和真实履约校验,不受自动上报开关限制。
export const retryWechatOrder = (sn) => postRequest(`/order/order/wechat/${encodeURIComponent(sn)}/retry`, {}, undefined, 120000)
// 查询同笔微信支付下的操作日志,不返回原始支付和物流凭据。
export const getWechatOrderLogs = (sn, params) => getRequest(`/order/order/wechat/${encodeURIComponent(sn)}/logs`, params)
// 导出订单列表
export const exportOrder = (params) => {
return getRequest(`/order/order/queryExportOrder`, params,'blob')

View File

@@ -182,10 +182,12 @@ export const postBlobRequest = (url, data) => {
});
};
export const postRequest = (url, params, headers) => {
// 个别需等待外部平台的操作可传超时毫秒数,现有调用保持 8 秒默认值。
export const postRequest = (url, params, headers, timeout = 8000) => {
let accessToken = getStore("accessToken");
return service({
method: "post",
timeout,
url: `${url}`,
data: params,
transformRequest: headers

View File

@@ -0,0 +1,23 @@
/**
* 小程序订单上报状态及日志显示口径,成功仅表示发货接口受理。
* @author Bulbasaur
* @since 2026-11-09
*/
export const wechatSyncOptions = [
{ value: "UNCHECKED", label: "待核查", type: "info" },
{ value: "PENDING", label: "待上报", type: "warning" },
{ value: "PROCESSING", label: "处理中", type: "warning" },
{ value: "SUCCESS", label: "上报成功", type: "success" },
{ value: "FAILED", label: "上报失败", type: "danger" },
];
/** 查找页面和日志的统一状态标签,重复请求跳过使用独立说明。 */
export function wechatSyncLabel(value) {
if (value === "SKIPPED") return "已跳过重复";
return wechatSyncOptions.find((item) => item.value === value)?.label || value || "—";
}
/** 返回 Element Plus 标签类型,未知状态使用中性色。 */
export function wechatSyncTag(value) {
return wechatSyncOptions.find((item) => item.value === value)?.type || "info";
}

View File

@@ -52,6 +52,7 @@
@change="selectDateRange"
/>
</el-form-item>
<slot name="extra-filters" :form="searchForm" />
<el-form-item class="order-search-action">
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
@@ -74,6 +75,9 @@
<el-button type="warning" class="export" :loading="exporting" @click="exportOrder">导出订单</el-button>
</div>
<el-alert v-if="loadError" title="订单加载失败,请重试" type="error" :closable="false" show-icon>
<el-button link type="primary" @click="init">重新加载</el-button>
</el-alert>
<el-table v-loading="loading" :data="data" ref="tableRef" class="mt_10" style="width: 100%">
<el-table-column prop="sn" label="订单号" min-width="200" show-overflow-tooltip />
<el-table-column label="商品" min-width="280">
@@ -126,9 +130,11 @@
</template>
</el-table-column>
<el-table-column prop="createTime" label="下单时间" width="170" />
<el-table-column label="操作" width="80" align="center" fixed="right">
<slot name="extra-columns" />
<el-table-column label="操作" :width="actionWidth" align="center" fixed="right">
<template #default="{ row }">
<a class="link-text" @click="detail(row)">查看</a>
<slot name="extra-actions" :row="row" />
</template>
</el-table-column>
</el-table>
@@ -162,6 +168,8 @@ const props = defineProps({
fixedClientType: { type: String, default: "" },
fixedPaymentMethod: { type: String, default: "" },
allowExport: { type: Boolean, default: true },
wechatSync: { type: Boolean, default: false },
actionWidth: { type: Number, default: 80 },
});
const PROMOTION_OPTIONS = [
@@ -204,6 +212,7 @@ function emptySearchForm(pageSize = 20) {
orderStatus: "",
paymentMethod: props.fixedPaymentMethod,
orderPromotionType: "",
...(props.wechatSync ? { wechatSyncStatus: "" } : {}),
};
}
@@ -216,6 +225,9 @@ const searchFormRef = ref(null);
const tableRef = ref(null);
const loading = ref(true);
const loadError = ref(false);
let listRequestSeq = 0;
let countRequestSeq = 0;
const exporting = ref(false);
const pageSizes = ref(PAGE_SIZES);
const promotionOptions = ref(PROMOTION_OPTIONS);
@@ -389,16 +401,28 @@ function listParams() {
return params;
}
function getDataList() {
const requestId = ++listRequestSeq;
loading.value = true;
API_Order.getOrderList(listParams())
loadError.value = false;
const fetchList = props.wechatSync ? API_Order.getWechatOrderList : API_Order.getOrderList;
fetchList(listParams())
.then((res) => {
if (requestId !== listRequestSeq) return;
if (res.success) {
data.value = res.result.records;
total.value = res.result.total;
} else {
throw new Error("订单查询失败");
}
})
.catch(() => {
if (requestId !== listRequestSeq) return;
data.value = [];
total.value = 0;
loadError.value = true;
})
.finally(() => {
loading.value = false;
if (requestId === listRequestSeq) loading.value = false;
});
}
function detail(v) {
@@ -454,14 +478,19 @@ function orderStatusClick(name) {
getDataList();
}
function getOrderNumData() {
API_Order.getOrderNum(countParams())
const requestId = ++countRequestSeq;
const fetchCount = props.wechatSync ? API_Order.getWechatOrderNum : API_Order.getOrderNum;
fetchCount(countParams())
.then((res) => {
if (requestId !== countRequestSeq) return;
if (res.success) {
orderNumData.value = res.result;
} else {
orderNumData.value = {};
}
})
.catch(() => {
orderNumData.value = {};
if (requestId === countRequestSeq) orderNumData.value = {};
});
}
@@ -476,6 +505,9 @@ watch(
}
);
// 补报完成后刷新当前筛选和页码,不清空运营人员已选条件。
defineExpose({ reload: init });
onMounted(() => {
try {
sessionStorage.removeItem("manager-order-list-query");

View File

@@ -0,0 +1,88 @@
<!--
小程序订单同步日志抽屉:按支付流水展示审计记录,不显示原始身份或支付凭据。
@author Bulbasaur
@since 2026-11-09
-->
<template>
<el-drawer :model-value="modelValue" title="微信订单同步日志" size="min(1100px, 95vw)" @update:model-value="emit('update:modelValue', $event)">
<p>订单:{{ orderSn }}。日志包含同笔微信支付下的关联子订单操作;上报成功仅代表微信接口已受理。</p>
<el-button :loading="loading" @click="load">刷新</el-button>
<el-alert v-if="failed" title="日志加载失败,请点击刷新重试" type="error" :closable="false" class="mt_10" />
<el-table v-loading="loading" :data="records" class="mt_10" empty-text="暂无本地同步记录,历史上报结果需核查">
<el-table-column type="expand">
<template #default="{ row }">
<div class="log-detail">
<p>触发订单:{{ row.orderSn }}</p>
<p>请求摘要:{{ row.summary || '—' }}</p>
<p>错误码:{{ row.errorCode || '—' }}</p>
<p>处理原因:{{ row.errorMessage || '—' }}</p>
<p>结束时间:{{ row.finishTime || '—' }};耗时:{{ row.durationMs == null ? '—' : `${row.durationMs} 毫秒` }}</p>
</div>
</template>
</el-table-column>
<el-table-column prop="createTime" label="操作时间" width="175" />
<el-table-column label="操作" width="110">
<template #default="{ row }">{{ row.operation === 'RECEIVE' ? '收货通知' : '发货上报' }}</template>
</el-table-column>
<el-table-column label="来源" width="110">
<template #default="{ row }">{{ row.triggerType === 'MANUAL' ? '人工补报' : '自动事件' }}</template>
</el-table-column>
<el-table-column prop="operatorName" label="操作人" min-width="100" />
<el-table-column label="结果" min-width="120">
<template #default="{ row }"><el-tag :type="wechatSyncTag(row.status)">{{ wechatSyncLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="errorMessage" label="处理原因" min-width="220" show-overflow-tooltip />
</el-table>
<el-pagination v-model:current-page="pageNumber" :page-size="20" :total="total" layout="total, prev, pager, next" class="mt_10" @current-change="load" />
</el-drawer>
</template>
<script setup>
import { ref, watch } from "vue";
import { getWechatOrderLogs } from "@/api/order";
import { wechatSyncLabel, wechatSyncTag } from "@/utils/wechatOrderSync";
const props = defineProps({ modelValue: Boolean, orderSn: { type: String, default: "" } });
const emit = defineEmits(["update:modelValue"]);
const records = ref([]);
const loading = ref(false);
const failed = ref(false);
const total = ref(0);
const pageNumber = ref(1);
let requestSeq = 0;
/** 请求序号隔离不同订单及页码,关闭抽屉后不接受过期响应。 */
async function load() {
if (!props.modelValue || !props.orderSn) return;
const id = ++requestSeq;
loading.value = true;
failed.value = false;
try {
const res = await getWechatOrderLogs(props.orderSn, { pageNumber: pageNumber.value, pageSize: 20 });
if (id !== requestSeq) return;
if (!res.success) throw new Error("日志加载失败");
records.value = res.result.records;
total.value = res.result.total;
} catch (e) {
if (id !== requestSeq) return;
failed.value = true;
records.value = [];
total.value = 0;
} finally {
if (id === requestSeq) loading.value = false;
}
}
watch(() => [props.modelValue, props.orderSn], () => {
requestSeq++;
records.value = [];
total.value = 0;
pageNumber.value = 1;
loading.value = false;
if (props.modelValue) load();
}, { immediate: true });
</script>
<style scoped>
.log-detail { padding: 0 20px; overflow-wrap: anywhere; }
</style>

View File

@@ -8,14 +8,76 @@
class="wechat-order-notice"
description="仅展示微信小程序下单且使用微信支付的订单。自动上报开关开启且微信侧已开通发货管理时,按现有履约流程上报。"
/>
<OrderList fixed-client-type="WECHAT_MP" fixed-payment-method="WECHAT" :allow-export="false" />
<OrderList ref="orderListRef" fixed-client-type="WECHAT_MP" fixed-payment-method="WECHAT" :allow-export="false" wechat-sync :action-width="220">
<template #extra-filters="{ form }">
<el-form-item label="上报状态" prop="wechatSyncStatus">
<el-select v-model="form.wechatSyncStatus" placeholder="全部上报状态" clearable>
<el-option v-for="item in wechatSyncOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</template>
<template #extra-columns>
<el-table-column label="微信上报状态" width="130">
<template #default="{ row }"><el-tag :type="wechatSyncTag(row.wechatSyncStatus)">{{ wechatSyncLabel(row.wechatSyncStatus) }}</el-tag></template>
</el-table-column>
<el-table-column prop="wechatSyncTime" label="最近上报时间" width="175" />
<el-table-column prop="wechatSyncError" label="上报处理原因" min-width="230" show-overflow-tooltip />
</template>
<template #extra-actions="{ row }">
<el-button link type="primary" @click="showLogs(row)">同步日志</el-button>
<el-button link type="warning" :loading="retryingSn === row.sn" :disabled="!!retryingSn" @click="retry(row)">补报</el-button>
</template>
</OrderList>
<SyncLogDrawer v-model="logVisible" :order-sn="logOrderSn" />
</div>
</template>
<script setup>
import OrderList from "../order/orderList.vue";
import SyncLogDrawer from "./SyncLogDrawer.vue";
import { ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { retryWechatOrder } from "@/api/order";
import { wechatSyncOptions, wechatSyncLabel, wechatSyncTag } from "@/utils/wechatOrderSync";
defineOptions({ name: "wechatOrderList" });
const orderListRef = ref(null);
const retryingSn = ref("");
const logVisible = ref(false);
const logOrderSn = ref("");
/** 展示当前订单所在微信支付流水的审计日志。 */
function showLogs(row) {
logOrderSn.value = row.sn;
logVisible.value = true;
}
/** 人工补报需要明确确认范围;同笔支付共用锁,已成功且内容未变时由后端跳过。 */
async function retry(row) {
if (retryingSn.value) return;
try {
const historyNotice = row.wechatSyncStatus === "UNCHECKED" ? "该订单没有本地上报记录,请先核查微信后台,确认需要补报后继续。" : "";
await ElMessageBox.confirm(historyNotice + "将按该笔微信支付的全部关联订单归集补报。人工补报不受自动上报开关限制;已成功且内容未变化会跳过。", "补报微信发货信息", { type: "warning", confirmButtonText: "确认补报", cancelButtonText: "取消" });
} catch (e) { return; }
if (retryingSn.value) return;
retryingSn.value = row.sn;
try {
const res = await retryWechatOrder(row.sn);
if (!res.success) throw new Error(res.message || "补报失败");
if (res.result === "SUCCESS") ElMessage.success("微信接口已受理发货信息");
else if (res.result === "SKIPPED") ElMessage.success("相同发货信息已成功上报,本次已跳过");
else {
ElMessage.warning("本次未完成上报,请查看同步日志中的处理原因");
showLogs(row);
}
} catch (e) {
ElMessage.error("补报未完成,请刷新状态并查看日志后再处理");
} finally {
retryingSn.value = "";
orderListRef.value?.reload();
}
}
</script>
<style scoped>

View File

@@ -0,0 +1,74 @@
/**
* 小程序订单筛选及异步竞态回归,隔离网络验证旧响应不覆盖新页面。
* @author Bulbasaur
* @since 2026-11-09
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { parse } = require('@vue/compiler-sfc');
const root = path.resolve(__dirname, '..');
const tick = () => new Promise(resolve => setImmediate(resolve));
/** 从实际 Vue 脚本加载逻辑,替换网络及 Vue 生命周期,不启动业务服务。 */
function script(file) {
return parse(fs.readFileSync(path.join(root,file),'utf8')).descriptor.scriptSetup.content.replace(/^import .*;\r?$/gm,'');
}
/** 为并发请求提供可控完成顺序。 */
function deferred() {
let resolve, reject;
const promise = new Promise((yes,no) => {resolve=yes; reject=no;});
return {promise,resolve,reject};
}
/** 保留表单值及计算属性的最小运行环境。 */
function primitives() {
return { ref:value=>({value}), computed:fn=>({get value(){return fn();}}), defineOptions:()=>{}, defineExpose:()=>{}, onMounted:()=>{} };
}
test('专用列表固定支付范围、重置筛选,且旧列表和数量响应不能覆盖新结果', async()=>{
const requests=[];
const send=kind=>params=>{const wait=deferred();requests.push({kind,params:{...params},...wait});return wait.promise;};
const context={...primitives(),defineProps:()=>({fixedClientType:'WECHAT_MP',fixedPaymentMethod:'WECHAT',wechatSync:true,allowExport:false}),useRoute:()=>({query:{clientType:'PC',paymentMethod:'WALLET'}}),watch:()=>{},wechatSyncOptions:[],API_Order:{getWechatOrderList:send('list'),getWechatOrderNum:send('count')}};
vm.createContext(context);
vm.runInContext(script('src/views/order/order/orderList.vue')+';globalThis.t={init,handleSearch,handleReset,changePage,orderStatusClick,searchForm,data,orderNumData,loading,loadError};',context);
const t=context.t;
t.init();
t.searchForm.value.paymentMethod='WALLET';t.searchForm.value.wechatSyncStatus='FAILED';t.handleSearch();
const newest=requests.filter(r=>r.kind==='list').at(-1);
newest.resolve({success:true,result:{records:[{sn:'new'}],total:1}});
requests.filter(r=>r.kind==='count').at(-1).resolve({success:true,result:{waitPayNum:5}});
await tick();
requests[0].resolve({success:true,result:{records:[{sn:'old'}],total:99}});
requests[1].resolve({success:true,result:{waitPayNum:99}});
await tick();
assert.equal(t.data.value[0].sn,'new');assert.equal(t.orderNumData.value.waitPayNum,5);
t.changePage(2);t.orderStatusClick('DELIVERED');t.handleReset();
for(const r of requests){assert.equal(r.params.clientType,'WECHAT_MP');assert.equal(r.params.paymentMethod,'WECHAT');}
assert.equal(t.searchForm.value.wechatSyncStatus,'');assert.equal(t.searchForm.value.paymentMethod,'WECHAT');
requests.filter(r=>r.kind==='list').at(-1).reject(new Error('network'));
await tick();assert.equal(t.loadError.value,true);assert.equal(t.data.value.length,0);assert.equal(t.loading.value,false);
});
test('普通订单仍使用原接口且允许其他支付方式,不强制附加上报条件', async()=>{
let sent;
const context={...primitives(),defineProps:()=>({fixedClientType:'',fixedPaymentMethod:'',wechatSync:false,allowExport:true}),useRoute:()=>({query:{}}),watch:()=>{},wechatSyncOptions:[],API_Order:{getOrderList:params=>{sent={...params};return Promise.resolve({success:true,result:{records:[],total:0}});},getOrderNum:()=>Promise.resolve({success:true,result:{}})}};
vm.createContext(context);vm.runInContext(script('src/views/order/order/orderList.vue')+';globalThis.t={init,searchForm};',context);
context.t.searchForm.value.paymentMethod='WALLET';context.t.init();await tick();
assert.equal(sent.paymentMethod,'WALLET');assert.equal(sent.clientType,undefined);assert.equal(sent.wechatSyncStatus,undefined);
});
test('日志抽屉切换订单及关闭后,旧请求不会回填另一笔订单', async()=>{
const props={modelValue:true,orderSn:'A'},requests=[];let changed;
const context={...primitives(),defineProps:()=>props,defineEmits:()=>()=>{},watch:(source,callback)=>{changed=callback;callback();},getWechatOrderLogs:(sn,params)=>{const wait=deferred();requests.push({sn,...wait});return wait.promise;}};
vm.createContext(context);vm.runInContext(script('src/views/order/wechat/SyncLogDrawer.vue')+';globalThis.t={records,loading,failed,load};',context);
props.orderSn='B';changed();
requests[1].resolve({success:true,result:{records:[{orderSn:'B'}],total:1}});await tick();
requests[0].resolve({success:true,result:{records:[{orderSn:'A'}],total:1}});await tick();
assert.equal(context.t.records.value[0].orderSn,'B');
context.t.load();props.modelValue=false;changed();requests[2].resolve({success:true,result:{records:[{orderSn:'B'}],total:1}});await tick();
assert.equal(context.t.records.value.length,0);assert.equal(context.t.loading.value,false);
});