feat(payment): rebuild order details and asset records
This commit is contained in:
@@ -23,15 +23,13 @@
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card title="统一资产账本" class="aksrt-section-card">
|
||||
<n-card title="用户近期资产记录" class="aksrt-section-card">
|
||||
<n-space vertical :size="12">
|
||||
<n-space wrap>
|
||||
<n-select v-model:value="ledgerFilters.asset" :options="assetOptions" clearable placeholder="资产" style="width: 130px" />
|
||||
<n-input v-model:value="ledgerFilters.transaction_id" placeholder="交易号" clearable style="width: 130px" />
|
||||
<n-input v-model:value="ledgerFilters.operation_key" placeholder="幂等键" clearable style="width: 240px" />
|
||||
<n-input v-model:value="ledgerFilters.ref_type" placeholder="引用类型" clearable style="width: 130px" />
|
||||
<n-input v-model:value="ledgerFilters.ref_key" placeholder="引用键" clearable style="width: 180px" />
|
||||
<n-input v-model:value="ledgerFilters.actor_user_id" placeholder="操作者 ID" clearable style="width: 130px" />
|
||||
<n-input v-model:value="ledgerFilters.user_id" placeholder="用户 ID" clearable style="width: 130px" />
|
||||
<n-select v-model:value="ledgerFilters.asset" :options="assetOptions" placeholder="资产" style="width: 130px" />
|
||||
<n-input v-model:value="ledgerFilters.date_from" type="date" placeholder="开始日期" style="width: 160px" />
|
||||
<n-input v-model:value="ledgerFilters.date_to" type="date" placeholder="结束日期" style="width: 160px" />
|
||||
<n-button type="primary" :loading="ledgerLoading" @click="searchLedger">查询</n-button>
|
||||
<n-button @click="resetLedger">重置</n-button>
|
||||
</n-space>
|
||||
@@ -40,13 +38,13 @@
|
||||
:data="ledgerRows"
|
||||
:loading="ledgerLoading"
|
||||
:row-key="row => row.id"
|
||||
:scroll-x="1600"
|
||||
:scroll-x="1100"
|
||||
size="small"
|
||||
/>
|
||||
<n-pagination
|
||||
v-model:page="ledgerPage"
|
||||
:page-count="ledgerPageCount"
|
||||
:page-size="20"
|
||||
:page-size="10"
|
||||
@update:page="loadLedger"
|
||||
/>
|
||||
</n-space>
|
||||
@@ -169,40 +167,41 @@ const ledgerRows = ref([])
|
||||
const ledgerPage = ref(1)
|
||||
const ledgerPageCount = ref(1)
|
||||
const ledgerFilters = reactive({
|
||||
asset: null,
|
||||
transaction_id: '',
|
||||
operation_key: '',
|
||||
ref_type: '',
|
||||
ref_key: '',
|
||||
actor_user_id: '',
|
||||
user_id: '',
|
||||
asset: 'points',
|
||||
date_from: '',
|
||||
date_to: '',
|
||||
})
|
||||
const assetOptions = [
|
||||
{ label: '积分', value: 'points' },
|
||||
{ label: '余额', value: 'balance' },
|
||||
]
|
||||
const ledgerColumns = [
|
||||
{ title: '交易号', key: 'transaction_label', width: 100, fixed: 'left' },
|
||||
{ title: '幂等键', key: 'operation_key', width: 260, ellipsis: { tooltip: true } },
|
||||
{ title: '资产', key: 'assets_label', width: 100 },
|
||||
{ title: '原因', key: 'reason', width: 150, ellipsis: { tooltip: true } },
|
||||
{ title: '业务引用', key: 'reference_label', width: 200, ellipsis: { tooltip: true } },
|
||||
{ title: '操作者', key: 'actor_label', width: 100 },
|
||||
{ title: '增减 / 手续费', key: 'delta_label', width: 180, ellipsis: { tooltip: true } },
|
||||
{ title: '变动后余额', key: 'balance_label', width: 150, ellipsis: { tooltip: true } },
|
||||
{ title: '备注', key: 'remark', width: 180, ellipsis: { tooltip: true } },
|
||||
{ title: 'UTC 时间', key: 'created_at', width: 190 },
|
||||
{ title: '类型', key: 'reason', width: 150 },
|
||||
{ title: '订单号', key: 'ref_key', width: 200 },
|
||||
{ title: '变动 / 变动后', key: 'delta_label', width: 190 },
|
||||
{ title: '说明', key: 'remark', width: 240, ellipsis: { tooltip: true } },
|
||||
{ title: '时间', key: 'created_at', width: 170 },
|
||||
]
|
||||
|
||||
function formatAssetValue(asset, value) {
|
||||
if (asset === 'balance') return typeof value === 'string' && /^[+-]?\d+\.\d{2}$/.test(value) ? value : '0.00'
|
||||
const raw = String(value ?? '0')
|
||||
return /^-?\d+$/.test(raw) ? raw : '0'
|
||||
const raw = Number(value ?? 0)
|
||||
if (!Number.isFinite(raw)) return asset === 'balance' ? '0.00' : '0'
|
||||
if (asset !== 'balance') return String(raw)
|
||||
const negative = raw < 0
|
||||
const digits = String(Math.abs(Math.trunc(raw))).padStart(3, '0')
|
||||
const whole = digits.slice(0, -2).replace(/^0+(?=\d)/, '')
|
||||
return (negative ? '-' : '') + whole + '.' + digits.slice(-2)
|
||||
}
|
||||
|
||||
async function loadLedger() {
|
||||
ledgerLoading.value = true
|
||||
try {
|
||||
const params = { page: ledgerPage.value, per_page: 20 }
|
||||
if (!/^\d+$/.test(String(ledgerFilters.user_id).trim())) {
|
||||
message.warning('请输入用户 ID 后查询该用户近期记录')
|
||||
return
|
||||
}
|
||||
const params = { page: ledgerPage.value, per_page: 10 }
|
||||
Object.entries(ledgerFilters).forEach(([key, value]) => {
|
||||
if (value !== null && String(value).trim() !== '') params[key] = value
|
||||
})
|
||||
@@ -210,23 +209,16 @@ async function loadLedger() {
|
||||
const data = response?.data || response || {}
|
||||
ledgerRows.value = (data.items || []).map((row) => {
|
||||
const entries = Array.isArray(row.entries) ? row.entries : []
|
||||
const entry = entries[0] || {}
|
||||
return {
|
||||
...row,
|
||||
transaction_label: `#${row.id}`,
|
||||
assets_label: (row.assets || []).join(' / '),
|
||||
reference_label: [row.ref_type, row.ref_key].filter(Boolean).join(':') || '—',
|
||||
actor_label: row.actor_user_id ? `#${row.actor_user_id}` : '系统',
|
||||
delta_label: entries.map((entry) => {
|
||||
const value = formatAssetValue(entry.asset, entry.delta)
|
||||
const signed = entry.asset === 'points' && !value.startsWith('-') && value !== '0' ? `+${value}` : value
|
||||
return `${entry.asset}:${signed}`
|
||||
}).join(';'),
|
||||
balance_label: entries.map((entry) => `${entry.asset}:${formatAssetValue(entry.asset, entry.balance_after)}`).join(';'),
|
||||
delta_label: `${formatAssetValue(entry.asset, entry.delta)} / ${formatAssetValue(entry.asset, entry.balance_after)}`,
|
||||
remark: row.remark || '—',
|
||||
}
|
||||
})
|
||||
ledgerPageCount.value = Math.max(1, Math.ceil(Number(data.total || 0) / Number(data.per_page || 20)))
|
||||
ledgerPageCount.value = Math.max(1, Math.ceil(Number(data.total || 0) / Number(data.per_page || 10)))
|
||||
} catch (e) {
|
||||
message.error(e.message || '加载账本失败')
|
||||
message.error(e.message || '加载资产记录失败')
|
||||
} finally {
|
||||
ledgerLoading.value = false
|
||||
}
|
||||
@@ -238,8 +230,10 @@ function searchLedger() {
|
||||
}
|
||||
|
||||
function resetLedger() {
|
||||
Object.assign(ledgerFilters, { asset: null, transaction_id: '', operation_key: '', ref_type: '', ref_key: '', actor_user_id: '' })
|
||||
searchLedger()
|
||||
Object.assign(ledgerFilters, { user_id: '', asset: 'points', date_from: '', date_to: '' })
|
||||
ledgerRows.value = []
|
||||
ledgerPage.value = 1
|
||||
ledgerPageCount.value = 1
|
||||
}
|
||||
|
||||
async function loadCfg() {
|
||||
@@ -270,6 +264,5 @@ async function saveCfg() {
|
||||
|
||||
onMounted(() => {
|
||||
loadCfg()
|
||||
loadLedger()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -25,7 +25,6 @@ export const knowledgeSchema = {
|
||||
payment_show_pay_time: true,
|
||||
payment_order_status_pending: '待支付',
|
||||
payment_order_status_paid: '已支付',
|
||||
payment_order_status_refunding: '退款中',
|
||||
payment_order_status_refunded: '已退款',
|
||||
payment_order_status_closed: '已关闭',
|
||||
pay_service: [],
|
||||
@@ -136,7 +135,6 @@ export const knowledgeSchema = {
|
||||
fields: [
|
||||
{ key: 'payment_order_status_pending', label: '待支付状态文案', type: 'input', default: '待支付' },
|
||||
{ key: 'payment_order_status_paid', label: '已支付状态文案', type: 'input', default: '已支付' },
|
||||
{ key: 'payment_order_status_refunding', label: '退款中状态文案', type: 'input', default: '退款中' },
|
||||
{ key: 'payment_order_status_refunded', label: '已退款状态文案', type: 'input', default: '已退款' },
|
||||
{ key: 'payment_order_status_closed', label: '已关闭状态文案', type: 'input', default: '已关闭' },
|
||||
],
|
||||
|
||||
@@ -74,7 +74,6 @@
|
||||
var map = {
|
||||
pending: { icon: "fa-clock", desc: "订单已生成,请在有效期内完成支付" },
|
||||
paid: { icon: "fa-check-circle", desc: "支付已完成,相关权益已发放至账户" },
|
||||
refunding: { icon: "fa-hourglass-half", desc: "退款申请已受理,正在处理中" },
|
||||
refunded: { icon: "fa-undo-alt", desc: "退款已完成,款项将原路退回" },
|
||||
closed: { icon: "fa-times-circle", desc: "订单已关闭,如有需要可重新下单" },
|
||||
};
|
||||
@@ -106,6 +105,26 @@
|
||||
return "<div><dt>" + escapeHtml(label) + "</dt><dd>" + cell + "</dd></div>";
|
||||
}
|
||||
|
||||
function gatewayResultText(value) {
|
||||
if (!value) return "";
|
||||
var result = value;
|
||||
if (typeof value === "string") {
|
||||
try { result = JSON.parse(value); } catch (_) { return String(value); }
|
||||
}
|
||||
if (!result || typeof result !== "object") return String(result);
|
||||
var labels = { succeeded: "退款成功", failed: "渠道明确拒绝", pending: "渠道处理中", unknown: "结果待确认" };
|
||||
var parts = [];
|
||||
if (result.status) parts.push(labels[String(result.status)] || String(result.status));
|
||||
if (result.message) parts.push(String(result.message));
|
||||
if (result.refund_id) parts.push("退款单号 " + String(result.refund_id));
|
||||
return parts.join(" · ") || "渠道暂未提供可读结果";
|
||||
}
|
||||
|
||||
function afterSaleAmount(value, order) {
|
||||
var amount = String(value == null ? "" : value);
|
||||
return order && order.is_points ? amount + " " + String(order.currency_symbol || "积分") : "¥" + amount;
|
||||
}
|
||||
|
||||
function renderDetail(state, order, session) {
|
||||
var allowedActions = order.allowed_actions || {};
|
||||
var reconciliation = order.reconciliation || {};
|
||||
@@ -145,15 +164,29 @@
|
||||
html += '<h4 class="aksrt-order-detail__group-title">交易信息</h4>';
|
||||
html += '<dl class="aksrt-order-detail__list">';
|
||||
[
|
||||
["流水号", order.order_number],
|
||||
["订单号", order.order_number],
|
||||
["支付方式", paymentMethodText(order)],
|
||||
["创建时间", order.created_at],
|
||||
["支付时间", order.paid_at],
|
||||
].forEach(function (row) {
|
||||
html += detailRow(row[0], row[1], row[0] === "流水号");
|
||||
html += detailRow(row[0], row[1], row[0] === "订单号");
|
||||
});
|
||||
html += "</dl></section>";
|
||||
|
||||
var afterSales = Array.isArray(order.after_sales) ? order.after_sales : [];
|
||||
if (afterSales.length) {
|
||||
html += '<section class="aksrt-order-detail__group"><h4 class="aksrt-order-detail__group-title">售后记录</h4><ul class="aksrt-order-detail__after-sales">';
|
||||
afterSales.forEach(function (item) {
|
||||
var labels = { pending: "待审核", processing: "处理中", approved: "已通过", rejected: "已拒绝", cancelled: "已取消", completed: "已完成" };
|
||||
html += "<li><strong>" + escapeHtml(labels[item.status] || item.status || "") + "</strong><span>" + escapeHtml(item.reason || "") + "</span><small>退款金额:" + escapeHtml(afterSaleAmount(item.refund_amount, order)) + "</small>";
|
||||
if (item.admin_note) html += "<small>处理备注:" + escapeHtml(item.admin_note) + "</small>";
|
||||
if (item.gateway_result) html += "<small>渠道结果:" + escapeHtml(gatewayResultText(item.gateway_result)) + "</small>";
|
||||
if (item.status === "pending") html += '<button class="btn-ghost" type="button" data-aksrt-after-sale-cancel="' + escapeHtml(order.order_number) + '">取消申请</button>';
|
||||
html += "</li>";
|
||||
});
|
||||
html += "</ul></section>";
|
||||
}
|
||||
|
||||
if (reconciliation.state === "refresh_available") {
|
||||
html += '<div class="aksrt-order-detail__refresh-state" role="status">';
|
||||
html += '<i class="fa fas fa-info-circle" aria-hidden="true"></i>';
|
||||
@@ -166,6 +199,7 @@
|
||||
html += '<button class="btn-outline" type="button" data-aksrt-modal-close>关闭</button>';
|
||||
if (allowedActions.can_cancel) html += '<button class="btn-ghost" type="button" data-aksrt-order-cancel="' + escapeHtml(order.order_number) + '">关闭订单</button>';
|
||||
if (allowedActions.can_pay) html += '<button class="btn-primary" type="button" data-aksrt-order-pay="' + escapeHtml(order.order_number) + '" data-aksrt-order-method="' + escapeHtml(paymentMethod.value || "") + '">继续支付</button>';
|
||||
if (allowedActions.can_after_sale) html += '<form class="aksrt-order-detail__after-sale-form" data-aksrt-after-sale-form="' + escapeHtml(order.order_number) + '"><label>申请售后原因<textarea name="reason" required maxlength="2000" rows="3"></textarea></label><button class="btn-ghost" type="submit">提交售后申请</button></form>';
|
||||
html += "</footer>";
|
||||
|
||||
session.content.innerHTML = html;
|
||||
@@ -244,6 +278,7 @@
|
||||
var html = '<button class="btn-outline" type="button" data-aksrt-order-detail="' + escapeHtml(order.order_number) + '">订单详情</button>';
|
||||
if (allowedActions.can_cancel) html += '<button class="btn-ghost" type="button" data-aksrt-order-cancel="' + escapeHtml(order.order_number) + '">关闭订单</button>';
|
||||
if (allowedActions.can_pay) html += '<button class="btn-primary" type="button" data-aksrt-order-pay="' + escapeHtml(order.order_number) + '" data-aksrt-order-method="' + escapeHtml((order.payment_method || {}).value || "") + '">继续支付</button>';
|
||||
if (allowedActions.can_after_sale) html += '<button class="btn-outline" type="button" data-aksrt-order-detail="' + escapeHtml(order.order_number) + '">申请售后</button>';
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -256,7 +291,7 @@
|
||||
: "";
|
||||
return '<article class="aksrt-orders-page__card" data-aksrt-order-card="' + escapeHtml(order.order_number) + '" data-order-status="' + escapeHtml(order.status_group || "") + '">' +
|
||||
'<div class="aksrt-orders-page__card-rail"><span>' + escapeHtml(order.product_type_label || "") + '</span><time>' + escapeHtml(order.created_at || "") + '</time><span class="aksrt-orders-page__card-status is-' + escapeHtml(order.status_group || "") + '">' + escapeHtml(order.status_label || "") + "</span></div>" +
|
||||
'<div class="aksrt-orders-page__card-main">' + image + '<div class="aksrt-orders-page__card-copy"><h3>' + escapeHtml(order.product_name || "订单") + "</h3><p>流水号 " + escapeHtml(order.order_number) + "</p></div><div class=\"aksrt-orders-page__card-value\"><strong>" + formatMoney(order) + "</strong><span>实付</span></div></div>" +
|
||||
'<div class="aksrt-orders-page__card-main">' + image + '<div class="aksrt-orders-page__card-copy"><h3>' + escapeHtml(order.product_name || "订单") + "</h3><p>订单号 " + escapeHtml(order.order_number) + "</p></div><div class=\"aksrt-orders-page__card-value\"><strong>" + formatMoney(order) + "</strong><span>实付</span></div></div>" +
|
||||
'<footer class="aksrt-orders-page__card-actions">' + countdown + actionHtml(order) + "</footer></article>";
|
||||
}
|
||||
|
||||
@@ -265,7 +300,10 @@
|
||||
if (!root) return;
|
||||
root.innerHTML = '<div class="aksrt-orders-page__stat"><span>全部</span><strong>' + Number(stats.total || 0) + "</strong></div>" +
|
||||
'<div class="aksrt-orders-page__stat"><span>待支付</span><strong class="is-pending">' + Number(stats.pending || 0) + "</strong></div>" +
|
||||
'<div class="aksrt-orders-page__stat"><span>已支付</span><strong class="is-paid">' + Number(stats.paid || 0) + "</strong></div>";
|
||||
'<div class="aksrt-orders-page__stat"><span>已支付</span><strong class="is-paid">' + Number(stats.paid || 0) + "</strong></div>" +
|
||||
'<div class="aksrt-orders-page__stat"><span>已退款</span><strong>' + Number(stats.refunded || 0) + "</strong></div>" +
|
||||
'<div class="aksrt-orders-page__stat"><span>已关闭</span><strong>' + Number(stats.closed || 0) + "</strong></div>" +
|
||||
'<div class="aksrt-orders-page__stat"><span>售后待处理</span><strong>' + Number(stats.after_sale_pending || 0) + "</strong></div>";
|
||||
var pendingCount = state.root.querySelector('[data-aksrt-order-filter="status"][data-aksrt-order-filter-value="pending"] .aksrt-orders-page__filter-count');
|
||||
if (pendingCount) {
|
||||
pendingCount.textContent = String(Number(stats.pending || 0));
|
||||
@@ -428,7 +466,7 @@
|
||||
if (!value) return;
|
||||
var icon = button.querySelector("i");
|
||||
var settle = function (ok) {
|
||||
notyf(ok ? "流水号已复制" : "复制失败,请手动选择复制", ok ? "success" : "danger");
|
||||
notyf(ok ? "订单号已复制" : "复制失败,请手动选择复制", ok ? "success" : "danger");
|
||||
if (!ok) return;
|
||||
button.classList.add("is-copied");
|
||||
if (icon) icon.className = "fa fas fa-check";
|
||||
@@ -517,12 +555,42 @@
|
||||
if (cancel && root.contains(cancel)) { event.preventDefault(); closeOrder(state, cancel); }
|
||||
};
|
||||
state.onRootSubmit = function (event) {
|
||||
var afterSaleForm = closest(event.target, "[data-aksrt-after-sale-form]");
|
||||
if (afterSaleForm) {
|
||||
event.preventDefault();
|
||||
var number = afterSaleForm.getAttribute("data-aksrt-after-sale-form") || "";
|
||||
var reason = afterSaleForm.querySelector("[name=reason]");
|
||||
if (!number || !reason || !String(reason.value || "").trim()) return;
|
||||
request(orderPath(number, "/after-sale"), { reason: String(reason.value).trim() }, "POST", window.AksrtRequest.createIdempotencyKey()).then(function () {
|
||||
notyf("售后申请已提交", "success");
|
||||
invalidateOrder(number);
|
||||
openDetail(state, number);
|
||||
refreshView(state);
|
||||
}).catch(function (error) { notyf(errorMessage(error, "售后申请提交失败"), "danger"); });
|
||||
return;
|
||||
}
|
||||
var form = closest(event.target, "[data-aksrt-orders-search]");
|
||||
if (!form || !root.contains(form)) return;
|
||||
event.preventDefault();
|
||||
var input = form.querySelector("#aksrt-order-number");
|
||||
loadView(state, { orderNumber: input ? String(input.value || "").trim() : "", page: 1 });
|
||||
};
|
||||
state.onDocumentSubmit = function (event) {
|
||||
var afterSaleForm = closest(event.target, "[data-aksrt-after-sale-form]");
|
||||
if (!afterSaleForm || root.contains(afterSaleForm) || event.__aksrtAfterSaleHandled) return;
|
||||
var number = afterSaleForm.getAttribute("data-aksrt-after-sale-form") || "";
|
||||
if (!activeDetail(state, number)) return;
|
||||
event.__aksrtAfterSaleHandled = true;
|
||||
event.preventDefault();
|
||||
var reason = afterSaleForm.querySelector("[name=reason]");
|
||||
if (!number || !reason || !String(reason.value || "").trim()) return;
|
||||
request(orderPath(number, "/after-sale"), { reason: String(reason.value).trim() }, "POST", window.AksrtRequest.createIdempotencyKey()).then(function () {
|
||||
notyf("售后申请已提交", "success");
|
||||
invalidateOrder(number);
|
||||
openDetail(state, number);
|
||||
refreshView(state);
|
||||
}).catch(function (error) { notyf(errorMessage(error, "售后申请提交失败"), "danger"); });
|
||||
};
|
||||
state.onModalClose = function (event) {
|
||||
var detail = event && event.detail ? event.detail : {};
|
||||
if (!state.detailSession || detail.session !== state.detailSession) return;
|
||||
@@ -533,6 +601,25 @@
|
||||
state.detailReplacementSession = null;
|
||||
};
|
||||
state.onDocumentClick = function (event) {
|
||||
var afterSaleCancel = closest(event.target, "[data-aksrt-after-sale-cancel]");
|
||||
if (afterSaleCancel && !event.__aksrtAfterSaleCancelHandled) {
|
||||
var afterSaleNumber = afterSaleCancel.getAttribute("data-aksrt-after-sale-cancel") || "";
|
||||
if (activeDetail(state, afterSaleNumber)) {
|
||||
event.__aksrtAfterSaleCancelHandled = true;
|
||||
event.preventDefault();
|
||||
afterSaleCancel.disabled = true;
|
||||
request(orderPath(afterSaleNumber, "/after-sale"), undefined, "DELETE", window.AksrtRequest.createIdempotencyKey()).then(function () {
|
||||
notyf("售后申请已取消", "success");
|
||||
invalidateOrder(afterSaleNumber);
|
||||
openDetail(state, afterSaleNumber);
|
||||
refreshView(state);
|
||||
}).catch(function (error) {
|
||||
afterSaleCancel.disabled = false;
|
||||
notyf(errorMessage(error, "取消申请失败"), "danger");
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!state.detailSession || window.AksrtModalRuntime.getActive() !== state.detailSession.shell) return;
|
||||
var copy = closest(event.target, "[data-aksrt-order-copy]");
|
||||
if (copy) {
|
||||
@@ -561,6 +648,7 @@
|
||||
|
||||
root.addEventListener("click", state.onRootClick);
|
||||
root.addEventListener("submit", state.onRootSubmit);
|
||||
document.addEventListener("submit", state.onDocumentSubmit);
|
||||
document.addEventListener("aksrt:modal:close", state.onModalClose);
|
||||
document.addEventListener("click", state.onDocumentClick);
|
||||
state.countdownTimer = window.setInterval(function () { updateCountdowns(state); }, 1000);
|
||||
@@ -586,6 +674,7 @@
|
||||
if (activeDetail(state, state.detailOrderNumber)) window.AksrtModalRuntime.close("replace");
|
||||
item.removeEventListener("click", state.onRootClick);
|
||||
item.removeEventListener("submit", state.onRootSubmit);
|
||||
document.removeEventListener("submit", state.onDocumentSubmit);
|
||||
document.removeEventListener("aksrt:modal:close", state.onModalClose);
|
||||
document.removeEventListener("click", state.onDocumentClick);
|
||||
window.clearInterval(state.countdownTimer);
|
||||
|
||||
@@ -230,6 +230,9 @@ if (class_exists('\AKSRT\Payment\ChargeCard')) {
|
||||
if (class_exists('\AKSRT\Payment\OrderCenterRest')) {
|
||||
\AKSRT\Payment\OrderCenterRest::get_instance()->init();
|
||||
}
|
||||
if (class_exists('\AKSRT\Payment\AfterSaleService')) {
|
||||
\AKSRT\Payment\AfterSaleService::get_instance()->init();
|
||||
}
|
||||
if (class_exists('\AKSRT\Credits\BalanceService')) {
|
||||
\AKSRT\Credits\BalanceService::get_instance()->init();
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ if (!function_exists('aksrt_user_points')) {
|
||||
function aksrt_user_points(int $user_id = 0): int {
|
||||
$user_id = $user_id > 0 ? $user_id : get_current_user_id();
|
||||
return $user_id > 0
|
||||
? \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS)
|
||||
? \AKSRT\Credits\MetaAssetService::get_instance()->balance_points($user_id)
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ if (!function_exists('aksrt_user_balance_decimal')) {
|
||||
function aksrt_user_balance_decimal(int $user_id = 0): string {
|
||||
$user_id = $user_id > 0 ? $user_id : get_current_user_id();
|
||||
return $user_id > 0
|
||||
? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id)
|
||||
? \AKSRT\Credits\MetaAssetService::get_instance()->balance_decimal($user_id)
|
||||
: '0.00';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,13 +319,12 @@ if (!function_exists('aksrt_get_user_content_paid_order')) {
|
||||
return null;
|
||||
}
|
||||
$orders = \AKSRT\Payment\OrderRepository::get_instance()->get_user_orders($uid, [
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
|
||||
'status' => '',
|
||||
'limit' => 20,
|
||||
'offset' => 0,
|
||||
]);
|
||||
foreach ($orders as $order) {
|
||||
if ((int) ($order['product_id'] ?? 0) !== $post_id) {
|
||||
if ((int) ($order['product_id'] ?? 0) !== $post_id || !\AKSRT\Core\DomainModel::is_resource_product_type((string) ($order['product_type'] ?? ''))) {
|
||||
continue;
|
||||
}
|
||||
$status = (string) ($order['status'] ?? '');
|
||||
|
||||
@@ -188,7 +188,6 @@ add_action('wp_ajax_aksrt_submit_tip', function (): void {
|
||||
|
||||
wp_send_json_success([
|
||||
'message' => __('赞赏成功,金额已进入对方余额', 'aksrt'),
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'replayed' => $result->replayed,
|
||||
'balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($sender_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(),
|
||||
]);
|
||||
|
||||
@@ -21,12 +21,14 @@ defined('ABSPATH') || exit;
|
||||
$credits_page = ($asset_request['asset'] ?? '') === 'points'
|
||||
? max(1, (int) ($asset_request['page'] ?? 1))
|
||||
: 1;
|
||||
$log = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['user_id' => $user_id, 'asset' => 'points', 'page' => $credits_page, 'per_page' => 10]);
|
||||
if (!empty($log['items'])) :
|
||||
$history = get_user_meta((int) $user_id, 'points_record', true);
|
||||
$history = is_array($history) ? array_slice($history, 0, 50) : [];
|
||||
$items = array_slice($history, ($credits_page - 1) * 10, 10);
|
||||
if ($items) :
|
||||
?>
|
||||
<div class="uc-transactions__list">
|
||||
<?php foreach ($log['items'] as $row) :
|
||||
$delta = (int) $row['delta_minor'];
|
||||
<?php foreach ($items as $row) :
|
||||
$delta = (int) ($row['value'] ?? 0);
|
||||
$delta_class = $delta >= 0 ? 'is-positive' : 'is-negative';
|
||||
?>
|
||||
<div class="uc-transactions__item">
|
||||
@@ -35,12 +37,12 @@ defined('ABSPATH') || exit;
|
||||
</div>
|
||||
<div class="uc-transactions__info">
|
||||
<div class="uc-transactions__reason">
|
||||
<?php echo esc_html(aksrt_asset_reason_label((string) $row['reason'])); ?>
|
||||
<?php echo esc_html((string) ($row['desc'] ?? '') !== '' ? (string) $row['desc'] : aksrt_asset_reason_label((string) ($row['type'] ?? ''))); ?>
|
||||
</div>
|
||||
<div class="uc-transactions__time">
|
||||
<?php echo esc_html(get_date_from_gmt((string) $row['created_at'], 'Y-m-d H:i')); ?>
|
||||
<?php echo esc_html((string) ($row['time'] ?? '')); ?>
|
||||
</div>
|
||||
<?php if (!empty($row['counterparty_user_id'])) : ?><div class="muted-2-color em09"><?php printf(esc_html__('对方用户 #%d', 'aksrt'), (int) $row['counterparty_user_id']); ?></div><?php endif; ?>
|
||||
<?php if (!empty($row['order_num'])) : ?><div class="muted-2-color em09"><?php echo esc_html__('订单号:', 'aksrt') . esc_html((string) $row['order_num']); ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="uc-transactions__amount">
|
||||
<span class="uc-transactions__delta <?php echo esc_attr($delta_class); ?>">
|
||||
@@ -51,7 +53,7 @@ defined('ABSPATH') || exit;
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php
|
||||
$total_pages = (int) ceil((int) ($log['total'] ?? 0) / 10);
|
||||
$total_pages = (int) ceil(count($history) / 10);
|
||||
if ($total_pages > 1 && function_exists('aksrt_render_asset_pagination')) {
|
||||
aksrt_render_asset_pagination($total_pages, $credits_page, 'points');
|
||||
}
|
||||
@@ -61,7 +63,7 @@ defined('ABSPATH') || exit;
|
||||
<div class="uc-transactions__empty-icon">
|
||||
<i class="fa fas fa-file-alt" aria-hidden="true"></i>
|
||||
</div>
|
||||
<p><?php echo esc_html(sprintf(__('还没有任何%s流水', 'aksrt'), $currency_name)); ?></p>
|
||||
<p><?php echo esc_html(sprintf(__('还没有任何%s记录', 'aksrt'), $currency_name)); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -13,12 +13,9 @@ $asset_request = is_array($asset_request ?? null) ? $asset_request : [];
|
||||
$wallet_page = ($asset_request['asset'] ?? '') === 'balance'
|
||||
? max(1, (int) ($asset_request['page'] ?? 1))
|
||||
: 1;
|
||||
$wlog = \AKSRT\Ledger\LedgerQuery::get_instance()->history([
|
||||
'user_id' => (int) ($user_id ?? 0),
|
||||
'asset' => 'balance',
|
||||
'page' => $wallet_page,
|
||||
'per_page' => 10,
|
||||
]);
|
||||
$history = get_user_meta((int) ($user_id ?? 0), 'balance_record', true);
|
||||
$history = is_array($history) ? array_slice($history, 0, 50) : [];
|
||||
$items = array_slice($history, ($wallet_page - 1) * 10, 10);
|
||||
?>
|
||||
<div class="uc-transactions uc-transactions--wallet">
|
||||
<div class="uc-transactions__header">
|
||||
@@ -27,12 +24,14 @@ $wlog = \AKSRT\Ledger\LedgerQuery::get_instance()->history([
|
||||
<?php esc_html_e('余额记录', 'aksrt'); ?>
|
||||
</h3>
|
||||
</div>
|
||||
<?php if (!empty($wlog['items'])) : ?>
|
||||
<?php if ($items) : ?>
|
||||
<div class="uc-transactions__list">
|
||||
<?php foreach ($wlog['items'] as $row) :
|
||||
$delta = (int) $row['delta_minor'];
|
||||
<?php foreach ($items as $row) :
|
||||
$raw_delta = (string) ($row['value'] ?? '0');
|
||||
try { $delta_minor = \AKSRT\Ledger\AssetAmount::balance($raw_delta)->minor(); } catch (\InvalidArgumentException $e) { $delta_minor = 0; }
|
||||
$delta = $delta_minor;
|
||||
$delta_class = $delta >= 0 ? 'is-positive' : 'is-negative';
|
||||
$delta_txt = ($delta > 0 ? '+' : '-') . \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, abs($delta))->formatted();
|
||||
$delta_txt = ($delta > 0 ? '+' : ($delta < 0 ? '-' : '')) . \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, abs($delta))->formatted();
|
||||
?>
|
||||
<div class="uc-transactions__item">
|
||||
<div class="uc-transactions__icon <?php echo esc_attr($delta_class); ?>">
|
||||
@@ -40,9 +39,10 @@ $wlog = \AKSRT\Ledger\LedgerQuery::get_instance()->history([
|
||||
</div>
|
||||
<div class="uc-transactions__info">
|
||||
<div class="uc-transactions__reason">
|
||||
<?php echo esc_html(aksrt_asset_reason_label((string) $row['reason'])); ?>
|
||||
<?php echo esc_html((string) ($row['desc'] ?? '') !== '' ? (string) $row['desc'] : aksrt_asset_reason_label((string) ($row['type'] ?? ''))); ?>
|
||||
</div>
|
||||
<div class="uc-transactions__time"><?php echo esc_html(get_date_from_gmt((string) $row['created_at'], 'Y-m-d H:i')); ?></div>
|
||||
<div class="uc-transactions__time"><?php echo esc_html((string) ($row['time'] ?? '')); ?></div>
|
||||
<?php if (!empty($row['order_num'])) : ?><div class="muted-2-color em09"><?php echo esc_html__('订单号:', 'aksrt') . esc_html((string) $row['order_num']); ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="uc-transactions__amount">
|
||||
<span class="uc-transactions__delta <?php echo esc_attr($delta_class); ?>"><?php echo esc_html($delta_txt); ?></span>
|
||||
@@ -51,7 +51,7 @@ $wlog = \AKSRT\Ledger\LedgerQuery::get_instance()->history([
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php
|
||||
$total_pages = (int) ceil((int) ($wlog['total'] ?? 0) / 10);
|
||||
$total_pages = (int) ceil(count($history) / 10);
|
||||
if ($total_pages > 1 && function_exists('aksrt_render_asset_pagination')) {
|
||||
aksrt_render_asset_pagination($total_pages, $wallet_page, 'balance');
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ $status_options = [
|
||||
'all' => __('全部', 'aksrt'),
|
||||
'pending' => __('待支付', 'aksrt'),
|
||||
'paid' => __('已支付', 'aksrt'),
|
||||
'refunding' => __('退款中', 'aksrt'),
|
||||
'refunded' => __('已退款', 'aksrt'),
|
||||
'closed' => __('已关闭', 'aksrt'),
|
||||
];
|
||||
@@ -58,13 +57,15 @@ $status_options = [
|
||||
<div class="aksrt-orders-page__stat"><span><?php esc_html_e('全部', 'aksrt'); ?></span><strong><?php echo esc_html((string) (int) ($stats['total'] ?? 0)); ?></strong></div>
|
||||
<div class="aksrt-orders-page__stat"><span><?php esc_html_e('待支付', 'aksrt'); ?></span><strong class="is-pending"><?php echo esc_html((string) (int) ($stats['pending'] ?? 0)); ?></strong></div>
|
||||
<div class="aksrt-orders-page__stat"><span><?php esc_html_e('已支付', 'aksrt'); ?></span><strong class="is-paid"><?php echo esc_html((string) (int) ($stats['paid'] ?? 0)); ?></strong></div>
|
||||
<div class="aksrt-orders-page__stat"><span><?php esc_html_e('已退款', 'aksrt'); ?></span><strong><?php echo esc_html((string) (int) ($stats['refunded'] ?? 0)); ?></strong></div>
|
||||
<div class="aksrt-orders-page__stat"><span><?php esc_html_e('售后待处理', 'aksrt'); ?></span><strong><?php echo esc_html((string) (int) ($stats['after_sale_pending'] ?? 0)); ?></strong></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="aksrt-orders-page__search" data-aksrt-orders-search>
|
||||
<label class="screen-reader-text" for="aksrt-order-number"><?php esc_html_e('按流水号查找', 'aksrt'); ?></label>
|
||||
<label class="screen-reader-text" for="aksrt-order-number"><?php esc_html_e('按订单号查找', 'aksrt'); ?></label>
|
||||
<i class="fa fas fa-search" aria-hidden="true"></i>
|
||||
<input id="aksrt-order-number" value="<?php echo esc_attr($order_number); ?>" maxlength="32" placeholder="<?php esc_attr_e('输入流水号开头快速查找', 'aksrt'); ?>">
|
||||
<input id="aksrt-order-number" value="<?php echo esc_attr($order_number); ?>" maxlength="22" placeholder="<?php esc_attr_e('输入订单号查找', 'aksrt'); ?>">
|
||||
<button class="btn-primary" type="submit"><?php esc_html_e('查找', 'aksrt'); ?></button>
|
||||
<button class="aksrt-orders-page__filter-toggle" type="button" data-aksrt-orders-filter-toggle aria-expanded="false" aria-controls="aksrt-orders-filter-panel"><i class="fa fas fa-sliders-h" aria-hidden="true"></i> <?php esc_html_e('筛选', 'aksrt'); ?></button>
|
||||
</form>
|
||||
@@ -112,7 +113,7 @@ $status_options = [
|
||||
<div class="aksrt-orders-page__card-rail"><span><?php echo esc_html((string) ($order['product_type_label'] ?? '')); ?></span><time><?php echo esc_html((string) ($order['created_at'] ?? '')); ?></time><span class="aksrt-orders-page__card-status is-<?php echo esc_attr((string) ($order['status_group'] ?? '')); ?>"><?php echo esc_html((string) ($order['status_label'] ?? '')); ?></span></div>
|
||||
<div class="aksrt-orders-page__card-main">
|
||||
<?php if (!empty($order['image'])) : ?><img class="aksrt-orders-page__card-img" src="<?php echo esc_url((string) $order['image']); ?>" alt=""><?php else : ?><div class="aksrt-orders-page__card-art" aria-hidden="true"><i class="fa fas fa-book"></i></div><?php endif; ?>
|
||||
<div class="aksrt-orders-page__card-copy"><h3><?php echo esc_html((string) ($order['product_name'] ?? __('订单', 'aksrt'))); ?></h3><p><?php printf(esc_html__('流水号 %s', 'aksrt'), esc_html($number)); ?></p></div>
|
||||
<div class="aksrt-orders-page__card-copy"><h3><?php echo esc_html((string) ($order['product_name'] ?? __('订单', 'aksrt'))); ?></h3><p><?php printf(esc_html__('订单号 %s', 'aksrt'), esc_html($number)); ?></p></div>
|
||||
<div class="aksrt-orders-page__card-value"><strong><?php echo $amount; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></strong><span><?php esc_html_e('实付', 'aksrt'); ?></span></div>
|
||||
</div>
|
||||
<footer class="aksrt-orders-page__card-actions">
|
||||
@@ -120,6 +121,7 @@ $status_options = [
|
||||
<button class="btn-outline" type="button" data-aksrt-order-detail="<?php echo esc_attr($number); ?>"><?php esc_html_e('订单详情', 'aksrt'); ?></button>
|
||||
<?php if (!empty($actions['can_cancel'])) : ?><button class="btn-ghost" type="button" data-aksrt-order-cancel="<?php echo esc_attr($number); ?>"><?php esc_html_e('关闭订单', 'aksrt'); ?></button><?php endif; ?>
|
||||
<?php if (!empty($actions['can_pay'])) : ?><button class="btn-primary" type="button" data-aksrt-order-pay="<?php echo esc_attr($number); ?>" data-aksrt-order-method="<?php echo esc_attr((string) ($order['payment_method']['value'] ?? '')); ?>"><?php esc_html_e('继续支付', 'aksrt'); ?></button><?php endif; ?>
|
||||
<?php if (!empty($actions['can_after_sale'])) : ?><button class="btn-outline" type="button" data-aksrt-order-detail="<?php echo esc_attr($number); ?>"><?php esc_html_e('申请售后', 'aksrt'); ?></button><?php endif; ?>
|
||||
</footer>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class PayFlows {
|
||||
|
||||
public const CAPABILITY = 'manage_options';
|
||||
public const PAGE_SLUG = 'aksrt_flows';
|
||||
private static ?self $instance = null;
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function render_page(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
|
||||
}
|
||||
$keyword = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
|
||||
$event_type = isset($_GET['event_type']) ? sanitize_key((string) wp_unslash($_GET['event_type'])) : '';
|
||||
$asset = isset($_GET['asset']) ? sanitize_key((string) wp_unslash($_GET['asset'])) : '';
|
||||
$direction = isset($_GET['direction']) ? sanitize_key((string) wp_unslash($_GET['direction'])) : '';
|
||||
$user_id = isset($_GET['user_id']) ? max(0, (int) $_GET['user_id']) : 0;
|
||||
$date_from = isset($_GET['date_from']) ? $this->sanitize_date((string) wp_unslash($_GET['date_from'])) : '';
|
||||
$date_to = isset($_GET['date_to']) ? $this->sanitize_date((string) wp_unslash($_GET['date_to'])) : '';
|
||||
$page = max(1, (int) ($_GET['paged'] ?? 1));
|
||||
$query = \AKSRT\Finance\FinancialFlowQuery::get_instance();
|
||||
$result = $query->search([
|
||||
'keyword' => $keyword,
|
||||
'event_type' => $event_type,
|
||||
'asset' => $asset,
|
||||
'direction' => $direction,
|
||||
'user_id' => $user_id,
|
||||
'date_from' => $this->date_bound_utc($date_from, false),
|
||||
'date_to' => $this->date_bound_utc($date_to, true),
|
||||
'page' => $page,
|
||||
'per_page' => 20,
|
||||
]);
|
||||
$pages = max(1, (int) ceil($result['total'] / $result['per_page']));
|
||||
|
||||
echo '<div class="wrap"><h1>' . esc_html__('流水明细', 'aksrt') . '</h1>';
|
||||
echo '<p class="description">' . esc_html__('仅展示已经确认发生的现金、余额和积分变动;流水不可编辑。', 'aksrt') . '</p>';
|
||||
echo '<form method="get" style="margin:12px 0;display:flex;gap:6px;align-items:center;flex-wrap:wrap;">';
|
||||
echo '<input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '">';
|
||||
echo '<input type="search" name="s" value="' . esc_attr($keyword) . '" placeholder="' . esc_attr__('账本流水号 / 流水号 / 渠道交易号', 'aksrt') . '" style="min-width:280px;">';
|
||||
echo '<input type="number" min="1" name="user_id" value="' . esc_attr($user_id > 0 ? (string) $user_id : '') . '" placeholder="' . esc_attr__('用户 ID', 'aksrt') . '" class="small-text">';
|
||||
echo '<select name="event_type"><option value="">' . esc_html__('全部事件', 'aksrt') . '</option>';
|
||||
foreach ($query->event_types() as $key => $label) {
|
||||
echo '<option value="' . esc_attr($key) . '"' . selected($event_type, $key, false) . '>' . esc_html($this->event_label($label)) . '</option>';
|
||||
}
|
||||
echo '</select>';
|
||||
$this->render_select('asset', $asset, __('全部资产', 'aksrt'), ['cash' => __('现金', 'aksrt'), 'balance' => __('余额', 'aksrt'), 'points' => __('积分', 'aksrt')]);
|
||||
$this->render_select('direction', $direction, __('全部方向', 'aksrt'), ['in' => __('收入', 'aksrt'), 'out' => __('支出', 'aksrt')]);
|
||||
echo '<label>' . esc_html__('从', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr($date_from) . '"></label>';
|
||||
echo '<label>' . esc_html__('至', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr($date_to) . '"></label>';
|
||||
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
|
||||
if ($keyword !== '' || $event_type !== '' || $asset !== '' || $direction !== '' || $user_id > 0 || $date_from !== '' || $date_to !== '') {
|
||||
echo '<a class="button" href="' . esc_url($this->page_url()) . '">' . esc_html__('清除条件', 'aksrt') . '</a>';
|
||||
}
|
||||
echo '</form>';
|
||||
echo '<p>' . esc_html(sprintf(__('共 %d 条流水', 'aksrt'), (int) $result['total'])) . '</p>';
|
||||
echo '<table class="wp-list-table widefat fixed striped"><thead><tr>';
|
||||
echo '<th style="width:180px">' . esc_html__('账本流水号 / 时间', 'aksrt') . '</th>';
|
||||
echo '<th style="width:130px">' . esc_html__('事件', 'aksrt') . '</th>';
|
||||
echo '<th>' . esc_html__('用户与分录', 'aksrt') . '</th>';
|
||||
echo '<th>' . esc_html__('关联业务', 'aksrt') . '</th>';
|
||||
echo '<th>' . esc_html__('渠道交易号', 'aksrt') . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
if ($result['items'] === []) {
|
||||
echo '<tr><td colspan="5">' . esc_html__('暂无匹配流水', 'aksrt') . '</td></tr>';
|
||||
}
|
||||
foreach ($result['items'] as $flow) {
|
||||
echo '<tr>';
|
||||
echo '<td><code>' . esc_html((string) $flow['flow_number']) . '</code><br><span class="description">' . esc_html($this->local_time((string) $flow['created_at'])) . '</span></td>';
|
||||
echo '<td><strong>' . esc_html($this->event_label((string) $flow['event_type'])) . '</strong><br><code>' . esc_html((string) $flow['event_type']) . '</code></td>';
|
||||
echo '<td>' . $this->entries_html((array) $flow['entries']) . '</td>';
|
||||
echo '<td>' . $this->reference_html($flow) . '</td>';
|
||||
echo '<td>' . ((string) ($flow['provider_transaction_id'] ?? '') !== '' ? '<code>' . esc_html((string) $flow['provider_transaction_id']) . '</code>' : '—') . '</td>';
|
||||
echo '</tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
if ($pages > 1) {
|
||||
echo '<div class="tablenav"><div class="tablenav-pages">' . wp_kses_post((string) paginate_links([
|
||||
'base' => add_query_arg('paged', '%#%'),
|
||||
'format' => '',
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
'total' => $pages,
|
||||
'current' => $page,
|
||||
])) . '</div></div>';
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/** @param array<string,string> $options */
|
||||
private function render_select(string $name, string $value, string $empty_label, array $options): void {
|
||||
echo '<select name="' . esc_attr($name) . '"><option value="">' . esc_html($empty_label) . '</option>';
|
||||
foreach ($options as $key => $label) {
|
||||
echo '<option value="' . esc_attr($key) . '"' . selected($value, $key, false) . '>' . esc_html($label) . '</option>';
|
||||
}
|
||||
echo '</select>';
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $entries */
|
||||
private function entries_html(array $entries): string {
|
||||
if ($entries === []) {
|
||||
return '—';
|
||||
}
|
||||
$lines = [];
|
||||
foreach ($entries as $entry) {
|
||||
$user_id = (int) ($entry['user_id'] ?? 0);
|
||||
$user = $user_id > 0 ? get_userdata($user_id) : null;
|
||||
$user_label = $user ? $user->display_name . ' (#' . $user_id . ')' : '#' . $user_id;
|
||||
$asset = (string) ($entry['asset'] ?? '');
|
||||
$delta = (int) ($entry['delta_minor'] ?? 0);
|
||||
$amount = $asset === 'points'
|
||||
? (string) abs($delta)
|
||||
: \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, abs($delta))->decimal();
|
||||
$asset_label = ['cash' => __('现金', 'aksrt'), 'balance' => __('余额', 'aksrt'), 'points' => __('积分', 'aksrt')][$asset] ?? $asset;
|
||||
$lines[] = esc_html($user_label) . ' · ' . esc_html($asset_label) . ' <strong>' . ($delta > 0 ? '+' : '-') . esc_html($amount) . '</strong>';
|
||||
}
|
||||
return implode('<br>', $lines);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $flow */
|
||||
private function reference_html(array $flow): string {
|
||||
$lines = [];
|
||||
if (!empty($flow['order_number'])) {
|
||||
$url = add_query_arg(['page' => PayOrders::PAGE_SLUG, 'order' => (string) $flow['order_number']], admin_url('admin.php'));
|
||||
$lines[] = esc_html__('订单', 'aksrt') . ' <a href="' . esc_url($url) . '"><code>' . esc_html((string) $flow['order_number']) . '</code></a>';
|
||||
}
|
||||
if (!empty($flow['withdrawal_number'])) {
|
||||
$lines[] = esc_html__('提现', 'aksrt') . ' <code>' . esc_html((string) $flow['withdrawal_number']) . '</code>';
|
||||
}
|
||||
if ($lines === [] && !empty($flow['ref_key'])) {
|
||||
$lines[] = esc_html((string) ($flow['ref_type'] ?? '')) . ' · ' . esc_html((string) $flow['ref_key']);
|
||||
}
|
||||
return $lines !== [] ? implode('<br>', $lines) : '—';
|
||||
}
|
||||
|
||||
private function event_label(string $event_type): string {
|
||||
$labels = [
|
||||
'order_payment' => __('订单支付', 'aksrt'),
|
||||
'order_refund' => __('订单退款', 'aksrt'),
|
||||
'withdraw_hold' => __('提现冻结', 'aksrt'),
|
||||
'withdraw_refund' => __('提现退回', 'aksrt'),
|
||||
'withdraw_payout' => __('提现打款', 'aksrt'),
|
||||
'creator_income' => __('创作收益', 'aksrt'),
|
||||
'admin_adjustment' => __('后台调账', 'aksrt'),
|
||||
'points_exchange' => __('积分兑换', 'aksrt'),
|
||||
'transfer' => __('用户转账', 'aksrt'),
|
||||
];
|
||||
return $labels[$event_type] ?? $event_type;
|
||||
}
|
||||
|
||||
private function sanitize_date(string $date): string {
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1 ? $date : '';
|
||||
}
|
||||
|
||||
private function date_bound_utc(string $date, bool $exclusive_end): string {
|
||||
if ($date === '') {
|
||||
return '';
|
||||
}
|
||||
$local = new \DateTimeImmutable($date . ' 00:00:00', wp_timezone());
|
||||
if ($exclusive_end) {
|
||||
$local = $local->modify('+1 day');
|
||||
}
|
||||
return $local->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
private function local_time(string $utc): string {
|
||||
if ($utc === '') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return (new \DateTimeImmutable($utc, new \DateTimeZone('UTC')))->setTimezone(wp_timezone())->format('Y-m-d H:i:s');
|
||||
} catch (\Exception $e) {
|
||||
return $utc;
|
||||
}
|
||||
}
|
||||
|
||||
private function page_url(): string {
|
||||
return add_query_arg(['page' => self::PAGE_SLUG], admin_url('admin.php'));
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ class PayMenus {
|
||||
],
|
||||
'orders' => [
|
||||
'slug' => 'aksrt_orders',
|
||||
'title' => __('订单', 'aksrt'),
|
||||
'title' => __('订单明细', 'aksrt'),
|
||||
'callback' => static function (): void {
|
||||
if (class_exists('AKSRT\Admin\Native\PayOrders')) {
|
||||
PayOrders::get_instance()->render_orders_page();
|
||||
@@ -52,13 +52,6 @@ class PayMenus {
|
||||
wp_die(esc_html__('订单模块未初始化。', 'aksrt'));
|
||||
},
|
||||
],
|
||||
'flows' => [
|
||||
'slug' => PayFlows::PAGE_SLUG,
|
||||
'title' => __('流水明细', 'aksrt'),
|
||||
'callback' => static function (): void {
|
||||
PayFlows::get_instance()->render_page();
|
||||
},
|
||||
],
|
||||
'products' => [
|
||||
'slug' => 'aksrt_pay_products',
|
||||
'title' => __('付费文章', 'aksrt'),
|
||||
|
||||
+158
-29
@@ -29,11 +29,12 @@ class PayOrders {
|
||||
add_action('admin_post_aksrt_pay_update_order_note', [$this, 'handle_update_order_note']);
|
||||
add_action('admin_post_aksrt_pay_clear_closed_orders', [$this, 'handle_clear_closed_orders']);
|
||||
add_action('admin_post_aksrt_pay_delete_order', [$this, 'handle_delete_order']);
|
||||
add_action('admin_post_aksrt_after_sale_review', [$this, 'handle_after_sale_review']);
|
||||
}
|
||||
|
||||
public function render_orders_page(): void {
|
||||
$this->ensure_cap();
|
||||
$this->render_header(__('订单', 'aksrt'));
|
||||
$this->render_header(__('订单明细', 'aksrt'));
|
||||
|
||||
if (!class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
echo '<div class="notice notice-error"><p>' . esc_html__('订单服务不可用。', 'aksrt') . '</p></div>';
|
||||
@@ -41,7 +42,21 @@ class PayOrders {
|
||||
return;
|
||||
}
|
||||
|
||||
$detail_order = isset($_GET['order']) ? sanitize_text_field((string) wp_unslash($_GET['order'])) : '';
|
||||
$tab = isset($_GET['tab']) ? sanitize_key((string) wp_unslash($_GET['tab'])) : 'orders';
|
||||
if (!in_array($tab, ['stats', 'orders', 'after-sales'], true)) $tab = 'orders';
|
||||
$this->render_workbench_tabs($tab);
|
||||
if ($tab === 'stats') {
|
||||
$this->render_statistics_page();
|
||||
$this->render_footer();
|
||||
return;
|
||||
}
|
||||
if ($tab === 'after-sales') {
|
||||
$this->render_after_sales_page();
|
||||
$this->render_footer();
|
||||
return;
|
||||
}
|
||||
|
||||
$detail_order = isset($_GET['order']) ? sanitize_text_field((string) wp_unslash($_GET['order'])) : '';
|
||||
if ($detail_order !== '') {
|
||||
$this->render_order_detail_page($detail_order);
|
||||
$this->render_footer();
|
||||
@@ -50,10 +65,13 @@ class PayOrders {
|
||||
|
||||
$type_tabs = [
|
||||
'' => __('全部类型', 'aksrt'),
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE => __('内容付费', 'aksrt'),
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP => __('VIP', 'aksrt'),
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE => __('充值', 'aksrt'),
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS => __('购买积分', 'aksrt'),
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING => __('付费阅读', 'aksrt'),
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD => __('付费下载', 'aksrt'),
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE => __('付费图片', 'aksrt'),
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO => __('付费视频', 'aksrt'),
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP => __('购买会员', 'aksrt'),
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE => __('余额充值', 'aksrt'),
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS => __('购买积分', 'aksrt'),
|
||||
];
|
||||
$method_tabs = [
|
||||
'' => __('支付方式', 'aksrt'),
|
||||
@@ -68,6 +86,8 @@ class PayOrders {
|
||||
$product_id = isset($_GET['product_id']) ? max(0, (int) $_GET['product_id']) : 0;
|
||||
$user_id = isset($_GET['user_id']) ? max(0, (int) $_GET['user_id']) : 0;
|
||||
$pay_method = isset($_GET['payment_method']) ? sanitize_key((string) wp_unslash($_GET['payment_method'])) : '';
|
||||
$date_from = isset($_GET['date_from']) ? sanitize_text_field((string) wp_unslash($_GET['date_from'])) : '';
|
||||
$date_to = isset($_GET['date_to']) ? sanitize_text_field((string) wp_unslash($_GET['date_to'])) : '';
|
||||
$page_num = max(1, (int) ($_GET['paged'] ?? 1));
|
||||
$per_page = 20;
|
||||
|
||||
@@ -82,6 +102,8 @@ class PayOrders {
|
||||
$args = [
|
||||
'limit' => $per_page,
|
||||
'offset' => ($page_num - 1) * $per_page,
|
||||
'date_from' => $date_from,
|
||||
'date_to' => $date_to,
|
||||
];
|
||||
if ($status !== '' && $status !== 'all') {
|
||||
$args['status'] = $status;
|
||||
@@ -118,6 +140,8 @@ class PayOrders {
|
||||
'user_id' => $user_id > 0 ? $user_id : null,
|
||||
'payment_method' => $pay_method !== '' ? $pay_method : null,
|
||||
'status' => ($status !== '' && $status !== 'all') ? $status : null,
|
||||
'date_from' => $date_from !== '' ? $date_from : null,
|
||||
'date_to' => $date_to !== '' ? $date_to : null,
|
||||
's' => $search !== '' ? $search : null,
|
||||
]));
|
||||
$cls = ($product_type === $tkey) ? 'current' : '';
|
||||
@@ -136,6 +160,8 @@ class PayOrders {
|
||||
'user_id' => $user_id > 0 ? $user_id : null,
|
||||
'payment_method' => $mkey !== '' ? $mkey : null,
|
||||
'status' => ($status !== '' && $status !== 'all') ? $status : null,
|
||||
'date_from' => $date_from !== '' ? $date_from : null,
|
||||
'date_to' => $date_to !== '' ? $date_to : null,
|
||||
's' => $search !== '' ? $search : null,
|
||||
]));
|
||||
$cls = ($pay_method === $mkey) ? 'current' : '';
|
||||
@@ -155,6 +181,8 @@ class PayOrders {
|
||||
if ($pay_method !== '') {
|
||||
echo '<input type="hidden" name="payment_method" value="' . esc_attr($pay_method) . '" />';
|
||||
}
|
||||
echo '<label>' . esc_html__('开始日期', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr($date_from) . '" /></label> ';
|
||||
echo '<label>' . esc_html__('结束日期', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr($date_to) . '" /></label> ';
|
||||
echo '<select name="status">';
|
||||
echo '<option value="">' . esc_html__('全部状态', 'aksrt') . '</option>';
|
||||
foreach ($labels as $key => $label) {
|
||||
@@ -167,9 +195,9 @@ class PayOrders {
|
||||
}
|
||||
echo '</select> ';
|
||||
echo '<input type="number" min="0" name="user_id" value="' . esc_attr($user_id > 0 ? (string) $user_id : '') . '" placeholder="' . esc_attr__('用户 ID', 'aksrt') . '" class="small-text" /> ';
|
||||
echo '<input type="search" name="s" value="' . esc_attr($search) . '" placeholder="' . esc_attr__('流水号 / 账本流水号 / 类型 / 用户', 'aksrt') . '" /> ';
|
||||
echo '<input type="search" name="s" value="' . esc_attr($search) . '" placeholder="' . esc_attr__('订单号 / 用户 / 商品', 'aksrt') . '" /> ';
|
||||
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
|
||||
if ($product_id > 0 || $product_type !== '' || $user_id > 0 || $pay_method !== '') {
|
||||
if ($product_id > 0 || $product_type !== '' || $user_id > 0 || $pay_method !== '' || $date_from !== '' || $date_to !== '') {
|
||||
$filter_bits = [];
|
||||
if ($product_type !== '') {
|
||||
$filter_bits[] = sprintf(__('类型=%s', 'aksrt'), $product_type);
|
||||
@@ -183,6 +211,8 @@ class PayOrders {
|
||||
if ($pay_method !== '') {
|
||||
$filter_bits[] = sprintf(__('支付方式=%s', 'aksrt'), $method_tabs[$pay_method]);
|
||||
}
|
||||
if ($date_from !== '') $filter_bits[] = sprintf(__('开始日期=%s', 'aksrt'), $date_from);
|
||||
if ($date_to !== '') $filter_bits[] = sprintf(__('结束日期=%s', 'aksrt'), $date_to);
|
||||
echo ' <span class="description">' . esc_html(implode(' · ', $filter_bits)) . '</span>';
|
||||
echo ' <a class="button button-small" href="' . esc_url($this->page_url('aksrt_orders')) . '">' . esc_html__('清除过滤', 'aksrt') . '</a>';
|
||||
}
|
||||
@@ -200,7 +230,7 @@ class PayOrders {
|
||||
|
||||
echo '<table class="wp-list-table widefat fixed striped">';
|
||||
echo '<thead><tr>';
|
||||
echo '<th>' . esc_html__('流水号', 'aksrt') . '</th>';
|
||||
echo '<th>' . esc_html__('订单号', 'aksrt') . '</th>';
|
||||
echo '<th>' . esc_html__('用户', 'aksrt') . '</th>';
|
||||
echo '<th>' . esc_html__('商品信息', 'aksrt') . '</th>';
|
||||
echo '<th>' . esc_html__('金额 / 支付方式', 'aksrt') . '</th>';
|
||||
@@ -221,6 +251,7 @@ class PayOrders {
|
||||
$status_label = $labels[$status_key] ?? $status_key;
|
||||
$product_id = (int) ($row['product_id'] ?? 0);
|
||||
$product_type = sanitize_key((string) ($row['product_type'] ?? ''));
|
||||
$type_labels = \AKSRT\Core\DomainModel::order_product_type_labels();
|
||||
$meta = is_array($row['meta_payload'] ?? null) ? $row['meta_payload'] : [];
|
||||
$product_title = (string) ($meta['product_title'] ?? $meta['title'] ?? $meta['package_title'] ?? '');
|
||||
if ($product_title === '' && $product_id > 0 && $product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
|
||||
@@ -230,7 +261,7 @@ class PayOrders {
|
||||
echo '<tr>';
|
||||
echo '<td><code>' . esc_html($order_number) . '</code></td>';
|
||||
echo '<td>' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . '</td>';
|
||||
echo '<td><strong>' . esc_html($product_title !== '' ? $product_title : $product_type) . '</strong><br><span class="description">' . esc_html($product_type) . ($product_id > 0 ? ' #' . esc_html((string) $product_id) : '') . '</span></td>';
|
||||
echo '<td><strong>' . esc_html($product_title !== '' ? $product_title : ($type_labels[$product_type] ?? $product_type)) . '</strong><br><span class="description">' . esc_html($type_labels[$product_type] ?? $product_type) . ($product_id > 0 ? ' #' . esc_html((string) $product_id) : '') . '</span></td>';
|
||||
echo '<td><strong>' . esc_html($this->order_amount($row)) . '</strong><br><span class="description">' . esc_html((string) ($row['payment_method'] ?? '')) . '</span></td>';
|
||||
echo '<td>' . esc_html($status_label) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '<br><span class="description">' . esc_html((string) ($row['paid_at'] ?? '') ?: '—') . '</span></td>';
|
||||
@@ -280,7 +311,6 @@ class PayOrders {
|
||||
? \AKSRT\Core\DomainModel::order_status_labels()
|
||||
: [];
|
||||
$context = $repo->get_admin_order_context($order_number);
|
||||
$flow_number = \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number);
|
||||
$user_id = (int) ($order['user_id'] ?? 0);
|
||||
$user = $user_id > 0 ? get_userdata($user_id) : null;
|
||||
$status = (string) ($order['status'] ?? '');
|
||||
@@ -295,10 +325,9 @@ class PayOrders {
|
||||
|
||||
echo '<table class="widefat striped" style="max-width:960px;"><tbody>';
|
||||
$rows = [
|
||||
__('流水号', 'aksrt') => $order_number,
|
||||
__('账本流水号', 'aksrt') => $flow_number !== '' ? $flow_number : '—',
|
||||
__('订单号', 'aksrt') => $order_number,
|
||||
__('用户', 'aksrt') => $user ? $user->display_name . ' (#' . $user_id . ')' : (string) $user_id,
|
||||
__('商品类型', 'aksrt') => (string) ($order['product_type'] ?? ''),
|
||||
__('商品类型', 'aksrt') => \AKSRT\Core\DomainModel::order_product_type_labels()[(string) ($order['product_type'] ?? '')] ?? (string) ($order['product_type'] ?? ''),
|
||||
__('商品 ID', 'aksrt') => (string) ($order['product_id'] ?? ''),
|
||||
__('金额', 'aksrt') => $this->order_amount($order),
|
||||
__('状态', 'aksrt') => $labels[$status] ?? $status,
|
||||
@@ -311,6 +340,18 @@ class PayOrders {
|
||||
foreach ($rows as $label => $value) {
|
||||
echo '<tr><th style="width:160px;">' . esc_html($label) . '</th><td>' . esc_html((string) $value) . '</td></tr>';
|
||||
}
|
||||
$after_sales = \AKSRT\Payment\AfterSaleService::get_instance()->list_for_order($order_number);
|
||||
if ($after_sales) {
|
||||
echo '<tr><th>' . esc_html__('售后记录', 'aksrt') . '</th><td>';
|
||||
$status_labels = ['pending' => '待审核', 'processing' => '处理中', 'approved' => '已通过', 'rejected' => '已拒绝', 'cancelled' => '已取消', 'completed' => '已完成'];
|
||||
foreach ($after_sales as $application) {
|
||||
echo '<p><strong>' . esc_html($status_labels[(string) ($application['status'] ?? '')] ?? (string) ($application['status'] ?? '')) . '</strong> · ' . esc_html((string) ($application['refund_amount'] ?? '')) . '<br>' . esc_html((string) ($application['reason'] ?? ''));
|
||||
if (!empty($application['admin_note'])) echo '<br>' . esc_html__('处理备注:', 'aksrt') . esc_html((string) $application['admin_note']);
|
||||
if (!empty($application['gateway_result'])) echo '<br><code>' . esc_html((string) $application['gateway_result']) . '</code>';
|
||||
echo '</p>';
|
||||
}
|
||||
echo '</td></tr>';
|
||||
}
|
||||
if (!empty($context)) {
|
||||
echo '<tr><th>' . esc_html__('附加上下文', 'aksrt') . '</th><td><pre style="white-space:pre-wrap;margin:0;">' . esc_html(wp_json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) . '</pre></td></tr>';
|
||||
}
|
||||
@@ -331,6 +372,92 @@ class PayOrders {
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
private function render_workbench_tabs(string $active): void {
|
||||
echo '<nav class="nav-tab-wrapper" style="margin:12px 0 18px;">';
|
||||
foreach (['stats' => __('统计', 'aksrt'), 'orders' => __('订单', 'aksrt'), 'after-sales' => __('售后', 'aksrt')] as $key => $label) {
|
||||
$url = $this->page_url(self::PAGE_SLUG, ['tab' => $key]);
|
||||
echo '<a class="nav-tab ' . ($active === $key ? 'nav-tab-active' : '') . '" href="' . esc_url($url) . '">' . esc_html($label) . '</a>';
|
||||
}
|
||||
echo '</nav>';
|
||||
}
|
||||
|
||||
private function render_statistics_page(): void {
|
||||
$stats = \AKSRT\Payment\OrderRepository::get_instance()->get_admin_order_stats([
|
||||
'date_from' => isset($_GET['date_from']) ? sanitize_text_field((string) wp_unslash($_GET['date_from'])) : '',
|
||||
'date_to' => isset($_GET['date_to']) ? sanitize_text_field((string) wp_unslash($_GET['date_to'])) : '',
|
||||
]);
|
||||
echo '<form method="get" style="margin-bottom:16px;"><input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '" /><input type="hidden" name="tab" value="stats" />';
|
||||
echo '<label>' . esc_html__('开始日期', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr((string) ($_GET['date_from'] ?? '')) . '" /></label> ';
|
||||
echo '<label>' . esc_html__('结束日期', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr((string) ($_GET['date_to'] ?? '')) . '" /></label> ';
|
||||
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
|
||||
echo '</form><div style="display:flex;flex-wrap:wrap;gap:12px;">';
|
||||
foreach (['total' => __('订单总数', 'aksrt'), 'pending' => __('待支付', 'aksrt'), 'paid' => __('已支付', 'aksrt'), 'refunded' => __('已退款', 'aksrt'), 'closed' => __('已关闭', 'aksrt'), 'revenue' => __('实收金额', 'aksrt'), 'after_sale_pending' => __('售后待审核', 'aksrt')] as $key => $label) {
|
||||
$this->stat_card($label, (string) ($stats[$key] ?? 0), $this->page_url(self::PAGE_SLUG, ['tab' => 'orders']));
|
||||
}
|
||||
echo '</div><h2>' . esc_html__('按商品类型统计', 'aksrt') . '</h2><table class="widefat striped" style="max-width:640px;"><tbody>';
|
||||
foreach ((array) ($stats['by_type'] ?? []) as $type => $count) echo '<tr><th>' . esc_html(\AKSRT\Core\DomainModel::order_product_type_labels()[$type] ?? $type) . '</th><td>' . esc_html((string) $count) . '</td></tr>';
|
||||
echo '</tbody></table>';
|
||||
}
|
||||
|
||||
private function render_after_sales_page(): void {
|
||||
$service = \AKSRT\Payment\AfterSaleService::get_instance();
|
||||
$status = isset($_GET['after_sale_status']) ? sanitize_key((string) wp_unslash($_GET['after_sale_status'])) : '';
|
||||
$date_from = isset($_GET['date_from']) ? sanitize_text_field((string) wp_unslash($_GET['date_from'])) : '';
|
||||
$date_to = isset($_GET['date_to']) ? sanitize_text_field((string) wp_unslash($_GET['date_to'])) : '';
|
||||
$date_from = preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_from) ? $date_from : '';
|
||||
$date_to = preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_to) ? $date_to : '';
|
||||
$filter_args = ['status' => $status, 'date_from' => $date_from, 'date_to' => $date_to];
|
||||
$items = $service->admin_list($filter_args + ['limit' => 100]);
|
||||
echo '<form method="get"><input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '" /><input type="hidden" name="tab" value="after-sales" />';
|
||||
echo '<select name="after_sale_status"><option value="">' . esc_html__('全部售后状态', 'aksrt') . '</option>';
|
||||
foreach ([\AKSRT\Payment\AfterSaleService::STATUS_PENDING => '待审核', \AKSRT\Payment\AfterSaleService::STATUS_PROCESSING => '处理中', \AKSRT\Payment\AfterSaleService::STATUS_APPROVED => '已通过', \AKSRT\Payment\AfterSaleService::STATUS_REJECTED => '已拒绝', \AKSRT\Payment\AfterSaleService::STATUS_CANCELLED => '已取消', \AKSRT\Payment\AfterSaleService::STATUS_COMPLETED => '已完成'] as $key => $label) echo '<option value="' . esc_attr($key) . '"' . selected($status, $key, false) . '>' . esc_html($label) . '</option>';
|
||||
echo '</select> <label>' . esc_html__('开始日期', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr($date_from) . '" /></label> <label>' . esc_html__('结束日期', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr($date_to) . '" /></label> ';
|
||||
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
|
||||
echo '</form><table class="widefat striped"><thead><tr><th>ID</th><th>订单号</th><th>用户</th><th>原因</th><th>退款金额</th><th>状态</th><th>操作 / 处理记录</th></tr></thead><tbody>';
|
||||
if (!$items) echo '<tr><td colspan="7">' . esc_html__('暂无售后申请', 'aksrt') . '</td></tr>';
|
||||
foreach ($items as $item) {
|
||||
$id = (int) $item['id']; $number = (string) $item['order_number']; $uid = (int) $item['user_id'];
|
||||
$user = $uid > 0 ? get_userdata($uid) : null;
|
||||
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($number);
|
||||
$type = $order ? \AKSRT\Core\DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])) : '';
|
||||
$type_label = \AKSRT\Core\DomainModel::order_product_type_labels()[$type] ?? $type;
|
||||
$status_labels = [\AKSRT\Payment\AfterSaleService::STATUS_PENDING => '待审核', \AKSRT\Payment\AfterSaleService::STATUS_PROCESSING => '处理中', \AKSRT\Payment\AfterSaleService::STATUS_APPROVED => '已通过', \AKSRT\Payment\AfterSaleService::STATUS_REJECTED => '已拒绝', \AKSRT\Payment\AfterSaleService::STATUS_CANCELLED => '已取消', \AKSRT\Payment\AfterSaleService::STATUS_COMPLETED => '已完成'];
|
||||
$refund_amount = (string) ($item['refund_amount'] ?? '0');
|
||||
if ($order && (string) ($order['currency'] ?? '') === 'points') $refund_amount .= ' ' . __('积分', 'aksrt'); else $refund_amount = '¥' . $refund_amount;
|
||||
echo '<tr><td>' . esc_html((string) $id) . '<br><small>' . esc_html((string) ($item['created_at'] ?? '')) . '</small></td><td><a href="' . esc_url($this->page_url(self::PAGE_SLUG, ['tab' => 'orders', 'order' => $number])) . '"><code>' . esc_html($number) . '</code></a><br><span class="description">' . esc_html($type_label) . ($order ? ' · ' . esc_html(\AKSRT\Payment\UserOrders::get_instance()->decorate_order_view($order, false)['product_name'] ?? '') : '') . '</span></td><td>' . esc_html($user ? $user->display_name . ' (#' . $uid . ')' : '#' . $uid) . '</td><td>' . esc_html((string) $item['reason']) . '</td><td>' . esc_html($refund_amount) . '</td><td>' . esc_html($status_labels[(string) $item['status']] ?? (string) $item['status']) . '</td><td>';
|
||||
if ((string) $item['status'] === 'pending') foreach (['approve' => '通过', 'reject' => '拒绝'] as $decision => $label) {
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline-block;vertical-align:top;margin-right:4px;"><input type="hidden" name="action" value="aksrt_after_sale_review" /><input type="hidden" name="after_sale_id" value="' . esc_attr((string) $id) . '" /><input type="hidden" name="decision" value="' . esc_attr($decision) . '" />'; wp_nonce_field('aksrt_after_sale_review_' . $id); echo '<textarea name="admin_note" rows="2" placeholder="' . esc_attr__('管理员备注', 'aksrt') . '"></textarea><br><button class="button button-small" type="submit">' . esc_html($label) . '</button></form>';
|
||||
}
|
||||
if ((string) $item['status'] === 'processing') {
|
||||
$gateway_result = json_decode((string) ($item['gateway_result'] ?? ''), true);
|
||||
$manual_refund = is_array($gateway_result) && sanitize_key((string) ($gateway_result['status'] ?? '')) === 'manual';
|
||||
foreach (($manual_refund ? ['complete' => '确认线下退款完成', 'reject' => '确认未退款并拒绝'] : ['approve' => '同步退款状态']) as $decision => $label) {
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline-block;margin-right:4px;"><input type="hidden" name="action" value="aksrt_after_sale_review" /><input type="hidden" name="after_sale_id" value="' . esc_attr((string) $id) . '" /><input type="hidden" name="decision" value="' . esc_attr($decision) . '" />'; wp_nonce_field('aksrt_after_sale_review_' . $id); echo '<button class="button button-small" type="submit">' . esc_html__($label, 'aksrt') . '</button></form>';
|
||||
}
|
||||
}
|
||||
if (!empty($item['admin_note'])) echo '<small>' . esc_html__('备注:', 'aksrt') . esc_html((string) $item['admin_note']) . '</small><br>';
|
||||
if (!empty($item['gateway_result'])) {
|
||||
$result_data = json_decode((string) $item['gateway_result'], true);
|
||||
$result_text = is_array($result_data) ? implode(' · ', array_filter([(string) ($result_data['status'] ?? ''), (string) ($result_data['message'] ?? ''), !empty($result_data['refund_id']) ? '退款单号 ' . (string) $result_data['refund_id'] : ''])) : (string) $item['gateway_result'];
|
||||
echo '<small>' . esc_html__('渠道结果:', 'aksrt') . ' ' . esc_html($result_text) . '</small>';
|
||||
}
|
||||
echo '</td></tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
}
|
||||
|
||||
public function handle_after_sale_review(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
$id = isset($_POST['after_sale_id']) ? absint($_POST['after_sale_id']) : 0;
|
||||
check_admin_referer('aksrt_after_sale_review_' . $id);
|
||||
$decision = isset($_POST['decision']) ? sanitize_key((string) wp_unslash($_POST['decision'])) : '';
|
||||
$note = isset($_POST['admin_note']) ? sanitize_textarea_field((string) wp_unslash($_POST['admin_note'])) : '';
|
||||
$result = \AKSRT\Payment\AfterSaleService::get_instance()->review($id, $decision, $note);
|
||||
$args = ['tab' => 'after-sales', 'aksrt_pay_msg' => is_wp_error($result) ? 'after_sale_review_failed' : 'after_sale_reviewed'];
|
||||
if (is_wp_error($result)) $args['after_sale_error'] = substr($result->get_error_message(), 0, 240);
|
||||
wp_safe_redirect($this->page_url(self::PAGE_SLUG, $args)); exit;
|
||||
}
|
||||
|
||||
public function handle_update_order_status(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
@@ -417,17 +544,14 @@ class PayOrders {
|
||||
}
|
||||
|
||||
private function render_order_quick_status_buttons(string $order_number, string $current_status, bool $redirect_detail = false): void {
|
||||
$labels = [
|
||||
'paid' => __('确认收款', 'aksrt'),
|
||||
'refunding' => __('发起退款', 'aksrt'),
|
||||
'refunded' => __('确认退款', 'aksrt'),
|
||||
'closed' => __('关闭订单', 'aksrt'),
|
||||
];
|
||||
$labels = [
|
||||
'paid' => __('确认收款', 'aksrt'),
|
||||
'closed' => __('关闭订单', 'aksrt'),
|
||||
];
|
||||
$transitions = \AKSRT\Core\DomainModel::order_transitions();
|
||||
foreach ((array) ($transitions[$current_status] ?? []) as $status) {
|
||||
$label = $status === 'paid' && $current_status === 'refunding'
|
||||
? __('取消退款', 'aksrt')
|
||||
: ($labels[$status] ?? $status);
|
||||
if ($status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED) continue;
|
||||
$label = $labels[$status] ?? $status;
|
||||
$confirm = sprintf(__('确认将订单状态改为“%s”?', 'aksrt'), $label);
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline;">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_update_order_status" />';
|
||||
@@ -513,13 +637,18 @@ class PayOrders {
|
||||
'shipping_created' => __('发货记录已创建。', 'aksrt'),
|
||||
'shipping_create_failed' => __('发货记录创建失败。', 'aksrt'),
|
||||
'after_sale_created' => __('售后工单已创建。', 'aksrt'),
|
||||
'after_sale_create_failed' => __('售后工单创建失败。', 'aksrt'),
|
||||
'after_sale_create_failed' => __('售后工单创建失败。', 'aksrt'),
|
||||
'after_sale_reviewed' => __('售后审核已处理。', 'aksrt'),
|
||||
'after_sale_review_failed' => __('售后审核失败,请查看申请状态和错误原因。', 'aksrt'),
|
||||
];
|
||||
if (!isset($map[$code])) {
|
||||
return;
|
||||
}
|
||||
$class = str_contains($code, 'failed') ? 'notice-error' : 'notice-success';
|
||||
echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($map[$code]) . '</p></div>';
|
||||
if (!isset($map[$code])) {
|
||||
return;
|
||||
}
|
||||
$class = str_contains($code, 'failed') ? 'notice-error' : 'notice-success';
|
||||
echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($map[$code]) . '</p></div>';
|
||||
if ($code === 'after_sale_review_failed' && isset($_GET['after_sale_error'])) {
|
||||
echo '<div class="notice notice-error"><p>' . esc_html(sanitize_text_field((string) wp_unslash($_GET['after_sale_error']))) . '</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
private function page_url(string $slug, array $args = []): string {
|
||||
|
||||
@@ -68,7 +68,6 @@ class SchemaPayment {
|
||||
['key' => 'status', 'title' => '状态设置', 'fields' => [
|
||||
['key' => 'payment_order_status_pending', 'label' => '待支付状态文案', 'type' => 'input', 'default' => '待支付'],
|
||||
['key' => 'payment_order_status_paid', 'label' => '已支付状态文案', 'type' => 'input', 'default' => '已支付'],
|
||||
['key' => 'payment_order_status_refunding', 'label' => '退款中状态文案', 'type' => 'input', 'default' => '退款中'],
|
||||
['key' => 'payment_order_status_refunded', 'label' => '已退款状态文案', 'type' => 'input', 'default' => '已退款'],
|
||||
['key' => 'payment_order_status_closed', 'label' => '已关闭状态文案', 'type' => 'input', 'default' => '已关闭'],
|
||||
]],
|
||||
|
||||
@@ -140,16 +140,10 @@ class UserController extends ControllerBase {
|
||||
'callback' => [$this, 'rest_get_credits_log'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => [
|
||||
'transaction_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'operation_key' => ['type' => 'string', 'maxLength' => 191, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'user_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'actor_user_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'asset' => ['type' => 'string', 'enum' => ['points', 'balance']],
|
||||
'reason' => ['type' => 'string', 'maxLength' => 64, 'sanitize_callback' => 'sanitize_key'],
|
||||
'ref_type' => ['type' => 'string', 'maxLength' => 32, 'sanitize_callback' => 'sanitize_key'],
|
||||
'ref_key' => ['type' => 'string', 'maxLength' => 191, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'user_id' => ['type' => 'integer', 'minimum' => 1, 'required' => true],
|
||||
'asset' => ['type' => 'string', 'enum' => ['points', 'balance'], 'required' => true],
|
||||
'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
|
||||
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20],
|
||||
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 50, 'default' => 10],
|
||||
'date_from' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'date_to' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
|
||||
],
|
||||
@@ -589,22 +583,36 @@ class UserController extends ControllerBase {
|
||||
}
|
||||
|
||||
public function rest_list_credit_users(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$args = [
|
||||
'search' => (string) $request->get_param('search'),
|
||||
'page' => (int) $request->get_param('page'),
|
||||
'per_page' => (int) $request->get_param('per_page'),
|
||||
];
|
||||
$asset = sanitize_key((string) $request->get_param('asset'));
|
||||
$asset = in_array($asset, ['points', 'balance'], true) ? $asset : 'points';
|
||||
$data = \AKSRT\Ledger\LedgerQuery::get_instance()->list_accounts($asset, $args);
|
||||
foreach ($data['items'] as &$item) {
|
||||
$item['asset'] = $asset;
|
||||
$item['balance'] = $asset === 'balance'
|
||||
? \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, (int) $item['balance_minor'])->decimal()
|
||||
: (int) $item['balance_minor'];
|
||||
unset($item['balance_minor']);
|
||||
global $wpdb;
|
||||
$page = max(1, (int) $request->get_param('page'));
|
||||
$per_page = min(100, max(1, (int) $request->get_param('per_page')));
|
||||
$search = trim(sanitize_text_field((string) $request->get_param('search')));
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($search !== '') {
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$where[] = '(u.user_login LIKE %s OR u.user_email LIKE %s OR u.display_name LIKE %s' . (ctype_digit($search) ? ' OR u.ID = %d' : '') . ')';
|
||||
array_push($params, $like, $like, $like);
|
||||
if (ctype_digit($search)) $params[] = (int) $search;
|
||||
}
|
||||
unset($item);
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
$total_sql = "SELECT COUNT(*) FROM {$wpdb->users} u {$where_sql}";
|
||||
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($total_sql, $params)) : $wpdb->get_var($total_sql));
|
||||
$sql = "SELECT u.ID AS id, u.user_login AS login, u.user_email AS email, u.display_name, u.user_registered AS registered,
|
||||
(SELECT m.meta_value FROM {$wpdb->usermeta} m WHERE m.user_id = u.ID AND m.meta_key = 'balance' ORDER BY m.umeta_id ASC LIMIT 1) AS balance_value,
|
||||
(SELECT m.meta_value FROM {$wpdb->usermeta} m WHERE m.user_id = u.ID AND m.meta_key = 'points' ORDER BY m.umeta_id ASC LIMIT 1) AS points_value
|
||||
FROM {$wpdb->users} u {$where_sql} ORDER BY u.user_registered DESC, u.ID DESC LIMIT %d OFFSET %d";
|
||||
$rows = $wpdb->get_results($wpdb->prepare($sql, array_merge($params, [$per_page, ($page - 1) * $per_page])), ARRAY_A);
|
||||
$items = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
try { $balance = \AKSRT\Ledger\AssetAmount::balance((string) ($row['balance_value'] ?? '0'))->decimal(); } catch (\InvalidArgumentException $e) { $balance = '0.00'; }
|
||||
$items[] = [
|
||||
'id' => (int) $row['id'], 'login' => (string) $row['login'], 'email' => (string) $row['email'],
|
||||
'display_name' => (string) $row['display_name'], 'registered' => (string) $row['registered'],
|
||||
'balance' => $balance, 'points' => max(0, (int) ($row['points_value'] ?? 0)),
|
||||
];
|
||||
}
|
||||
$data = ['items' => $items, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
|
||||
return $this->success(['data' => $data]);
|
||||
}
|
||||
|
||||
@@ -653,64 +661,58 @@ class UserController extends ControllerBase {
|
||||
'balance' => $asset === 'balance'
|
||||
? \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($user_id, $asset))->decimal()
|
||||
: $result->balance($user_id, $asset),
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'replayed' => $result->replayed,
|
||||
], 'message' => __('资产调整已保存', 'aksrt')]);
|
||||
}
|
||||
|
||||
public function rest_get_credits_log(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$args = [
|
||||
'transaction_id' => (int) $request->get_param('transaction_id'),
|
||||
'operation_key' => (string) $request->get_param('operation_key'),
|
||||
'user_id' => (int) $request->get_param('user_id'),
|
||||
'actor_user_id' => (int) $request->get_param('actor_user_id'),
|
||||
'reason' => (string) $request->get_param('reason'),
|
||||
'ref_type' => (string) $request->get_param('ref_type'),
|
||||
'ref_key' => (string) $request->get_param('ref_key'),
|
||||
'page' => (int) $request->get_param('page'),
|
||||
'per_page' => (int) $request->get_param('per_page'),
|
||||
'date_from' => (string) $request->get_param('date_from'),
|
||||
'date_to' => (string) $request->get_param('date_to'),
|
||||
];
|
||||
$args['asset'] = sanitize_key((string) $request->get_param('asset'));
|
||||
$data = \AKSRT\Ledger\LedgerQuery::get_instance()->history($args);
|
||||
foreach ($data['items'] as &$item) {
|
||||
$assets = array_values(array_unique(array_map('strval', (array) ($item['assets'] ?? []))));
|
||||
$single_asset = count($assets) === 1 ? $assets[0] : '';
|
||||
if ($single_asset !== '') {
|
||||
$item['delta'] = $this->asset_minor_for_response($single_asset, (int) ($item['delta_minor'] ?? 0), true);
|
||||
$item['fee'] = $this->asset_minor_for_response($single_asset, (int) ($item['fee_minor'] ?? 0));
|
||||
$item['balance_after'] = $this->asset_minor_for_response($single_asset, (int) ($item['balance_after_minor'] ?? 0));
|
||||
} else {
|
||||
$item['delta'] = null;
|
||||
$item['fee'] = null;
|
||||
$item['balance_after'] = null;
|
||||
}
|
||||
unset($item['delta_minor'], $item['fee_minor'], $item['balance_after_minor']);
|
||||
foreach ($item['entries'] as &$entry) {
|
||||
$entry_asset = (string) ($entry['asset'] ?? '');
|
||||
$entry['delta'] = $this->asset_minor_for_response($entry_asset, (int) ($entry['delta_minor'] ?? 0), true);
|
||||
$entry['balance_after'] = $this->asset_minor_for_response($entry_asset, (int) ($entry['balance_after_minor'] ?? 0));
|
||||
unset($entry['delta_minor'], $entry['balance_after_minor']);
|
||||
}
|
||||
unset($entry);
|
||||
$user_id = max(0, (int) $request->get_param('user_id'));
|
||||
$asset = sanitize_key((string) $request->get_param('asset'));
|
||||
if ($user_id <= 0 || !get_userdata($user_id) || !in_array($asset, ['points', 'balance'], true)) {
|
||||
return $this->error(__('用户或资产类型无效', 'aksrt'), 400);
|
||||
}
|
||||
unset($item);
|
||||
$meta_key = $asset === 'points' ? 'points_record' : 'balance_record';
|
||||
$records = get_user_meta($user_id, $meta_key, true);
|
||||
$records = is_array($records) ? array_slice($records, 0, 50) : [];
|
||||
$date_from = sanitize_text_field((string) $request->get_param('date_from'));
|
||||
$date_to = sanitize_text_field((string) $request->get_param('date_to'));
|
||||
$records = array_values(array_filter($records, static function ($record) use ($date_from, $date_to): bool {
|
||||
if (!is_array($record)) return false;
|
||||
$day = substr((string) ($record['time'] ?? ''), 0, 10);
|
||||
return ($date_from === '' || $day >= $date_from) && ($date_to === '' || $day <= $date_to);
|
||||
}));
|
||||
$page = max(1, (int) $request->get_param('page'));
|
||||
$per_page = min(50, max(1, (int) $request->get_param('per_page')));
|
||||
$items = [];
|
||||
foreach (array_slice($records, ($page - 1) * $per_page, $per_page) as $index => $record) {
|
||||
$value = $asset === 'points' ? (int) ($record['value'] ?? 0) : $this->asset_value_to_minor((string) ($record['value'] ?? '0'));
|
||||
$after = $asset === 'points' ? (int) ($record['points'] ?? 0) : $this->asset_value_to_minor((string) ($record['balance'] ?? '0'));
|
||||
$items[] = [
|
||||
'id' => (($page - 1) * $per_page) + $index + 1,
|
||||
'operation_key' => (string) ($record['operation_key'] ?? ''),
|
||||
'reason' => (string) ($record['type'] ?? ''),
|
||||
'ref_type' => !empty($record['order_num']) ? 'order' : '',
|
||||
'ref_key' => (string) ($record['order_num'] ?? ''),
|
||||
'actor_user_id' => 0,
|
||||
'assets' => [$asset],
|
||||
'created_at' => (string) ($record['time'] ?? ''),
|
||||
'remark' => (string) ($record['desc'] ?? ''),
|
||||
'entries' => [[
|
||||
'user_id' => $user_id, 'asset' => $asset, 'entry_type' => $value < 0 ? 'debit' : 'credit',
|
||||
'delta' => $value, 'balance_after' => $after, 'remark' => (string) ($record['desc'] ?? ''),
|
||||
]],
|
||||
];
|
||||
}
|
||||
$data = ['items' => $items, 'total' => count($records), 'page' => $page, 'per_page' => $per_page];
|
||||
return $this->success(['data' => $data]);
|
||||
}
|
||||
|
||||
private function asset_minor_for_response(string $asset, int $minor, bool $signed = false) {
|
||||
if ($asset === \AKSRT\Ledger\AssetType::POINTS->value) {
|
||||
return $minor;
|
||||
}
|
||||
if ($asset !== \AKSRT\Ledger\AssetType::BALANCE->value) {
|
||||
return null;
|
||||
}
|
||||
$negative = $minor < 0;
|
||||
$value = \AKSRT\Ledger\AssetAmount::decimal_from_minor(abs($minor));
|
||||
if ($negative) {
|
||||
return '-' . $value;
|
||||
}
|
||||
return $signed && $minor > 0 ? '+' . $value : $value;
|
||||
private function asset_value_to_minor(string $value): int {
|
||||
$value = trim($value);
|
||||
$negative = str_starts_with($value, '-');
|
||||
if ($negative) $value = substr($value, 1);
|
||||
try { $minor = \AKSRT\Ledger\AssetAmount::balance($value)->minor(); } catch (\InvalidArgumentException $e) { return 0; }
|
||||
return $negative ? -$minor : $minor;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+18
-23
@@ -19,7 +19,7 @@ trait PayOrders {
|
||||
*/
|
||||
public function fulfill_paid_order(string $order_number, array $paid_order) {
|
||||
$product_type = sanitize_key((string) ($paid_order['product_type'] ?? ''));
|
||||
if ($product_type !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
|
||||
if (!\AKSRT\Core\DomainModel::is_resource_product_type($product_type)) {
|
||||
return true;
|
||||
}
|
||||
$post_id = (int) ($paid_order['product_id'] ?? 0);
|
||||
@@ -131,7 +131,8 @@ trait PayOrders {
|
||||
return $assert;
|
||||
}
|
||||
|
||||
$priced = $assert['priced'];
|
||||
$priced = $assert['priced'];
|
||||
$content_type = \AKSRT\Core\DomainModel::resolve_order_product_type(\AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE, ['pay_type' => $assert['meta']['pay_type'] ?? '']);
|
||||
if ((($priced['modo'] ?? '0') !== 'points')) {
|
||||
return new \WP_Error('aksrt_wrong_modo', __('该内容不支持积分支付。', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
@@ -164,6 +165,7 @@ trait PayOrders {
|
||||
|
||||
$note = wp_json_encode([
|
||||
'content_pay' => 1,
|
||||
'pay_type' => (string) ($assert['meta']['pay_type'] ?? ''),
|
||||
'pay_modo' => 'points',
|
||||
'post_id' => $post_id,
|
||||
'price_type' => $price_type,
|
||||
@@ -178,7 +180,7 @@ trait PayOrders {
|
||||
if (is_array($existing)) {
|
||||
$existing_meta = $this->decode_order_note_meta($existing);
|
||||
if ((int) ($existing['user_id'] ?? 0) !== $user_id
|
||||
|| sanitize_key((string) ($existing['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE
|
||||
|| sanitize_key((string) ($existing['product_type'] ?? '')) !== $content_type
|
||||
|| (int) ($existing['product_id'] ?? 0) !== $post_id
|
||||
|| (string) ($existing['currency'] ?? '') !== 'points'
|
||||
|| (int) ($existing['amount'] ?? 0) !== $amount
|
||||
@@ -224,12 +226,12 @@ trait PayOrders {
|
||||
sprintf(__('积分购买《%s》', 'aksrt'), get_the_title($post_id)),
|
||||
$user_id,
|
||||
['post_id' => $post_id],
|
||||
static function () use ($is_new_order, $repo, $order_number, $operation_key, $user_id, $post_id, $amount, $note) {
|
||||
static function () use ($is_new_order, $repo, $order_number, $operation_key, $user_id, $post_id, $amount, $note, $content_type) {
|
||||
if ($is_new_order) {
|
||||
$created = $repo->create([
|
||||
'order_number' => $order_number,
|
||||
'user_id' => $user_id,
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
|
||||
'product_type' => $content_type,
|
||||
'product_id' => $post_id,
|
||||
'amount' => $amount,
|
||||
'currency' => 'points',
|
||||
@@ -245,7 +247,6 @@ trait PayOrders {
|
||||
}
|
||||
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
||||
'payment_method' => 'points',
|
||||
'payment_transaction_id' => 'points_' . $order_number,
|
||||
'note' => sprintf(__('积分支付 %d 积分。', 'aksrt'), $amount),
|
||||
'require_transition' => true,
|
||||
]);
|
||||
@@ -264,9 +265,7 @@ trait PayOrders {
|
||||
'amount' => $amount,
|
||||
'pay_modo' => 'points',
|
||||
'balance' => $ledger->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
|
||||
'transaction_id' => $deducted->transaction_id,
|
||||
'flow_number' => $deducted->flow_number,
|
||||
'replayed' => $deducted->replayed,
|
||||
'replayed' => $deducted->replayed,
|
||||
'message' => __('支付成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
],
|
||||
@@ -289,6 +288,7 @@ trait PayOrders {
|
||||
$post = $assert['post'];
|
||||
$meta = $assert['meta'];
|
||||
$priced = $assert['priced'];
|
||||
$content_type = \AKSRT\Core\DomainModel::resolve_order_product_type(\AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE, ['pay_type' => $meta['pay_type'] ?? '']);
|
||||
if ((($priced['modo'] ?? '0') === 'points')) {
|
||||
return new \WP_Error('aksrt_wrong_modo', __('该内容仅支持积分支付。', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
@@ -331,6 +331,7 @@ trait PayOrders {
|
||||
}
|
||||
$note = wp_json_encode([
|
||||
'content_pay' => 1,
|
||||
'pay_type' => (string) ($meta['pay_type'] ?? ''),
|
||||
'pay_modo' => '0',
|
||||
'post_id' => $post_id,
|
||||
'price_type' => $price_type,
|
||||
@@ -355,7 +356,7 @@ trait PayOrders {
|
||||
$replayed = is_array($existing);
|
||||
if (is_array($existing)) {
|
||||
if ((int) ($existing['user_id'] ?? 0) !== max(0, $user_id)
|
||||
|| sanitize_key((string) ($existing['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE
|
||||
|| sanitize_key((string) ($existing['product_type'] ?? '')) !== $content_type
|
||||
|| (int) ($existing['product_id'] ?? 0) !== $post_id
|
||||
|| sanitize_key((string) ($existing['payment_method'] ?? '')) !== 'free'
|
||||
|| (string) ($existing['amount'] ?? '') !== '0.00'
|
||||
@@ -366,7 +367,7 @@ trait PayOrders {
|
||||
} else {
|
||||
$order_number = $repo->create([
|
||||
'user_id' => max(0, $user_id),
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
|
||||
'product_type' => $content_type,
|
||||
'product_id' => $post_id,
|
||||
'amount' => '0.00',
|
||||
'currency' => 'CNY',
|
||||
@@ -384,7 +385,7 @@ trait PayOrders {
|
||||
if ($status !== \AKSRT\Payment\Order::STATUS_PAID) {
|
||||
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
||||
'payment_method' => 'free',
|
||||
'payment_transaction_id' => 'free_' . $order_number,
|
||||
'payment_transaction_id' => '',
|
||||
'note' => __('优惠后无需支付。', 'aksrt'),
|
||||
]);
|
||||
if (!$paid) {
|
||||
@@ -398,7 +399,7 @@ trait PayOrders {
|
||||
'amount' => '0.00',
|
||||
'pay_modo' => '0',
|
||||
'free' => true,
|
||||
'transaction_id' => 'free_' . $order_number,
|
||||
'transaction_id' => '',
|
||||
'replayed' => $replayed,
|
||||
'message' => __('无需支付,已可访问。', 'aksrt'),
|
||||
'reload' => true,
|
||||
@@ -430,7 +431,7 @@ trait PayOrders {
|
||||
$pending_coupon = (string) ($pending_meta['coupon']['code'] ?? $pending_coupon);
|
||||
}
|
||||
$can_resume = (int) ($pending['user_id'] ?? 0) === max(0, $user_id)
|
||||
&& sanitize_key((string) ($pending['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE
|
||||
&& sanitize_key((string) ($pending['product_type'] ?? '')) === $content_type
|
||||
&& (int) ($pending['product_id'] ?? 0) === $post_id
|
||||
&& (string) ($pending['amount'] ?? '') === $amount_decimal
|
||||
&& strtoupper((string) ($pending['currency'] ?? '')) === 'CNY'
|
||||
@@ -448,6 +449,7 @@ trait PayOrders {
|
||||
} else {
|
||||
$note = wp_json_encode([
|
||||
'content_pay' => 1,
|
||||
'pay_type' => (string) ($meta['pay_type'] ?? ''),
|
||||
'pay_modo' => '0',
|
||||
'post_id' => $post_id,
|
||||
'price_type' => $price_type,
|
||||
@@ -469,7 +471,7 @@ trait PayOrders {
|
||||
|
||||
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
|
||||
'user_id' => max(0, $user_id),
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
|
||||
'product_type' => $content_type,
|
||||
'product_id' => $post_id,
|
||||
'amount' => $amount_decimal,
|
||||
'currency' => 'CNY',
|
||||
@@ -495,16 +497,12 @@ trait PayOrders {
|
||||
$payment_method = $user_id > 0 ? 'balance' : 'alipay';
|
||||
}
|
||||
if ($can_resume && (string) ($order['status'] ?? '') === \AKSRT\Payment\Order::STATUS_PAID) {
|
||||
$ledger_key = \AKSRT\Ledger\OperationKey::deterministic('order-payment', [$order_number]);
|
||||
$history = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['operation_key' => $ledger_key, 'page' => 1, 'per_page' => 1]);
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order_number' => $order_number,
|
||||
'amount' => $amount_decimal,
|
||||
'pay_modo' => '0',
|
||||
'transaction_id' => (int) ($history['items'][0]['id'] ?? 0),
|
||||
'flow_number' => \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number),
|
||||
'replayed' => true,
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => __('支付成功', 'aksrt'),
|
||||
@@ -559,7 +557,6 @@ trait PayOrders {
|
||||
'message' => $payment_result['message'],
|
||||
'reload' => false,
|
||||
'poll' => true,
|
||||
'transaction_id' => 0,
|
||||
'replayed' => false,
|
||||
],
|
||||
];
|
||||
@@ -571,7 +568,6 @@ trait PayOrders {
|
||||
'order_number' => $order_number,
|
||||
'amount' => $amount_decimal,
|
||||
'pay_modo' => '0',
|
||||
'transaction_id' => (int) ($payment_result['ledger_transaction_id'] ?? 0),
|
||||
'replayed' => !empty($payment_result['replayed']),
|
||||
'balance' => $user_id > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : '0.00',
|
||||
'message' => $payment_result['message'],
|
||||
@@ -590,13 +586,12 @@ trait PayOrders {
|
||||
return null;
|
||||
}
|
||||
$orders = \AKSRT\Payment\OrderRepository::get_instance()->get_user_orders($user_id, [
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
|
||||
'status' => \AKSRT\Core\DomainModel::ORDER_STATUS_PENDING,
|
||||
'limit' => 20,
|
||||
'offset' => 0,
|
||||
]);
|
||||
foreach ($orders as $order) {
|
||||
if ((int) ($order['product_id'] ?? 0) === $post_id) {
|
||||
if ((int) ($order['product_id'] ?? 0) === $post_id && \AKSRT\Core\DomainModel::is_resource_product_type((string) ($order['product_type'] ?? ''))) {
|
||||
return $order;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,9 +179,7 @@ trait PayRest {
|
||||
$order = $repo->get($order_number);
|
||||
// Guest may only poll own guest cash orders (user_id=0): content resource.
|
||||
$ptype = sanitize_key((string) ($order['product_type'] ?? ''));
|
||||
$guest_types = [
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
|
||||
];
|
||||
$guest_types = \AKSRT\Core\DomainModel::resource_order_product_types();
|
||||
if ($order && ((int) ($order['user_id'] ?? -1) !== 0 || !in_array($ptype, $guest_types, true))) {
|
||||
$order = null;
|
||||
}
|
||||
|
||||
@@ -411,8 +411,6 @@ trait VipServicePurchase {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
if (is_array($existing) && (string) ($existing['status'] ?? '') === \AKSRT\Payment\Order::STATUS_PAID) {
|
||||
$ledger_key = \AKSRT\Ledger\OperationKey::deterministic('order-payment', [$order_number]);
|
||||
$history = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['operation_key' => $ledger_key, 'page' => 1, 'per_page' => 1]);
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
@@ -421,8 +419,6 @@ trait VipServicePurchase {
|
||||
'level' => $level,
|
||||
'days' => $days,
|
||||
'action' => $action,
|
||||
'transaction_id' => (int) ($history['items'][0]['id'] ?? 0),
|
||||
'flow_number' => \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number),
|
||||
'replayed' => true,
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => __('会员购买已完成', 'aksrt'),
|
||||
@@ -491,7 +487,6 @@ trait VipServicePurchase {
|
||||
'message' => $payment_result['message'],
|
||||
'reload' => false,
|
||||
'poll' => true,
|
||||
'transaction_id' => 0,
|
||||
'replayed' => false,
|
||||
],
|
||||
];
|
||||
@@ -505,7 +500,6 @@ trait VipServicePurchase {
|
||||
'level' => $level,
|
||||
'days' => $days,
|
||||
'action' => $action,
|
||||
'transaction_id' => (int) ($payment_result['ledger_transaction_id'] ?? 0),
|
||||
'replayed' => !empty($payment_result['replayed']),
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => $action === 'renew'
|
||||
@@ -549,6 +543,7 @@ trait VipServicePurchase {
|
||||
if (!empty($meta['card_pass']) || sanitize_key((string) ($meta['vip_pay_type'] ?? '')) === 'card_pass') {
|
||||
return true;
|
||||
}
|
||||
$vip_before = $this->get_membership_snapshot($user_id);
|
||||
|
||||
$level = (int) ($meta['level'] ?? 0);
|
||||
$days = (int) ($meta['days'] ?? 0);
|
||||
@@ -611,9 +606,16 @@ trait VipServicePurchase {
|
||||
$fulfilled = (int) get_user_meta($user_id, 'aksrt_vip_level', true) >= max($previous_level, $level)
|
||||
&& ($new_expiry === 0 || $days <= 0 || $new_expiry >= max($previous_expiry, time()) + ($days * DAY_IN_SECONDS));
|
||||
}
|
||||
if (!$fulfilled) {
|
||||
if (!$fulfilled) {
|
||||
return new \WP_Error('aksrt_vip_fulfillment_failed', __('会员权益发放失败。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$vip_after = $this->get_membership_snapshot($user_id);
|
||||
if (!\AKSRT\Payment\OrderRepository::get_instance()->merge_meta($order_number, [
|
||||
'vip_before' => $vip_before,
|
||||
'vip_after' => $vip_after,
|
||||
])) {
|
||||
return new \WP_Error('aksrt_vip_snapshot_failed', __('会员订单快照保存失败。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -860,7 +862,6 @@ trait VipServicePurchase {
|
||||
}
|
||||
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
||||
'payment_method' => 'points',
|
||||
'payment_transaction_id' => 'points_' . $order_number,
|
||||
'note' => sprintf(__('积分兑换会员 %d 积分。', 'aksrt'), $cost),
|
||||
'require_transition' => true,
|
||||
]);
|
||||
@@ -880,9 +881,7 @@ trait VipServicePurchase {
|
||||
'level' => $level,
|
||||
'days' => $days,
|
||||
'balance' => $ledger->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
|
||||
'transaction_id' => $deducted->transaction_id,
|
||||
'flow_number' => $deducted->flow_number,
|
||||
'replayed' => $deducted->replayed,
|
||||
'replayed' => $deducted->replayed,
|
||||
'message' => __('兑换成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
],
|
||||
|
||||
@@ -14,7 +14,7 @@ class DBMigrator {
|
||||
use DBMigratorVip;
|
||||
|
||||
/** Current schema version; changing it re-runs dbDelta. */
|
||||
const SCHEMA_VERSION = '2.1.0';
|
||||
const SCHEMA_VERSION = '2.5.0';
|
||||
|
||||
const OPT_KEY = 'aksrt_db_schema_version';
|
||||
|
||||
|
||||
@@ -204,6 +204,29 @@ trait DBMigratorPart2 {
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_order);
|
||||
|
||||
// ---------- order after-sales ----------
|
||||
$sql_order_aftersale = "CREATE TABLE {$prefix}aksrt_order_aftersale (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
order_number VARCHAR(32) NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
aftersale_type VARCHAR(24) NOT NULL DEFAULT 'refund',
|
||||
reason TEXT NOT NULL,
|
||||
refund_amount VARCHAR(32) NOT NULL DEFAULT '0',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
admin_note TEXT DEFAULT NULL,
|
||||
gateway_request_id VARCHAR(128) DEFAULT NULL,
|
||||
gateway_refund_id VARCHAR(128) DEFAULT NULL,
|
||||
gateway_result LONGTEXT DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
reviewed_at DATETIME DEFAULT NULL,
|
||||
completed_at DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_order_status (order_number, status),
|
||||
KEY idx_user_created (user_id, created_at),
|
||||
KEY idx_status_created (status, created_at)
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_order_aftersale);
|
||||
|
||||
// ---------- 9. 作者收益台账 ----------
|
||||
$sql_revenue = "CREATE TABLE {$prefix}aksrt_revenue (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
@@ -246,12 +269,14 @@ trait DBMigratorPart2 {
|
||||
method VARCHAR(32) NOT NULL DEFAULT '',
|
||||
account_info TEXT DEFAULT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
operation_key VARCHAR(191) DEFAULT NULL,
|
||||
ledger_transaction_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
processed_at DATETIME DEFAULT NULL,
|
||||
note TEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_withdrawal_number (withdrawal_number),
|
||||
UNIQUE KEY uniq_withdrawal_operation (operation_key),
|
||||
UNIQUE KEY uniq_ledger_transaction (ledger_transaction_id),
|
||||
KEY idx_user (user_id),
|
||||
KEY idx_user_created (user_id, created_at),
|
||||
|
||||
@@ -25,6 +25,7 @@ trait DBMigratorPart3 {
|
||||
'dl_token' => 'aksrt_dl_token',
|
||||
'notification' => 'aksrt_notification',
|
||||
'order' => 'aksrt_order',
|
||||
'order_aftersale' => 'aksrt_order_aftersale',
|
||||
'order_item' => 'aksrt_order_item',
|
||||
'revenue' => 'aksrt_revenue',
|
||||
'withdrawal' => 'aksrt_withdrawal',
|
||||
|
||||
@@ -40,6 +40,10 @@ class DomainModel {
|
||||
];
|
||||
|
||||
public const ORDER_PRODUCT_RESOURCE = 'resource';
|
||||
public const ORDER_PRODUCT_READING = 'reading';
|
||||
public const ORDER_PRODUCT_DOWNLOAD = 'download';
|
||||
public const ORDER_PRODUCT_IMAGE = 'image';
|
||||
public const ORDER_PRODUCT_VIDEO = 'video';
|
||||
public const ORDER_PRODUCT_VIP = 'vip';
|
||||
public const ORDER_PRODUCT_RECHARGE = 'recharge';
|
||||
public const ORDER_PRODUCT_POINTS = 'points';
|
||||
@@ -51,13 +55,53 @@ class DomainModel {
|
||||
*/
|
||||
public static function order_product_type_labels(): array {
|
||||
return [
|
||||
self::ORDER_PRODUCT_RESOURCE => __('付费内容', 'aksrt'),
|
||||
self::ORDER_PRODUCT_VIP => __('VIP 会员', 'aksrt'),
|
||||
self::ORDER_PRODUCT_READING => __('付费阅读', 'aksrt'),
|
||||
self::ORDER_PRODUCT_DOWNLOAD => __('付费下载', 'aksrt'),
|
||||
self::ORDER_PRODUCT_IMAGE => __('付费图片', 'aksrt'),
|
||||
self::ORDER_PRODUCT_VIDEO => __('付费视频', 'aksrt'),
|
||||
self::ORDER_PRODUCT_VIP => __('购买会员', 'aksrt'),
|
||||
self::ORDER_PRODUCT_RECHARGE => __('余额充值', 'aksrt'),
|
||||
self::ORDER_PRODUCT_POINTS => __('积分购买', 'aksrt'),
|
||||
self::ORDER_PRODUCT_POINTS => __('购买积分', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function is_resource_product_type(string $type): bool {
|
||||
return in_array(sanitize_key($type), [
|
||||
self::ORDER_PRODUCT_RESOURCE,
|
||||
self::ORDER_PRODUCT_READING,
|
||||
self::ORDER_PRODUCT_DOWNLOAD,
|
||||
self::ORDER_PRODUCT_IMAGE,
|
||||
self::ORDER_PRODUCT_VIDEO,
|
||||
], true);
|
||||
}
|
||||
|
||||
/** Resolve new and historical paid-content order types from stored order meta. */
|
||||
public static function resolve_order_product_type(string $stored_type, array $meta = []): string {
|
||||
$stored_type = sanitize_key($stored_type);
|
||||
if ($stored_type !== self::ORDER_PRODUCT_RESOURCE) {
|
||||
return isset(self::order_product_type_labels()[$stored_type]) ? $stored_type : $stored_type;
|
||||
}
|
||||
$pay_type = sanitize_key((string) ($meta['pay_type'] ?? $meta['content_pay_type'] ?? ''));
|
||||
return match ($pay_type) {
|
||||
self::PAY_TYPE_READING => self::ORDER_PRODUCT_READING,
|
||||
self::PAY_TYPE_DOWNLOAD => self::ORDER_PRODUCT_DOWNLOAD,
|
||||
self::PAY_TYPE_GALLERY => self::ORDER_PRODUCT_IMAGE,
|
||||
self::PAY_TYPE_VIDEO => self::ORDER_PRODUCT_VIDEO,
|
||||
default => self::ORDER_PRODUCT_READING,
|
||||
};
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public static function resource_order_product_types(): array {
|
||||
return [
|
||||
self::ORDER_PRODUCT_RESOURCE,
|
||||
self::ORDER_PRODUCT_READING,
|
||||
self::ORDER_PRODUCT_DOWNLOAD,
|
||||
self::ORDER_PRODUCT_IMAGE,
|
||||
self::ORDER_PRODUCT_VIDEO,
|
||||
];
|
||||
}
|
||||
|
||||
public const PAY_TYPE_NO = 'no';
|
||||
public const PAY_TYPE_READING = 'reading';
|
||||
public const PAY_TYPE_DOWNLOAD = 'download';
|
||||
@@ -67,7 +111,6 @@ class DomainModel {
|
||||
|
||||
public const ORDER_STATUS_PENDING = 'pending';
|
||||
public const ORDER_STATUS_PAID = 'paid';
|
||||
public const ORDER_STATUS_REFUNDING = 'refunding';
|
||||
public const ORDER_STATUS_REFUNDED = 'refunded';
|
||||
public const ORDER_STATUS_CLOSED = 'closed';
|
||||
|
||||
@@ -454,7 +497,6 @@ class DomainModel {
|
||||
return [
|
||||
self::ORDER_STATUS_PENDING => __('待支付', 'aksrt'),
|
||||
self::ORDER_STATUS_PAID => __('已支付', 'aksrt'),
|
||||
self::ORDER_STATUS_REFUNDING => __('退款中', 'aksrt'),
|
||||
self::ORDER_STATUS_REFUNDED => __('已退款', 'aksrt'),
|
||||
self::ORDER_STATUS_CLOSED => __('已关闭', 'aksrt'),
|
||||
];
|
||||
@@ -470,11 +512,7 @@ class DomainModel {
|
||||
self::ORDER_STATUS_CLOSED,
|
||||
],
|
||||
self::ORDER_STATUS_PAID => [
|
||||
self::ORDER_STATUS_REFUNDING,
|
||||
],
|
||||
self::ORDER_STATUS_REFUNDING => [
|
||||
self::ORDER_STATUS_REFUNDED,
|
||||
self::ORDER_STATUS_PAID,
|
||||
],
|
||||
self::ORDER_STATUS_REFUNDED => [],
|
||||
self::ORDER_STATUS_CLOSED => [],
|
||||
@@ -500,6 +538,8 @@ class DomainModel {
|
||||
public static function paid_order_statuses(): array {
|
||||
return [
|
||||
self::ORDER_STATUS_PAID,
|
||||
// Read-only compatibility: normalize_row exposes legacy in-flight refunds as paid.
|
||||
'refunding',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -388,18 +388,11 @@ trait BalanceServiceCharge {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
if (is_array($existing) && (string) ($order['status'] ?? '') === \AKSRT\Payment\Order::STATUS_PAID) {
|
||||
$ledger_key = \AKSRT\Ledger\OperationKey::deterministic('recharge', [$order_number]);
|
||||
$history = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['operation_key' => $ledger_key, 'page' => 1, 'per_page' => 1]);
|
||||
$transaction_id = (int) ($history['items'][0]['id'] ?? 0);
|
||||
if ($transaction_id <= 0) {
|
||||
return new \WP_Error('aksrt_recharge_state', __('充值订单与账本状态不一致', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
return [
|
||||
'redirect' => false,
|
||||
'order_number' => $order_number,
|
||||
'amount' => $pay_decimal,
|
||||
'credit_amount' => $credit_decimal,
|
||||
'transaction_id' => $transaction_id,
|
||||
'replayed' => true,
|
||||
'wallet' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => __('充值成功', 'aksrt'),
|
||||
@@ -450,15 +443,11 @@ trait BalanceServiceCharge {
|
||||
}
|
||||
|
||||
// The synchronous gateway has already committed the recharge and order state.
|
||||
$ledger_key = \AKSRT\Ledger\OperationKey::deterministic('recharge', [$order_number]);
|
||||
$history = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['operation_key' => $ledger_key, 'page' => 1, 'per_page' => 1]);
|
||||
|
||||
return [
|
||||
'redirect' => false,
|
||||
'order_number' => $order_number,
|
||||
'amount' => $pay_decimal,
|
||||
'credit_amount' => $credit_decimal,
|
||||
'transaction_id' => (int) ($history['items'][0]['id'] ?? 0),
|
||||
'replayed' => false,
|
||||
'wallet' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => __('充值成功', 'aksrt'),
|
||||
|
||||
@@ -230,7 +230,6 @@ trait BalanceServiceRest {
|
||||
'data' => [
|
||||
'message' => __('转账成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'replayed' => $result->replayed,
|
||||
'balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($from_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(),
|
||||
],
|
||||
@@ -281,7 +280,6 @@ trait BalanceServiceRest {
|
||||
'data' => [
|
||||
'message' => __('转账成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'replayed' => $result->replayed,
|
||||
'balance' => $result->balance($from_id, \AKSRT\Ledger\AssetType::POINTS),
|
||||
],
|
||||
@@ -392,14 +390,15 @@ trait BalanceServiceRest {
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $operation_key;
|
||||
}
|
||||
$site_now = current_datetime();
|
||||
$start = (clone $site_now)->setTime(0, 0, 0);
|
||||
$end = (clone $start)->modify('+1 day');
|
||||
$utc = new \DateTimeZone('UTC');
|
||||
$start_gmt = $start->setTimezone($utc)->format('Y-m-d H:i:s.u');
|
||||
$end_gmt = $end->setTimezone($utc)->format('Y-m-d H:i:s.u');
|
||||
$guard = static function () use ($user_id, $points, $daily_max, $start_gmt, $end_gmt) {
|
||||
$used = \AKSRT\Ledger\LedgerQuery::get_instance()->sum_credits($user_id, 'points_exchange', $start_gmt, $end_gmt);
|
||||
$guard = static function () use ($user_id, $points, $daily_max) {
|
||||
$used = 0;
|
||||
$records = get_user_meta($user_id, 'points_record', true);
|
||||
$today = current_time('Y-m-d');
|
||||
foreach (is_array($records) ? $records : [] as $record) {
|
||||
if (substr((string) ($record['time'] ?? ''), 0, 10) !== $today) continue;
|
||||
if (sanitize_key((string) ($record['type'] ?? '')) !== 'points_exchange') continue;
|
||||
$used += max(0, (int) ($record['value'] ?? 0));
|
||||
}
|
||||
if ($used + $points > $daily_max) {
|
||||
return new \WP_Error('aksrt_exchange_daily_limit', sprintf(__('今日购买积分上限为 %d,当前还可购买 %d', 'aksrt'), $daily_max, max(0, $daily_max - $used)), ['status' => 400]);
|
||||
}
|
||||
@@ -451,7 +450,7 @@ trait BalanceServiceRest {
|
||||
'currency' => 'CNY',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PAID,
|
||||
'payment_method' => 'balance',
|
||||
'payment_transaction_id' => 'ledger_' . $transaction_id,
|
||||
'payment_transaction_id' => '',
|
||||
'paid_at' => current_time('mysql'),
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'request_key' => $operation_key,
|
||||
@@ -481,7 +480,6 @@ trait BalanceServiceRest {
|
||||
}
|
||||
return rest_ensure_response(['success' => true, 'data' => [
|
||||
'message' => sprintf(__('购买成功,获得 %d 积分', 'aksrt'), $points),
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'replayed' => $result->replayed,
|
||||
'wallet_balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($user_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(),
|
||||
'credit_balance' => $result->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
/**
|
||||
* User-meta backed wallet and points balances.
|
||||
*
|
||||
* Commission remains in the immutable asset ledger. Balance and points are
|
||||
* deliberately kept on user meta, with a bounded recent-history record.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Credits;
|
||||
|
||||
use AKSRT\Ledger\AssetAmount;
|
||||
use AKSRT\Ledger\AssetType;
|
||||
use AKSRT\Ledger\LedgerAbort;
|
||||
use AKSRT\Ledger\LedgerCommand;
|
||||
use AKSRT\Ledger\LedgerResult;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class MetaAssetService {
|
||||
private const MAX_RECORDS = 50;
|
||||
private static ?self $instance = null;
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function get_instance(): self {
|
||||
return self::$instance ??= new self();
|
||||
}
|
||||
|
||||
public function balance_points(int $user_id): int {
|
||||
return max(0, (int) get_user_meta($user_id, 'points', true));
|
||||
}
|
||||
|
||||
public function balance_minor(int $user_id): int {
|
||||
if ($user_id <= 0) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return AssetAmount::balance((string) get_user_meta($user_id, 'balance', true))->minor();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public function balance_decimal(int $user_id): string {
|
||||
return AssetAmount::from_minor(AssetType::BALANCE, $this->balance_minor($user_id))->decimal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a balance/points LedgerCommand against user meta. This adapter keeps
|
||||
* existing business callbacks and result DTOs while never writing Ledger or
|
||||
* FinancialFlow rows for these two assets.
|
||||
*
|
||||
* @return LedgerResult|\WP_Error
|
||||
*/
|
||||
public function post_command(LedgerCommand $command, ?callable $guard = null, ?callable $business_mutation = null) {
|
||||
global $wpdb;
|
||||
$entries = $command->entries;
|
||||
if ($entries === []) {
|
||||
return new \WP_Error('aksrt_asset_entries', __('资产明细不能为空', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$valid = $command->validate();
|
||||
if (is_wp_error($valid)) {
|
||||
return $valid;
|
||||
}
|
||||
foreach ($entries as $entry) {
|
||||
if (!in_array((string) ($entry['asset'] ?? ''), [AssetType::BALANCE->value, AssetType::POINTS->value], true)) {
|
||||
return new \WP_Error('aksrt_asset_mixed_pool', __('余额/积分不能与佣金资金池混合操作', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
}
|
||||
|
||||
$lock_names = [];
|
||||
$lock_names[] = 'aksrt_meta_op_' . substr(hash('sha256', $command->operation_key), 0, 48);
|
||||
foreach ($entries as $entry) {
|
||||
$lock_names[] = 'aksrt_meta_asset_' . (int) $entry['user_id'] . '_' . sanitize_key((string) $entry['asset']);
|
||||
}
|
||||
$lock_names = array_values(array_unique($lock_names));
|
||||
sort($lock_names, SORT_STRING);
|
||||
$user_ids = array_values(array_unique(array_map(static fn(array $entry): int => (int) $entry['user_id'], $entries)));
|
||||
$held = [];
|
||||
foreach ($lock_names as $lock_name) {
|
||||
$got = (int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock_name));
|
||||
if ($got !== 1) {
|
||||
$this->release_locks($held);
|
||||
return new \WP_Error('aksrt_asset_busy', __('资产正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$held[] = $lock_name;
|
||||
}
|
||||
|
||||
$owns_transaction = $wpdb->query('START TRANSACTION') !== false;
|
||||
if (!$owns_transaction) {
|
||||
$this->release_locks($held);
|
||||
return new \WP_Error('aksrt_asset_db', __('资产更新失败,请稍后重试', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload_hash = $command->payload_hash();
|
||||
$replay = $this->find_operation($entries, $command->operation_key, $payload_hash);
|
||||
if (is_wp_error($replay)) {
|
||||
throw new LedgerAbort($replay);
|
||||
}
|
||||
if (is_array($replay)) {
|
||||
$wpdb->query('COMMIT');
|
||||
return new LedgerResult(0, true, [], $replay, '');
|
||||
}
|
||||
|
||||
$before = [];
|
||||
$totals = [];
|
||||
foreach ($entries as $entry) {
|
||||
$user_id = (int) $entry['user_id'];
|
||||
$asset = (string) $entry['asset'];
|
||||
$key = $user_id . ':' . $asset;
|
||||
if (!isset($before[$key])) {
|
||||
$before[$key] = $this->read_locked_balance($user_id, $asset);
|
||||
$totals[$key] = 0;
|
||||
}
|
||||
$delta = (int) $entry['delta_minor'];
|
||||
if (($delta > 0 && $totals[$key] > PHP_INT_MAX - $delta) || ($delta < 0 && $totals[$key] < PHP_INT_MIN - $delta)) {
|
||||
throw new LedgerAbort(new \WP_Error('aksrt_asset_overflow', __('资产金额超出范围', 'aksrt'), ['status' => 400]));
|
||||
}
|
||||
$totals[$key] += $delta;
|
||||
}
|
||||
|
||||
$balances = [];
|
||||
foreach ($before as $key => $old) {
|
||||
[$user_id_raw, $asset] = explode(':', $key, 2);
|
||||
$user_id = (int) $user_id_raw;
|
||||
$delta = $totals[$key];
|
||||
if ($delta === 0) {
|
||||
$balances[$user_id][$asset] = $old;
|
||||
continue;
|
||||
}
|
||||
if ($delta < 0 && $old < abs($delta)) {
|
||||
$is_points = $asset === AssetType::POINTS->value;
|
||||
$code = $is_points ? 'aksrt_points_insufficient' : 'aksrt_balance_insufficient';
|
||||
throw new LedgerAbort(new \WP_Error($code, $is_points ? __('积分不足', 'aksrt') : __('余额不足', 'aksrt'), [
|
||||
'status' => 402,
|
||||
'balance_minor' => $old,
|
||||
'required_minor' => abs($delta),
|
||||
]));
|
||||
}
|
||||
if ($delta > 0 && $old > PHP_INT_MAX - $delta) {
|
||||
throw new LedgerAbort(new \WP_Error('aksrt_asset_overflow', __('资产金额超出范围', 'aksrt'), ['status' => 400]));
|
||||
}
|
||||
$new = $old + $delta;
|
||||
$this->write_balance($user_id, $asset, $delta, $new);
|
||||
$balances[$user_id][$asset] = $new;
|
||||
}
|
||||
|
||||
if ($guard !== null) {
|
||||
$guard_result = $guard(0, $balances);
|
||||
if (is_wp_error($guard_result)) {
|
||||
throw new LedgerAbort($guard_result);
|
||||
}
|
||||
if ($guard_result !== true) {
|
||||
throw new LedgerAbort(new \WP_Error('aksrt_asset_guard', __('资产操作校验失败', 'aksrt'), ['status' => 409]));
|
||||
}
|
||||
}
|
||||
|
||||
$written = [];
|
||||
$running = $before;
|
||||
foreach ($entries as $entry) {
|
||||
$user_id = (int) $entry['user_id'];
|
||||
$asset = (string) $entry['asset'];
|
||||
$key = $user_id . ':' . $asset;
|
||||
$delta = (int) $entry['delta_minor'];
|
||||
$old = (int) ($running[$key] ?? 0) + $delta;
|
||||
$running[$key] = $old;
|
||||
$order_number = trim((string) ($command->metadata['order_number'] ?? ''));
|
||||
if ($order_number === '' && $command->ref_type === 'order') {
|
||||
$order_number = trim($command->ref_key);
|
||||
}
|
||||
$record = [
|
||||
'order_num' => $order_number,
|
||||
'value' => $asset === AssetType::BALANCE->value ? AssetAmount::from_minor(AssetType::BALANCE, abs($delta))->decimal() : $delta,
|
||||
'type' => in_array(sanitize_key((string) ($entry['entry_type'] ?? '')), ['credit', 'debit'], true)
|
||||
? sanitize_key($command->reason)
|
||||
: sanitize_key((string) ($entry['entry_type'] ?? $command->reason)),
|
||||
'desc' => sanitize_text_field((string) ($entry['remark'] ?? $command->reason)),
|
||||
'time' => current_time('Y-m-d H:i'),
|
||||
];
|
||||
if ($asset === AssetType::BALANCE->value) {
|
||||
$record['value'] = $delta < 0 ? '-' . $record['value'] : $record['value'];
|
||||
$record['balance'] = AssetAmount::from_minor(AssetType::BALANCE, max(0, $old))->decimal();
|
||||
$meta_key = 'balance_record';
|
||||
} else {
|
||||
$record['value'] = $delta;
|
||||
$record['points'] = max(0, $old);
|
||||
$meta_key = 'points_record';
|
||||
}
|
||||
wp_cache_delete($user_id, 'user_meta');
|
||||
$history = get_user_meta($user_id, $meta_key, true);
|
||||
$history = is_array($history) ? $history : [];
|
||||
$history = array_slice($history, 0, self::MAX_RECORDS - 1);
|
||||
$history = array_merge([$record], $history);
|
||||
update_user_meta($user_id, $meta_key, $history);
|
||||
$written[] = [
|
||||
'user_id' => $user_id,
|
||||
'asset' => $asset,
|
||||
'delta_minor' => $delta,
|
||||
'balance_after_minor' => $old,
|
||||
'counterparty_user_id' => max(0, (int) ($entry['counterparty_user_id'] ?? 0)),
|
||||
'entry_type' => sanitize_key((string) ($entry['entry_type'] ?? 'adjustment')),
|
||||
'remark' => sanitize_text_field((string) ($entry['remark'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
if ($business_mutation !== null) {
|
||||
$mutation_result = $business_mutation(0, new LedgerResult(0, false, $written, $balances));
|
||||
if (is_wp_error($mutation_result)) {
|
||||
throw new LedgerAbort($mutation_result);
|
||||
}
|
||||
if ($mutation_result !== true) {
|
||||
throw new LedgerAbort(new \WP_Error('aksrt_asset_mutation', __('资产关联操作失败', 'aksrt'), ['status' => 409]));
|
||||
}
|
||||
}
|
||||
$this->write_operation_markers($entries, $command->operation_key, $payload_hash);
|
||||
|
||||
if ($wpdb->query('COMMIT') === false) {
|
||||
throw new \RuntimeException('meta_asset_commit_failed');
|
||||
}
|
||||
foreach ($user_ids as $user_id) {
|
||||
clean_user_cache((int) $user_id);
|
||||
}
|
||||
return new LedgerResult(0, false, $written, $balances, '');
|
||||
} catch (LedgerAbort $e) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
foreach ($user_ids as $user_id) clean_user_cache((int) $user_id);
|
||||
return $e->error;
|
||||
} catch (\Throwable $e) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
foreach ($user_ids as $user_id) clean_user_cache((int) $user_id);
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[AKSRT Meta Assets] ' . $e->getMessage());
|
||||
}
|
||||
return new \WP_Error('aksrt_asset_db', __('资产更新失败,请稍后重试', 'aksrt'), ['status' => 500]);
|
||||
} finally {
|
||||
$this->release_locks($held);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,int>|\WP_Error|null */
|
||||
private function find_operation(array $entries, string $operation_key, string $payload_hash): array|\WP_Error|null {
|
||||
$balances = [];
|
||||
$users = [];
|
||||
foreach ($entries as $entry) {
|
||||
$users[(int) $entry['user_id']] = true;
|
||||
}
|
||||
$marker_key = 'aksrt_asset_op_' . hash('sha256', $operation_key);
|
||||
$found = 0;
|
||||
foreach (array_keys($users) as $user_id) {
|
||||
$stored_hash = (string) get_user_meta((int) $user_id, $marker_key, true);
|
||||
if ($stored_hash === '') {
|
||||
continue;
|
||||
}
|
||||
if (!hash_equals($stored_hash, $payload_hash)) {
|
||||
return new \WP_Error('aksrt_asset_idempotency_conflict', __('相同资产操作标识已用于其他请求', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$found++;
|
||||
}
|
||||
if ($found > 0 && $found !== count($users)) {
|
||||
return new \WP_Error('aksrt_asset_idempotency_conflict', __('资产操作记录不完整,请联系管理员核查', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
if ($found === count($users)) {
|
||||
foreach ($entries as $entry) {
|
||||
$user_id = (int) $entry['user_id'];
|
||||
$asset = (string) $entry['asset'];
|
||||
$balances[$user_id][$asset] = $asset === AssetType::POINTS->value
|
||||
? $this->balance_points($user_id)
|
||||
: $this->balance_minor($user_id);
|
||||
}
|
||||
}
|
||||
return $balances !== [] ? $balances : null;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $entries */
|
||||
private function write_operation_markers(array $entries, string $operation_key, string $payload_hash): void {
|
||||
$marker_key = 'aksrt_asset_op_' . hash('sha256', $operation_key);
|
||||
foreach (array_unique(array_map(static fn(array $entry): int => (int) $entry['user_id'], $entries)) as $user_id) {
|
||||
update_user_meta($user_id, $marker_key, $payload_hash);
|
||||
}
|
||||
}
|
||||
|
||||
private function read_locked_balance(int $user_id, string $asset): int {
|
||||
global $wpdb;
|
||||
$key = $asset === AssetType::POINTS->value ? 'points' : 'balance';
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT umeta_id, meta_value FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s ORDER BY umeta_id ASC LIMIT 1 FOR UPDATE",
|
||||
$user_id,
|
||||
$key
|
||||
), ARRAY_A);
|
||||
if (!is_array($row)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return $asset === AssetType::POINTS->value
|
||||
? max(0, (int) $row['meta_value'])
|
||||
: AssetAmount::balance((string) $row['meta_value'])->minor();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private function write_balance(int $user_id, string $asset, int $delta, int $minor): void {
|
||||
global $wpdb;
|
||||
$key = $asset === AssetType::POINTS->value ? 'points' : 'balance';
|
||||
$value = $asset === AssetType::POINTS->value
|
||||
? (string) $minor
|
||||
: AssetAmount::from_minor(AssetType::BALANCE, $minor)->decimal();
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT umeta_id FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s ORDER BY umeta_id ASC LIMIT 1 FOR UPDATE",
|
||||
$user_id,
|
||||
$key
|
||||
), ARRAY_A);
|
||||
if (is_array($row)) {
|
||||
$meta_id = (int) $row['umeta_id'];
|
||||
if ($asset === AssetType::POINTS->value) {
|
||||
$sql = "UPDATE {$wpdb->usermeta} SET meta_value = CAST(meta_value AS SIGNED) + %d WHERE umeta_id = %d AND (%d >= 0 OR CAST(meta_value AS SIGNED) >= %d)";
|
||||
$updated = $wpdb->query($wpdb->prepare($sql, $delta, $meta_id, $delta, abs($delta)));
|
||||
} else {
|
||||
$delta_decimal = AssetAmount::from_minor(AssetType::BALANCE, abs($delta))->decimal();
|
||||
if ($delta < 0) {
|
||||
$delta_decimal = '-' . $delta_decimal;
|
||||
}
|
||||
$minimum = AssetAmount::from_minor(AssetType::BALANCE, abs($delta))->decimal();
|
||||
$sql = "UPDATE {$wpdb->usermeta} SET meta_value = CAST(ROUND(CAST(meta_value AS DECIMAL(20,2)) + CAST(%s AS DECIMAL(20,2)), 2) AS CHAR) WHERE umeta_id = %d AND (%s >= '0.00' OR CAST(meta_value AS DECIMAL(20,2)) >= CAST(%s AS DECIMAL(20,2)))";
|
||||
$updated = $wpdb->query($wpdb->prepare($sql, $delta_decimal, $meta_id, $delta_decimal, $minimum));
|
||||
}
|
||||
if ($updated !== 1) {
|
||||
throw new \RuntimeException('meta_asset_update_failed');
|
||||
}
|
||||
wp_cache_delete($user_id, 'user_meta');
|
||||
return;
|
||||
}
|
||||
if ($minor < 0 || $wpdb->insert($wpdb->usermeta, ['user_id' => $user_id, 'meta_key' => $key, 'meta_value' => $value], ['%d', '%s', '%s']) !== 1) {
|
||||
throw new \RuntimeException('meta_asset_insert_failed');
|
||||
}
|
||||
wp_cache_delete($user_id, 'user_meta');
|
||||
}
|
||||
|
||||
private function release_locks(array $locks): void {
|
||||
global $wpdb;
|
||||
foreach (array_reverse($locks) as $lock_name) {
|
||||
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_name));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ defined('ABSPATH') || exit;
|
||||
|
||||
class PointsFree {
|
||||
|
||||
public const META_DETAIL = 'aksrt_free_points_detail';
|
||||
public const META_DETAIL = 'free_points_detail';
|
||||
public const META_LOGIN = 'aksrt_free_points_login_date';
|
||||
public const META_SIGNIN = 'aksrt_free_points_signin_date';
|
||||
public const META_TASKS = 'aksrt_free_points_task_dates';
|
||||
|
||||
@@ -124,7 +124,6 @@ class Signin {
|
||||
}
|
||||
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::deterministic('signin', [$user_id, $today_str]);
|
||||
$transaction_id = null;
|
||||
$replayed = false;
|
||||
if ($reward > 0) {
|
||||
$guard = static function () use ($wpdb, $table, $user_id, $today_str) {
|
||||
@@ -164,7 +163,6 @@ class Signin {
|
||||
if (is_wp_error($ledger_result)) {
|
||||
return $ledger_result;
|
||||
}
|
||||
$transaction_id = $ledger_result->transaction_id;
|
||||
$replayed = $ledger_result->replayed;
|
||||
} else {
|
||||
$stored = $this->store_zero_reward_signin($user_id, $today_str, $new_streak);
|
||||
@@ -189,7 +187,6 @@ class Signin {
|
||||
'streak' => $new_streak,
|
||||
'reward' => $reward,
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
|
||||
'transaction_id' => $transaction_id,
|
||||
'replayed' => $replayed,
|
||||
], 200);
|
||||
}
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Finance;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class FinancialFlowQuery {
|
||||
|
||||
private static ?self $instance = null;
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function order_payment_number(string $order_number): string {
|
||||
global $wpdb;
|
||||
$order_number = substr(sanitize_text_field($order_number), 0, 32);
|
||||
if ($order_number === '') {
|
||||
return '';
|
||||
}
|
||||
return (string) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT flow_number FROM {$this->flow_table()} WHERE order_number = %s AND event_type = 'order_payment' ORDER BY id ASC LIMIT 1",
|
||||
$order_number
|
||||
));
|
||||
}
|
||||
|
||||
/** @param array<int,string> $order_numbers @return array<string,string> */
|
||||
public function order_payment_number_map(array $order_numbers): array {
|
||||
global $wpdb;
|
||||
$order_numbers = array_values(array_unique(array_filter(array_map(static function ($number): string {
|
||||
return substr(sanitize_text_field((string) $number), 0, 32);
|
||||
}, $order_numbers))));
|
||||
if ($order_numbers === []) {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($order_numbers), '%s'));
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT order_number, flow_number FROM {$this->flow_table()} WHERE event_type = 'order_payment' AND order_number IN ({$placeholders}) ORDER BY id ASC",
|
||||
$order_numbers
|
||||
), ARRAY_A);
|
||||
$map = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$number = (string) ($row['order_number'] ?? '');
|
||||
if ($number !== '' && !isset($map[$number])) {
|
||||
$map[$number] = (string) ($row['flow_number'] ?? '');
|
||||
}
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $args
|
||||
* @return array{items:array<int,array<string,mixed>>,total:int,page:int,per_page:int}
|
||||
*/
|
||||
public function search(array $args = []): array {
|
||||
global $wpdb;
|
||||
$page = max(1, (int) ($args['page'] ?? 1));
|
||||
$per_page = min(100, max(1, (int) ($args['per_page'] ?? 20)));
|
||||
$offset = ($page - 1) * $per_page;
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
$keyword = trim(sanitize_text_field((string) ($args['keyword'] ?? '')));
|
||||
if ($keyword !== '') {
|
||||
if ($this->keyword_hits_identifier($keyword)) {
|
||||
// 命中完整标识符:走等值查询。各列都有索引,避免 6 路 LIKE '%kw%' 全表扫描。
|
||||
$where[] = '(f.flow_number = %s OR f.operation_key = %s OR f.order_number = %s OR f.withdrawal_number = %s OR f.provider_transaction_id = %s OR f.ref_key = %s)';
|
||||
array_push($params, $keyword, $keyword, $keyword, $keyword, $keyword, $keyword);
|
||||
} else {
|
||||
// 未精确命中(含前缀、部分匹配、含中文或短关键字):退回模糊匹配。
|
||||
$like = '%' . $wpdb->esc_like($keyword) . '%';
|
||||
$where[] = '(f.flow_number LIKE %s OR f.operation_key LIKE %s OR f.order_number LIKE %s OR f.withdrawal_number LIKE %s OR f.provider_transaction_id LIKE %s OR f.ref_key LIKE %s)';
|
||||
array_push($params, $like, $like, $like, $like, $like, $like);
|
||||
}
|
||||
}
|
||||
$event_type = sanitize_key((string) ($args['event_type'] ?? ''));
|
||||
if ($event_type !== '') {
|
||||
$where[] = 'f.event_type = %s';
|
||||
$params[] = $event_type;
|
||||
}
|
||||
$user_id = max(0, (int) ($args['user_id'] ?? 0));
|
||||
if ($user_id > 0) {
|
||||
$where[] = "EXISTS (SELECT 1 FROM {$this->entry_table()} eu WHERE eu.flow_id = f.id AND (eu.user_id = %d OR eu.counterparty_user_id = %d))";
|
||||
array_push($params, $user_id, $user_id);
|
||||
}
|
||||
$asset = sanitize_key((string) ($args['asset'] ?? ''));
|
||||
if (in_array($asset, ['cash', 'balance', 'points'], true)) {
|
||||
$where[] = "EXISTS (SELECT 1 FROM {$this->entry_table()} ea WHERE ea.flow_id = f.id AND ea.asset = %s)";
|
||||
$params[] = $asset;
|
||||
}
|
||||
$direction = sanitize_key((string) ($args['direction'] ?? ''));
|
||||
if (in_array($direction, ['in', 'out'], true)) {
|
||||
$where[] = "EXISTS (SELECT 1 FROM {$this->entry_table()} ed WHERE ed.flow_id = f.id AND ed.direction = %s)";
|
||||
$params[] = $direction;
|
||||
}
|
||||
if (!empty($args['date_from'])) {
|
||||
$where[] = 'f.created_at >= %s';
|
||||
$params[] = (string) $args['date_from'];
|
||||
}
|
||||
if (!empty($args['date_to'])) {
|
||||
$where[] = 'f.created_at < %s';
|
||||
$params[] = (string) $args['date_to'];
|
||||
}
|
||||
$where_sql = implode(' AND ', $where);
|
||||
$count_sql = "SELECT COUNT(*) FROM {$this->flow_table()} f WHERE {$where_sql}";
|
||||
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) : $wpdb->get_var($count_sql));
|
||||
$item_sql = "SELECT f.* FROM {$this->flow_table()} f WHERE {$where_sql} ORDER BY f.created_at DESC, f.id DESC LIMIT %d OFFSET %d";
|
||||
$rows = (array) $wpdb->get_results($wpdb->prepare($item_sql, array_merge($params, [$per_page, $offset])), ARRAY_A);
|
||||
$ids = array_values(array_filter(array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $rows)));
|
||||
$entry_map = $this->entry_map($ids);
|
||||
foreach ($rows as &$row) {
|
||||
$row['id'] = (int) ($row['id'] ?? 0);
|
||||
$row['ledger_transaction_id'] = (int) ($row['ledger_transaction_id'] ?? 0);
|
||||
$row['metadata'] = json_decode((string) ($row['metadata'] ?? ''), true) ?: [];
|
||||
$row['entries'] = $entry_map[$row['id']] ?? [];
|
||||
}
|
||||
unset($row);
|
||||
return ['items' => $rows, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
public function event_types(): array {
|
||||
global $wpdb;
|
||||
$values = (array) $wpdb->get_col("SELECT DISTINCT event_type FROM {$this->flow_table()} ORDER BY event_type ASC");
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
$key = sanitize_key((string) $value);
|
||||
if ($key !== '') {
|
||||
$out[$key] = $key;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param array<int,int> $flow_ids @return array<int,array<int,array<string,mixed>>> */
|
||||
private function entry_map(array $flow_ids): array {
|
||||
global $wpdb;
|
||||
if ($flow_ids === []) {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($flow_ids), '%d'));
|
||||
$rows = (array) $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$this->entry_table()} WHERE flow_id IN ({$placeholders}) ORDER BY flow_id ASC, line_no ASC",
|
||||
$flow_ids
|
||||
), ARRAY_A);
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
foreach (['id', 'flow_id', 'line_no', 'user_id', 'delta_minor', 'balance_after_minor', 'counterparty_user_id'] as $field) {
|
||||
$row[$field] = (int) ($row[$field] ?? 0);
|
||||
}
|
||||
$map[$row['flow_id']][] = $row;
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键字是否精确命中某个标识符列。
|
||||
*
|
||||
* 业务流水号统一为 {YmdHis}{8 位随机数},运营通常是从截图或邮件里整段复制,
|
||||
* 此时等值查询可以走索引。若没有精确命中,则退回 LIKE 模糊匹配,保证
|
||||
* 「只输入前缀也能搜到」的既有行为不变。
|
||||
*/
|
||||
private function keyword_hits_identifier(string $keyword): bool {
|
||||
global $wpdb;
|
||||
// 只对像标识符的输入启用:纯 ASCII 字母数字、长度 8-191。
|
||||
if (!preg_match('/^[A-Za-z0-9_-]{8,191}$/', $keyword)) {
|
||||
return false;
|
||||
}
|
||||
$found = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT 1 FROM {$this->flow_table()}
|
||||
WHERE flow_number = %s OR operation_key = %s OR order_number = %s
|
||||
OR withdrawal_number = %s OR provider_transaction_id = %s OR ref_key = %s
|
||||
LIMIT 1",
|
||||
$keyword,
|
||||
$keyword,
|
||||
$keyword,
|
||||
$keyword,
|
||||
$keyword,
|
||||
$keyword
|
||||
));
|
||||
return $found !== null;
|
||||
}
|
||||
|
||||
private function flow_table(): string {
|
||||
return \AKSRT\Core\DBMigrator::table('financial_flow');
|
||||
}
|
||||
|
||||
private function entry_table(): string {
|
||||
return \AKSRT\Core\DBMigrator::table('financial_flow_entry');
|
||||
}
|
||||
}
|
||||
@@ -31,12 +31,11 @@ final class FinancialFlowService {
|
||||
*/
|
||||
public function record_ledger(LedgerCommand $command, int $transaction_id, array $entries, string $created_at) {
|
||||
$order_number = $command->ref_type === 'order' ? $command->ref_key : (string) ($command->metadata['order_number'] ?? '');
|
||||
$order = $order_number !== '' ? $this->resolve_order($order_number) : [];
|
||||
$withdrawal_number = '';
|
||||
if ($command->ref_type === 'withdraw') {
|
||||
$withdrawal_number = $this->resolve_withdrawal_number($command, $transaction_id);
|
||||
}
|
||||
$provider_transaction_id = (string) ($order['payment_transaction_id'] ?? '');
|
||||
$provider_transaction_id = '';
|
||||
|
||||
$flow_entries = [];
|
||||
foreach ($entries as $entry) {
|
||||
@@ -54,26 +53,6 @@ final class FinancialFlowService {
|
||||
}
|
||||
$event_type = $command->reason;
|
||||
$metadata = $command->metadata;
|
||||
if ($order !== [] && $this->is_successful_order_payment($order, $flow_entries)) {
|
||||
$event_type = 'order_payment';
|
||||
$metadata['ledger_reason'] = $command->reason;
|
||||
if ($this->is_external_recharge($order, $flow_entries)) {
|
||||
try {
|
||||
$cash_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($order['amount'] ?? ''))->minor();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new \WP_Error('aksrt_flow_amount', __('充值支付流水金额无效', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$flow_entries[] = [
|
||||
'user_id' => max(0, (int) ($order['user_id'] ?? 0)),
|
||||
'asset' => 'cash',
|
||||
'direction' => 'out',
|
||||
'entry_type' => 'payment',
|
||||
'delta_minor' => -$cash_minor,
|
||||
'counterparty_user_id' => 0,
|
||||
'remark' => __('充值订单支付', 'aksrt'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->record([
|
||||
'operation_key' => $command->operation_key,
|
||||
@@ -90,78 +69,6 @@ final class FinancialFlowService {
|
||||
}
|
||||
|
||||
/** @return string|\WP_Error */
|
||||
public function record_order_payment(array $order, bool $caller_transaction = false) {
|
||||
return $this->record_order_event($order, 'order_payment', -1, $caller_transaction);
|
||||
}
|
||||
|
||||
/** @return string|\WP_Error */
|
||||
public function record_order_refund(array $order, bool $caller_transaction = false) {
|
||||
return $this->record_order_event($order, 'order_refund', 1, $caller_transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录卡密核销产生的订单支付流水。
|
||||
*
|
||||
* 卡密是平台自发的预付凭证:核销那一刻既没有第三方现金流入,也不产生账本资产分录,
|
||||
* 订单金额还可能为 0(会员卡)或以积分计价(积分卡)。因此这里只落一条与订单关联的
|
||||
* order_payment 流水,不生成任何现金/资产分录,让订单在详情页具备可追溯的「账本流水号」。
|
||||
*
|
||||
* 余额卡与积分卡的核销走账本(LedgerService::credit),其流水由 record_ledger() 落库;
|
||||
* 本方法供会员卡这类无账本参与的卡种调用。
|
||||
*
|
||||
* @param array<string,mixed> $order
|
||||
* @return string|\WP_Error
|
||||
*/
|
||||
public function record_card_pass_payment(array $order, bool $caller_transaction = false) {
|
||||
return $this->record_order_event($order, 'order_payment', -1, $caller_transaction, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把账本流水挂到卡密核销订单上(订单号 + 渠道交易号 + 事件类型归一)。
|
||||
*
|
||||
* 卡密核销的资产入账走账本,但订单号是随机生成的、不能进 LedgerCommand
|
||||
* (payload_hash 覆盖 metadata,塞入随机号会破坏账本幂等重放)。因此账本
|
||||
* 提交之后再调用本方法补挂关联;同时把 event_type 归一为 order_payment,
|
||||
* 让订单详情页的「账本流水号」能反查到它。
|
||||
*
|
||||
* @return bool 是否发生了更新
|
||||
*/
|
||||
public function attach_order_to_ledger_flow(int $ledger_transaction_id, string $order_number, string $provider_transaction_id = ''): bool {
|
||||
global $wpdb;
|
||||
if ($ledger_transaction_id <= 0 || $order_number === '') {
|
||||
return false;
|
||||
}
|
||||
$order_number = substr(sanitize_text_field($order_number), 0, 32);
|
||||
$order = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT status, payment_method, payment_transaction_id FROM {$this->order_table()} WHERE order_number = %s LIMIT 1",
|
||||
$order_number
|
||||
), ARRAY_A);
|
||||
if (!is_array($order)
|
||||
|| sanitize_key((string) ($order['status'] ?? '')) !== \AKSRT\Payment\Order::STATUS_PAID
|
||||
|| sanitize_key((string) ($order['payment_method'] ?? '')) !== 'card_pass'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if ($provider_transaction_id === '') {
|
||||
$provider_transaction_id = (string) ($order['payment_transaction_id'] ?? '');
|
||||
}
|
||||
$updated = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$this->flow_table()}
|
||||
SET order_number = %s,
|
||||
provider_transaction_id = CASE WHEN provider_transaction_id IS NULL OR provider_transaction_id = '' THEN %s ELSE provider_transaction_id END,
|
||||
event_type = 'order_payment'
|
||||
WHERE ledger_transaction_id = %d AND (order_number IS NULL OR order_number = '')",
|
||||
$order_number,
|
||||
substr(sanitize_text_field($provider_transaction_id), 0, 128),
|
||||
$ledger_transaction_id
|
||||
));
|
||||
return $updated === 1;
|
||||
}
|
||||
|
||||
private function order_table(): string {
|
||||
return \AKSRT\Core\DBMigrator::table('order');
|
||||
}
|
||||
|
||||
/** @return string|\WP_Error */
|
||||
public function record_withdrawal_payout(array $withdrawal, string $operation_key, string $created_at, bool $caller_transaction = false) {
|
||||
$user_id = max(0, (int) ($withdrawal['user_id'] ?? 0));
|
||||
@@ -334,79 +241,6 @@ final class FinancialFlowService {
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $order
|
||||
* @param bool $voucher_settled 凭证结算(卡密核销):订单可能为 0 元或以积分计价,
|
||||
* 且没有任何现金/资产分录,故跳过金额与资产口径校验。
|
||||
*/
|
||||
private function record_order_event(array $order, string $event_type, int $sign, bool $caller_transaction, bool $voucher_settled = false) {
|
||||
$order_number = substr(sanitize_text_field((string) ($order['order_number'] ?? '')), 0, 32);
|
||||
$user_id = max(0, (int) ($order['user_id'] ?? 0));
|
||||
if ($order_number === '') {
|
||||
return new \WP_Error('aksrt_flow_order', __('订单流水参数无效', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$existing = $this->number_for_order_event($order_number, $event_type);
|
||||
if ($existing !== '') {
|
||||
return $existing;
|
||||
}
|
||||
$payment_method = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||
$currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
|
||||
$metadata = [
|
||||
'payment_method' => $payment_method,
|
||||
'currency' => $currency,
|
||||
];
|
||||
$entries = [];
|
||||
if ($voucher_settled) {
|
||||
// 卡密核销:凭证本身即支付凭据,订单金额可能为 0、也可能以积分计价,
|
||||
// 因此不做金额校验、也不生成现金分录;流水行即该订单的支付凭证
|
||||
// (订单详情页经 FinancialFlowQuery::order_payment_number() 反查得到)。
|
||||
$metadata['voucher'] = 'card_pass';
|
||||
} else {
|
||||
if (in_array($payment_method, ['balance', 'points'], true) || $currency === 'points') {
|
||||
return new \WP_Error('aksrt_flow_order_asset', __('余额或积分订单必须通过资产账本确认', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
try {
|
||||
$amount_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($order['amount'] ?? ''))->minor();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new \WP_Error('aksrt_flow_amount', __('订单流水金额无效', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
if ($amount_minor <= 0) {
|
||||
return new \WP_Error('aksrt_flow_amount', __('订单流水金额无效', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$entries[] = [
|
||||
'user_id' => $user_id,
|
||||
'asset' => 'cash',
|
||||
'direction' => $sign > 0 ? 'in' : 'out',
|
||||
'entry_type' => $event_type === 'order_payment' ? 'payment' : 'refund',
|
||||
'delta_minor' => $sign * $amount_minor,
|
||||
'remark' => $event_type === 'order_payment' ? __('订单支付', 'aksrt') : __('订单退款', 'aksrt'),
|
||||
];
|
||||
}
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::deterministic(
|
||||
$event_type === 'order_payment' ? 'order-payment' : 'order-refund',
|
||||
[$order_number]
|
||||
);
|
||||
return $this->record([
|
||||
'operation_key' => $operation_key,
|
||||
'event_type' => $event_type,
|
||||
'ref_type' => 'order',
|
||||
'ref_key' => $order_number,
|
||||
'order_number' => $order_number,
|
||||
'provider_transaction_id' => (string) ($order['payment_transaction_id'] ?? ''),
|
||||
'metadata' => $metadata,
|
||||
'created_at' => self::utc_now(),
|
||||
], $entries, $caller_transaction, $voucher_settled);
|
||||
}
|
||||
|
||||
private function number_for_order_event(string $order_number, string $event_type): string {
|
||||
global $wpdb;
|
||||
return (string) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT flow_number FROM {$this->flow_table()} WHERE order_number = %s AND event_type = %s ORDER BY id ASC LIMIT 1",
|
||||
$order_number,
|
||||
$event_type
|
||||
));
|
||||
}
|
||||
|
||||
private function resolve_withdrawal_number(LedgerCommand $command, int $transaction_id): string {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('withdrawal');
|
||||
@@ -420,55 +254,6 @@ final class FinancialFlowService {
|
||||
return (string) $wpdb->get_var($wpdb->prepare("SELECT withdrawal_number FROM {$table} WHERE ledger_transaction_id = %d LIMIT 1", $transaction_id));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function resolve_order(string $order_number): array {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('order');
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT order_number, user_id, product_type, amount, currency, status, payment_method, payment_transaction_id FROM {$table} WHERE order_number = %s LIMIT 1",
|
||||
$order_number
|
||||
), ARRAY_A);
|
||||
return is_array($row) ? $row : [];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $order @param array<int,array<string,mixed>> $entries */
|
||||
private function is_successful_order_payment(array $order, array $entries): bool {
|
||||
if (sanitize_key((string) ($order['status'] ?? '')) !== \AKSRT\Payment\Order::STATUS_PAID) {
|
||||
return false;
|
||||
}
|
||||
// card_pass:卡密即支付凭据,订单已支付就说明凭证已结算,不依赖现金或资产分录为凭。
|
||||
// 余额卡/积分卡的核销因此也能落成 order_payment 流水,与普通订单同口径。
|
||||
if (sanitize_key((string) ($order['payment_method'] ?? '')) === 'card_pass') {
|
||||
return true;
|
||||
}
|
||||
$buyer_id = max(0, (int) ($order['user_id'] ?? 0));
|
||||
foreach ($entries as $entry) {
|
||||
if ((int) ($entry['user_id'] ?? 0) === $buyer_id && (int) ($entry['delta_minor'] ?? 0) < 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return $this->is_external_recharge($order, $entries);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $order @param array<int,array<string,mixed>> $entries */
|
||||
private function is_external_recharge(array $order, array $entries): bool {
|
||||
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||
// 这些方式都在平台内部结算,核销当下没有第三方现金流入:balance / points 以已有资产支付,
|
||||
// card_pass 以平台自发凭证支付(现金在售卡时已确认),故都不生成现金分录。
|
||||
if (sanitize_key((string) ($order['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|
||||
|| in_array($method, ['', 'balance', 'points', 'card_pass'], true)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
$buyer_id = max(0, (int) ($order['user_id'] ?? 0));
|
||||
foreach ($entries as $entry) {
|
||||
if ((int) ($entry['user_id'] ?? 0) === $buyer_id && (string) ($entry['asset'] ?? '') === 'balance' && (int) ($entry['delta_minor'] ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function flow_table(): string {
|
||||
return \AKSRT\Core\DBMigrator::table('financial_flow');
|
||||
}
|
||||
|
||||
@@ -196,6 +196,26 @@ final class LedgerQuery {
|
||||
return 0;
|
||||
}
|
||||
$asset = AssetType::from_value($asset);
|
||||
if (in_array($asset, [AssetType::BALANCE, AssetType::POINTS], true) && class_exists('AKSRT\\Credits\\MetaAssetService')) {
|
||||
$meta_key = $asset === AssetType::POINTS ? 'points_record' : 'balance_record';
|
||||
$records = get_user_meta($user_id, $meta_key, true);
|
||||
$total = 0;
|
||||
foreach (is_array($records) ? $records : [] as $record) {
|
||||
if (!is_array($record) || sanitize_key((string) ($record['type'] ?? '')) !== $reason) continue;
|
||||
$value = $record['value'] ?? 0;
|
||||
if ($asset === AssetType::POINTS) {
|
||||
$delta = is_int($value) ? $value : (preg_match('/^\d+$/', (string) $value) ? (int) $value : 0);
|
||||
} else {
|
||||
$raw = (string) $value;
|
||||
if (str_starts_with($raw, '-')) continue;
|
||||
try { $delta = AssetAmount::balance($raw)->minor(); } catch (\InvalidArgumentException $e) { $delta = 0; }
|
||||
}
|
||||
if ($delta > 0) {
|
||||
$total = $total > PHP_INT_MAX - $delta ? PHP_INT_MAX : $total + $delta;
|
||||
}
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
$entry_table = \AKSRT\Core\DBMigrator::table('asset_entry');
|
||||
$transaction_table = \AKSRT\Core\DBMigrator::table('asset_transaction');
|
||||
return max(0, (int) $wpdb->get_var($wpdb->prepare(
|
||||
|
||||
@@ -37,6 +37,12 @@ final class LedgerService {
|
||||
}
|
||||
|
||||
public function balance(int $user_id, AssetType|string $asset): int {
|
||||
$asset = AssetType::from_value($asset);
|
||||
if (in_array($asset, [AssetType::BALANCE, AssetType::POINTS], true) && class_exists('\AKSRT\Credits\MetaAssetService')) {
|
||||
return $asset === AssetType::POINTS
|
||||
? \AKSRT\Credits\MetaAssetService::get_instance()->balance_points($user_id)
|
||||
: \AKSRT\Credits\MetaAssetService::get_instance()->balance_minor($user_id);
|
||||
}
|
||||
global $wpdb;
|
||||
if ($user_id <= 0) {
|
||||
return 0;
|
||||
@@ -51,6 +57,9 @@ final class LedgerService {
|
||||
}
|
||||
|
||||
public function balance_decimal(int $user_id): string {
|
||||
if (class_exists('\AKSRT\Credits\MetaAssetService')) {
|
||||
return \AKSRT\Credits\MetaAssetService::get_instance()->balance_decimal($user_id);
|
||||
}
|
||||
return AssetAmount::from_minor(AssetType::BALANCE, $this->balance($user_id, AssetType::BALANCE))->decimal();
|
||||
}
|
||||
|
||||
@@ -95,6 +104,24 @@ final class LedgerService {
|
||||
if ($amount_minor <= 0) {
|
||||
return new \WP_Error('aksrt_ledger_amount', __('入账金额必须大于零', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (in_array($asset, [AssetType::BALANCE, AssetType::POINTS], true) && class_exists('\AKSRT\Credits\MetaAssetService')) {
|
||||
$command = new LedgerCommand(
|
||||
$operation_key,
|
||||
sanitize_key($reason),
|
||||
[[
|
||||
'user_id' => $user_id,
|
||||
'asset' => $asset->value,
|
||||
'delta_minor' => $amount_minor,
|
||||
'entry_type' => 'credit',
|
||||
'remark' => $remark,
|
||||
]],
|
||||
$this->ref_type($ref),
|
||||
$this->ref_key($ref),
|
||||
$actor_user_id,
|
||||
array_merge($metadata, ['order_number' => $metadata['order_number'] ?? ($ref['key'] ?? '')])
|
||||
);
|
||||
return $this->post_meta_command($command, $guard, $business_mutation);
|
||||
}
|
||||
return $this->post(new LedgerCommand(
|
||||
$operation_key,
|
||||
sanitize_key($reason),
|
||||
@@ -137,6 +164,24 @@ final class LedgerService {
|
||||
if ($amount_minor <= 0) {
|
||||
return new \WP_Error('aksrt_ledger_amount', __('扣减金额必须大于零', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (in_array($asset, [AssetType::BALANCE, AssetType::POINTS], true) && class_exists('\AKSRT\Credits\MetaAssetService')) {
|
||||
$command = new LedgerCommand(
|
||||
$operation_key,
|
||||
sanitize_key($reason),
|
||||
[[
|
||||
'user_id' => $user_id,
|
||||
'asset' => $asset->value,
|
||||
'delta_minor' => -$amount_minor,
|
||||
'entry_type' => 'debit',
|
||||
'remark' => $remark,
|
||||
]],
|
||||
$this->ref_type($ref),
|
||||
$this->ref_key($ref),
|
||||
$actor_user_id,
|
||||
array_merge($metadata, ['order_number' => $metadata['order_number'] ?? ($ref['key'] ?? '')])
|
||||
);
|
||||
return $this->post_meta_command($command, $guard, $business_mutation);
|
||||
}
|
||||
return $this->post(new LedgerCommand(
|
||||
$operation_key,
|
||||
sanitize_key($reason),
|
||||
@@ -179,6 +224,11 @@ final class LedgerService {
|
||||
if ($sender_id <= 0 || $recipient_id <= 0 || $sender_id === $recipient_id || $gross_minor <= 0 || $fee_minor < 0 || $fee_minor >= $gross_minor) {
|
||||
return new \WP_Error('aksrt_ledger_transfer', __('转账参数无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (in_array($asset, [AssetType::BALANCE, AssetType::POINTS], true) && class_exists('\AKSRT\Credits\MetaAssetService')) {
|
||||
return $this->post_meta_command(
|
||||
LedgerCommand::transfer($operation_key, sanitize_key($reason), $sender_id, $recipient_id, $asset, $gross_minor, $fee_minor, $this->ref_type($ref), $this->ref_key($ref), $sender_remark, $recipient_remark, array_merge($metadata, ['order_number' => $ref['key'] ?? '']))
|
||||
);
|
||||
}
|
||||
return $this->post(LedgerCommand::transfer(
|
||||
$operation_key,
|
||||
sanitize_key($reason),
|
||||
@@ -214,12 +264,26 @@ final class LedgerService {
|
||||
if ($user_id <= 0 || $balance_minor <= 0 || $points <= 0) {
|
||||
return new \WP_Error('aksrt_ledger_exchange', __('兑换参数无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (class_exists('\AKSRT\Credits\MetaAssetService')) {
|
||||
return $this->post_meta_command(new LedgerCommand(
|
||||
$operation_key,
|
||||
sanitize_key($reason),
|
||||
[
|
||||
['user_id' => $user_id, 'asset' => AssetType::BALANCE->value, 'delta_minor' => -$balance_minor, 'entry_type' => 'debit', 'remark' => $remark],
|
||||
['user_id' => $user_id, 'asset' => AssetType::POINTS->value, 'delta_minor' => $points, 'entry_type' => 'credit', 'remark' => $remark],
|
||||
],
|
||||
$this->ref_type($ref),
|
||||
$this->ref_key($ref),
|
||||
$user_id,
|
||||
array_merge($metadata, ['order_number' => $ref['key'] ?? ''])
|
||||
), $guard, $business_mutation);
|
||||
}
|
||||
return $this->post(new LedgerCommand(
|
||||
$operation_key,
|
||||
sanitize_key($reason),
|
||||
[
|
||||
['user_id' => $user_id, 'asset' => AssetType::BALANCE->value, 'delta_minor' => -$balance_minor, 'entry_type' => 'exchange_out', 'remark' => $remark],
|
||||
['user_id' => $user_id, 'asset' => AssetType::POINTS->value, 'delta_minor' => $points, 'entry_type' => 'exchange_in', 'remark' => $remark],
|
||||
['user_id' => $user_id, 'asset' => AssetType::BALANCE->value, 'delta_minor' => -$balance_minor, 'entry_type' => 'debit', 'remark' => $remark],
|
||||
['user_id' => $user_id, 'asset' => AssetType::POINTS->value, 'delta_minor' => $points, 'entry_type' => 'credit', 'remark' => $remark],
|
||||
],
|
||||
$this->ref_type($ref),
|
||||
$this->ref_key($ref),
|
||||
@@ -234,6 +298,10 @@ final class LedgerService {
|
||||
* @return LedgerResult|\WP_Error
|
||||
*/
|
||||
public function post(LedgerCommand $command, ?callable $guard = null, ?callable $business_mutation = null) {
|
||||
$assets = array_values(array_unique(array_map(static fn(array $entry): string => (string) ($entry['asset'] ?? ''), $command->entries)));
|
||||
if ($assets !== [] && count(array_diff($assets, [AssetType::BALANCE->value, AssetType::POINTS->value])) === 0 && class_exists('\AKSRT\Credits\MetaAssetService')) {
|
||||
return $this->post_meta_command($command, $guard, $business_mutation);
|
||||
}
|
||||
if ($this->transaction_active) {
|
||||
return new \WP_Error('aksrt_ledger_nested', __('账务事务内禁止再次提交账务', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
@@ -253,6 +321,45 @@ final class LedgerService {
|
||||
return new \WP_Error('aksrt_ledger_db', __('账务处理失败,请稍后重试', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
/** @return LedgerResult|\WP_Error */
|
||||
private function post_meta_command(LedgerCommand $command, ?callable $guard = null, ?callable $business_mutation = null) {
|
||||
if ($this->transaction_active) {
|
||||
return new \WP_Error('aksrt_ledger_nested', __('账务事务内禁止再次提交账务', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$this->transaction_active = true;
|
||||
$this->after_commit_callbacks = [];
|
||||
try {
|
||||
$result = \AKSRT\Credits\MetaAssetService::get_instance()->post_command($command, $guard, $business_mutation);
|
||||
$callbacks = is_wp_error($result) ? [] : $this->after_commit_callbacks;
|
||||
$this->after_commit_callbacks = [];
|
||||
if (!is_wp_error($result)) {
|
||||
$this->transaction_active = false;
|
||||
if (!$result->replayed) {
|
||||
try {
|
||||
do_action('aksrt_asset_transaction_committed', $result, $command);
|
||||
} catch (\Throwable $error) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[AKSRT Meta Asset Event] ' . $error->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($callbacks as $callback) {
|
||||
try {
|
||||
$callback();
|
||||
} catch (\Throwable $error) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[AKSRT Meta Asset After Commit] ' . $error->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
} finally {
|
||||
$this->transaction_active = false;
|
||||
$this->after_commit_callbacks = [];
|
||||
}
|
||||
}
|
||||
|
||||
private function attempt_post(LedgerCommand $command, string $payload_hash, ?callable $guard, ?callable $business_mutation) {
|
||||
global $wpdb;
|
||||
$account_table = $this->table('asset_account');
|
||||
@@ -531,7 +638,7 @@ final class LedgerService {
|
||||
if (isset($wpdb->last_errno)) {
|
||||
return (int) $wpdb->last_errno;
|
||||
}
|
||||
if (preg_match('/\\b(1205|1213)\\b/', (string) ($wpdb->last_error ?? ''), $matches)) {
|
||||
if (preg_match('/\b(1205|1213)\b/', (string) ($wpdb->last_error ?? ''), $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
return 0;
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
<?php
|
||||
/** User applications and administrator review for full order refunds. */
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
use AKSRT\Core\DomainModel;
|
||||
use AKSRT\Ledger\AssetAmount;
|
||||
use AKSRT\Ledger\AssetType;
|
||||
use AKSRT\Ledger\LedgerCommand;
|
||||
use AKSRT\Ledger\LedgerService;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class AfterSaleService {
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
private static ?self $instance = null;
|
||||
|
||||
public static function get_instance(): self { return self::$instance ??= new self(); }
|
||||
|
||||
public function init(): void { add_action('rest_api_init', [$this, 'register_routes']); }
|
||||
|
||||
public function register_routes(): void {
|
||||
$number = '(?P<order_number>[0-9]{20}|[0-9]{22})';
|
||||
register_rest_route('aksrt/v1', '/orders/' . $number . '/after-sale', [
|
||||
['methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'rest_submit'], 'permission_callback' => [$this, 'require_user'], 'args' => [
|
||||
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'reason' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'],
|
||||
]],
|
||||
['methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'rest_list'], 'permission_callback' => [$this, 'require_user'], 'args' => [
|
||||
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
]],
|
||||
['methods' => 'DELETE', 'callback' => [$this, 'rest_cancel'], 'permission_callback' => [$this, 'require_user'], 'args' => [
|
||||
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
]],
|
||||
]);
|
||||
}
|
||||
|
||||
public function require_user(\WP_REST_Request $request) {
|
||||
if (!is_user_logged_in()) return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
|
||||
$nonce = (string) $request->get_header('X-WP-Nonce');
|
||||
return $nonce !== '' && wp_verify_nonce($nonce, 'wp_rest') ? true : new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
|
||||
public function rest_submit(\WP_REST_Request $request) {
|
||||
$result = $this->create(get_current_user_id(), (string) $request->get_param('order_number'), (string) $request->get_param('reason'));
|
||||
return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]);
|
||||
}
|
||||
|
||||
public function rest_list(\WP_REST_Request $request) {
|
||||
$number = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
if (!OrderRepository::get_instance()->get_user_order(get_current_user_id(), $number)) return new \WP_Error('aksrt_order_missing', __('订单不存在', 'aksrt'), ['status' => 404]);
|
||||
return rest_ensure_response(['success' => true, 'data' => $this->list_for_order($number)]);
|
||||
}
|
||||
|
||||
public function rest_cancel(\WP_REST_Request $request) {
|
||||
$result = $this->cancel_for_user(get_current_user_id(), (string) $request->get_param('order_number'));
|
||||
return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]);
|
||||
}
|
||||
|
||||
public function create(int $user_id, string $order_number, string $reason) {
|
||||
$reason = sanitize_textarea_field($reason);
|
||||
if ($user_id <= 0 || !preg_match('/^(?:\d{20}|\d{22})$/', $order_number) || $reason === '' || mb_strlen($reason) > 2000) {
|
||||
return new \WP_Error('aksrt_after_sale_invalid', __('请填写有效的售后原因', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
global $wpdb;
|
||||
$lock = 'aksrt_after_sale_' . md5($order_number);
|
||||
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('订单正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
|
||||
try {
|
||||
$order = OrderRepository::get_instance()->get_user_order($user_id, $order_number);
|
||||
if (!$order || (string) ($order['status'] ?? '') !== Order::STATUS_PAID) return new \WP_Error('aksrt_after_sale_order', __('只有已支付订单可以申请售后', 'aksrt'), ['status' => 409]);
|
||||
$active = $this->find_active($order_number);
|
||||
if ($active) {
|
||||
if ((int) ($active['user_id'] ?? 0) === $user_id && hash_equals((string) ($active['reason'] ?? ''), $reason)) {
|
||||
return $active;
|
||||
}
|
||||
return new \WP_Error('aksrt_after_sale_exists', __('该订单已有未结束售后申请', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$amount = $this->refund_amount($order);
|
||||
if ($amount === null) return new \WP_Error('aksrt_after_sale_amount', __('订单退款金额无效', 'aksrt'), ['status' => 400]);
|
||||
$inserted = $wpdb->insert($this->table(), [
|
||||
'order_number' => $order_number, 'user_id' => $user_id, 'aftersale_type' => 'refund',
|
||||
'reason' => $reason, 'refund_amount' => $amount, 'status' => self::STATUS_PENDING,
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s']);
|
||||
return $inserted === 1 ? $this->get((int) $wpdb->insert_id) : new \WP_Error('aksrt_after_sale_db', __('售后申请保存失败', 'aksrt'), ['status' => 500]);
|
||||
} finally {
|
||||
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
|
||||
}
|
||||
}
|
||||
|
||||
public function cancel_for_user(int $user_id, string $order_number) {
|
||||
if ($user_id <= 0 || !preg_match('/^(?:\d{20}|\d{22})$/', $order_number)) return new \WP_Error('aksrt_after_sale_invalid', __('售后申请无效', 'aksrt'), ['status' => 400]);
|
||||
global $wpdb;
|
||||
$lock = 'aksrt_after_sale_' . md5($order_number);
|
||||
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('售后申请正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
|
||||
try {
|
||||
$row = $this->find_active($order_number);
|
||||
if (!$row || (int) ($row['user_id'] ?? 0) !== $user_id) return new \WP_Error('aksrt_after_sale_missing', __('没有可取消的售后申请', 'aksrt'), ['status' => 404]);
|
||||
if ((string) ($row['status'] ?? '') !== self::STATUS_PENDING) return new \WP_Error('aksrt_after_sale_state', __('售后申请已进入处理阶段,无法取消', 'aksrt'), ['status' => 409]);
|
||||
if (!$this->update((int) $row['id'], ['status' => self::STATUS_CANCELLED])) return new \WP_Error('aksrt_after_sale_db', __('取消售后申请失败', 'aksrt'), ['status' => 500]);
|
||||
return $this->get((int) $row['id']);
|
||||
} finally {
|
||||
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function list_for_order(string $order_number): array {
|
||||
global $wpdb;
|
||||
$rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id DESC", $order_number), ARRAY_A);
|
||||
return array_map([$this, 'normalize'], (array) $rows);
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function admin_list(array $args = []): array {
|
||||
global $wpdb;
|
||||
$where = [];
|
||||
$params = [];
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
$valid = [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED, self::STATUS_COMPLETED];
|
||||
if ($status !== '' && in_array($status, $valid, true)) { $where[] = 'status = %s'; $params[] = $status; }
|
||||
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
|
||||
$date = sanitize_text_field((string) ($args[$key] ?? ''));
|
||||
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
$where[] = 'created_at ' . $operator . ' %s';
|
||||
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
|
||||
}
|
||||
}
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
$sql = "SELECT * FROM {$this->table()}" . ($where ? ' WHERE ' . implode(' AND ', $where) : '') . ' ORDER BY id DESC LIMIT %d OFFSET %d';
|
||||
$params[] = $limit; $params[] = $offset;
|
||||
return array_map([$this, 'normalize'], (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A));
|
||||
}
|
||||
|
||||
public function count(array $args = []): int {
|
||||
global $wpdb;
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($status !== '') { $where[] = 'status = %s'; $params[] = $status; }
|
||||
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
|
||||
$date = sanitize_text_field((string) ($args[$key] ?? ''));
|
||||
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
$where[] = 'created_at ' . $operator . ' %s';
|
||||
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
|
||||
}
|
||||
}
|
||||
$sql = "SELECT COUNT(*) FROM {$this->table()}" . ($where ? ' WHERE ' . implode(' AND ', $where) : '');
|
||||
return (int) ($params ? $wpdb->get_var($wpdb->prepare($sql, $params)) : $wpdb->get_var($sql));
|
||||
}
|
||||
|
||||
public function count_user_open(int $user_id): int {
|
||||
if ($user_id <= 0) return 0;
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing','approved')", $user_id));
|
||||
}
|
||||
|
||||
/** A refund status transition is valid only for its own active application. */
|
||||
public function can_finalize_order_refund(int $after_sale_id, string $order_number): bool {
|
||||
if ($after_sale_id <= 0 || $order_number === '') return false;
|
||||
$row = $this->get($after_sale_id);
|
||||
return is_array($row)
|
||||
&& hash_equals((string) ($row['order_number'] ?? ''), $order_number)
|
||||
&& in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_APPROVED], true);
|
||||
}
|
||||
|
||||
public function review(int $id, string $decision, string $note = '') {
|
||||
if (!current_user_can('manage_options')) return new \WP_Error('aksrt_after_sale_forbidden', __('权限不足', 'aksrt'), ['status' => 403]);
|
||||
$decision = sanitize_key($decision);
|
||||
if (!in_array($decision, ['approve', 'reject', 'complete'], true)) return new \WP_Error('aksrt_after_sale_decision', __('审核动作无效', 'aksrt'), ['status' => 400]);
|
||||
global $wpdb;
|
||||
$initial = $this->get($id);
|
||||
if (!$initial) return new \WP_Error('aksrt_after_sale_state', __('售后申请已处理或不存在', 'aksrt'), ['status' => 409]);
|
||||
$order_number = (string) ($initial['order_number'] ?? '');
|
||||
$lock = 'aksrt_after_sale_' . md5($order_number);
|
||||
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('售后申请正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
|
||||
try {
|
||||
$row = $this->get($id);
|
||||
if (!$row || !hash_equals($order_number, (string) ($row['order_number'] ?? '')) || !in_array((string) $row['status'], [self::STATUS_PENDING, self::STATUS_PROCESSING], true)) return new \WP_Error('aksrt_after_sale_state', __('售后申请已处理或不存在', 'aksrt'), ['status' => 409]);
|
||||
$note = sanitize_textarea_field($note);
|
||||
if ((string) $row['status'] === self::STATUS_PROCESSING) {
|
||||
if ($decision === 'complete') {
|
||||
$gateway_result = json_decode((string) ($row['gateway_result'] ?? ''), true);
|
||||
if (!is_array($gateway_result) || sanitize_key((string) ($gateway_result['status'] ?? '')) !== 'manual') {
|
||||
return new \WP_Error('aksrt_after_sale_manual_only', __('该申请不是待人工确认的退款', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$order = OrderRepository::get_instance()->get((string) $row['order_number']);
|
||||
if (!$order) return new \WP_Error('aksrt_after_sale_order', __('订单不存在', 'aksrt'), ['status' => 404]);
|
||||
$finished = $this->finish_refund($row, $order, $note !== '' ? $note : (string) ($row['admin_note'] ?? ''));
|
||||
return is_wp_error($finished) ? $finished : $this->get((int) $row['id']);
|
||||
}
|
||||
if ($decision === 'reject') {
|
||||
$gateway_result = json_decode((string) ($row['gateway_result'] ?? ''), true);
|
||||
if (!is_array($gateway_result) || sanitize_key((string) ($gateway_result['status'] ?? '')) !== 'manual') {
|
||||
return new \WP_Error('aksrt_after_sale_processing', __('退款处理中,不能拒绝;请同步退款结果', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$order = OrderRepository::get_instance()->get((string) $row['order_number']);
|
||||
if (!$order) return new \WP_Error('aksrt_after_sale_order', __('订单不存在', 'aksrt'), ['status' => 404]);
|
||||
$type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? []));
|
||||
$revoke = $this->assets_to_revoke($order, $type, (array) ($order['meta_payload'] ?? []));
|
||||
if (is_wp_error($revoke)) return $revoke;
|
||||
if ($revoke !== []) {
|
||||
$compensated = $this->compensate_revoke($row, $order, $revoke, $note);
|
||||
if (is_wp_error($compensated)) return $compensated;
|
||||
}
|
||||
if (!$this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => $note !== '' ? $note : __('管理员确认未执行渠道退款,已恢复冻结资产', 'aksrt')])) {
|
||||
return new \WP_Error('aksrt_after_sale_db', __('售后审核保存失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return $this->get((int) $row['id']);
|
||||
}
|
||||
return $this->sync_gateway_refund($row, $note);
|
||||
}
|
||||
if ($decision === 'complete') return new \WP_Error('aksrt_after_sale_manual_only', __('只有待人工确认的退款可以直接完成', 'aksrt'), ['status' => 409]);
|
||||
if ($decision === 'reject') {
|
||||
if (!$this->update($id, ['status' => self::STATUS_REJECTED, 'admin_note' => $note, 'reviewed_at' => current_time('mysql')])) {
|
||||
return new \WP_Error('aksrt_after_sale_db', __('售后审核保存失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return $this->get($id);
|
||||
}
|
||||
return $this->approve($row, $note);
|
||||
} finally {
|
||||
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
|
||||
}
|
||||
}
|
||||
|
||||
private function approve(array $row, string $note) {
|
||||
$order_number = (string) $row['order_number'];
|
||||
$order = OrderRepository::get_instance()->get($order_number);
|
||||
if (!$order || (string) ($order['status'] ?? '') !== Order::STATUS_PAID) return new \WP_Error('aksrt_after_sale_order', __('订单已不是可退款状态', 'aksrt'), ['status' => 409]);
|
||||
$type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? []));
|
||||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||||
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||
if ($type === DomainModel::ORDER_PRODUCT_VIP && !$this->vip_reversal_is_safe($order, $meta)) return new \WP_Error('aksrt_after_sale_vip_snapshot', __('会员权益已变化或缺少快照,不能安全撤销', 'aksrt'), ['status' => 409]);
|
||||
$revoke = $this->assets_to_revoke($order, $type, $meta);
|
||||
if (is_wp_error($revoke)) return $revoke;
|
||||
if ($method === 'card_pass') {
|
||||
if (!$this->asset_entries_available($revoke)) return new \WP_Error('aksrt_after_sale_asset', __('当前资产不足,不能撤销卡密权益', 'aksrt'), ['status' => 409]);
|
||||
return $this->commit_status_refund($row, $order, $note, $revoke);
|
||||
}
|
||||
if ($method === 'free') {
|
||||
return $this->commit_status_refund($row, $order, $note, $revoke);
|
||||
}
|
||||
$refund_local = in_array($method, ['balance', 'points'], true);
|
||||
if ($refund_local) {
|
||||
$refund_asset = $method === 'points' ? AssetType::POINTS : AssetType::BALANCE;
|
||||
$refund_minor = $refund_asset === AssetType::POINTS ? max(0, (int) ($order['amount'] ?? 0)) : $this->cash_minor((string) ($row['refund_amount'] ?? '0'));
|
||||
if ($refund_minor <= 0) return new \WP_Error('aksrt_after_sale_amount', __('退款金额无效', 'aksrt'), ['status' => 400]);
|
||||
$entries = $revoke;
|
||||
// A points-purchase reversal already includes the original balance payment
|
||||
// in assets_to_revoke(); do not return that payment twice.
|
||||
if (!($type === DomainModel::ORDER_PRODUCT_POINTS && $method === 'balance')) {
|
||||
$entries[] = ['user_id' => (int) $order['user_id'], 'asset' => $refund_asset->value, 'delta_minor' => $refund_minor, 'entry_type' => 'credit', 'remark' => sprintf(__('订单 %s 售后退款', 'aksrt'), $order_number)];
|
||||
}
|
||||
return $this->commit_local_refund($row, $order, $entries, $note);
|
||||
}
|
||||
$gateway = Gateway::get_instance()->get_gateway($method);
|
||||
if (!$gateway || !method_exists($gateway, 'refund')) return new \WP_Error('aksrt_after_sale_gateway', __('支付渠道不可用,无法退款', 'aksrt'), ['status' => 409]);
|
||||
$request_id = (string) ($row['gateway_request_id'] ?? '') ?: 'AS_' . (int) $row['id'];
|
||||
if (!$this->asset_entries_available($revoke)) {
|
||||
return new \WP_Error('aksrt_after_sale_asset', __('当前资产不足,不能审核通过退款', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
if (!$this->update((int) $row['id'], ['status' => self::STATUS_PROCESSING, 'admin_note' => $note, 'gateway_request_id' => $request_id, 'reviewed_at' => current_time('mysql')])) {
|
||||
return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败,未发起退款', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
if ($revoke !== []) {
|
||||
$held = $this->post_asset_entries($row, $order, $revoke, 'after-sale-revoke', 'after_sale_revoke', $note);
|
||||
if (is_wp_error($held)) {
|
||||
$this->update((int) $row['id'], ['status' => self::STATUS_PENDING, 'gateway_request_id' => null, 'reviewed_at' => null]);
|
||||
return $held;
|
||||
}
|
||||
}
|
||||
try {
|
||||
$result = $gateway->refund($order_number, (string) $row['refund_amount'], $request_id);
|
||||
} catch (\Throwable $error) {
|
||||
$result = ['success' => false, 'status' => 'unknown', 'message' => $error->getMessage()];
|
||||
}
|
||||
if (!is_array($result)) {
|
||||
$result = ['success' => false, 'status' => 'unknown', 'message' => __('支付渠道未返回有效退款结果', 'aksrt')];
|
||||
}
|
||||
$this->update((int) $row['id'], ['gateway_result' => wp_json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'gateway_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? ''))]);
|
||||
$status = sanitize_key((string) ($result['status'] ?? 'unknown'));
|
||||
if ($status === 'failed') {
|
||||
if ($revoke !== []) {
|
||||
$compensated = $this->compensate_revoke($row, $order, $revoke, $note);
|
||||
if (is_wp_error($compensated)) {
|
||||
$this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但资产补偿失败;申请保持处理中,可再次同步重试补偿。', 'aksrt') . ' ' . $compensated->get_error_message()]);
|
||||
return $this->get((int) $row['id']);
|
||||
}
|
||||
}
|
||||
$this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => sanitize_textarea_field((string) ($result['message'] ?? $note))]);
|
||||
return $this->get((int) $row['id']);
|
||||
}
|
||||
// Missing, unknown, and pending provider outcomes are ambiguous. Keep the
|
||||
// application processing and the asset clawback in place until query_refund
|
||||
// explicitly confirms success or failure.
|
||||
if (empty($result['success']) || $status !== 'succeeded') {
|
||||
$this->update((int) $row['id'], ['admin_note' => sanitize_textarea_field((string) ($result['message'] ?? __('退款结果仍待支付渠道确认,请稍后同步或人工核对', 'aksrt')))]);
|
||||
return $this->get((int) $row['id']);
|
||||
}
|
||||
$finished = $this->finish_refund($row, $order, $note);
|
||||
return is_wp_error($finished) ? $finished : $this->get((int) $row['id']);
|
||||
}
|
||||
|
||||
private function commit_local_refund(array $row, array $order, array $entries, string $note) {
|
||||
$number = (string) $order['order_number'];
|
||||
$commission = CommissionLedger::get_instance()->prepare_full_refund($number);
|
||||
if (is_wp_error($commission)) return $commission;
|
||||
$guard = static fn() => CommissionLedger::get_instance()->guard_full_refund($commission);
|
||||
$mutation = function () use ($row, $order, $note, $commission) {
|
||||
$commission_guard = CommissionLedger::get_instance()->guard_full_refund($commission);
|
||||
if (is_wp_error($commission_guard) || $commission_guard !== true) return is_wp_error($commission_guard) ? $commission_guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]);
|
||||
$finished = $this->finish_refund($row, $order, $note, false);
|
||||
if ($finished === true) {
|
||||
LedgerService::get_instance()->after_commit(static function (): void {
|
||||
OrderRepository::get_instance()->dispatch_managed_status_events();
|
||||
});
|
||||
}
|
||||
return $finished;
|
||||
};
|
||||
$result = LedgerService::get_instance()->post(new LedgerCommand(
|
||||
\AKSRT\Ledger\OperationKey::deterministic('after-sale-refund', [(int) $row['id']]), 'order_refund', $entries,
|
||||
'order', $number, get_current_user_id(), ['order_number' => $number]
|
||||
), $guard, $mutation);
|
||||
if (is_wp_error($result)) return $result;
|
||||
CommissionLedger::get_instance()->clawback_after_refund($commission);
|
||||
return $this->get((int) $row['id']);
|
||||
}
|
||||
|
||||
private function post_asset_entries(array $row, array $order, array $entries, string $prefix, string $reason, string $note) {
|
||||
return LedgerService::get_instance()->post(new LedgerCommand(
|
||||
\AKSRT\Ledger\OperationKey::deterministic($prefix, [(int) $row['id']]), $reason, $entries,
|
||||
'order', (string) $order['order_number'], get_current_user_id(), ['order_number' => (string) $order['order_number'], 'after_sale_id' => (int) $row['id']]
|
||||
));
|
||||
}
|
||||
|
||||
private function commit_status_refund(array $row, array $order, string $note, array $entries = []) {
|
||||
global $wpdb;
|
||||
$number = (string) $order['order_number'];
|
||||
$commission = CommissionLedger::get_instance()->prepare_full_refund($number);
|
||||
if (is_wp_error($commission)) return $commission;
|
||||
if ($entries !== []) {
|
||||
$mutation = function () use ($row, $order, $note, $commission) {
|
||||
$guard = CommissionLedger::get_instance()->guard_full_refund($commission);
|
||||
if (is_wp_error($guard) || $guard !== true) return is_wp_error($guard) ? $guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]);
|
||||
$finished = $this->finish_refund($row, $order, $note, false);
|
||||
if ($finished === true) {
|
||||
LedgerService::get_instance()->after_commit(static function (): void { OrderRepository::get_instance()->dispatch_managed_status_events(); });
|
||||
}
|
||||
return $finished;
|
||||
};
|
||||
$result = LedgerService::get_instance()->post(new LedgerCommand(
|
||||
\AKSRT\Ledger\OperationKey::deterministic('after-sale-card-refund', [(int) $row['id']]), 'order_refund', $entries,
|
||||
'order', $number, get_current_user_id(), ['order_number' => $number]
|
||||
), static fn() => CommissionLedger::get_instance()->guard_full_refund($commission), $mutation);
|
||||
if (is_wp_error($result)) return $result;
|
||||
CommissionLedger::get_instance()->clawback_after_refund($commission);
|
||||
return $this->get((int) $row['id']);
|
||||
}
|
||||
if ($wpdb->query('START TRANSACTION') === false) return new \WP_Error('aksrt_after_sale_finish', __('无法开始退款状态更新', 'aksrt'), ['status' => 500]);
|
||||
$guard = CommissionLedger::get_instance()->guard_full_refund($commission);
|
||||
if (is_wp_error($guard) || $guard !== true) { $wpdb->query('ROLLBACK'); return is_wp_error($guard) ? $guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); }
|
||||
$finished = $this->finish_order_and_application($row, $order, $note);
|
||||
if (is_wp_error($finished) || !$finished) { $wpdb->query('ROLLBACK'); return is_wp_error($finished) ? $finished : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); }
|
||||
$applied = CommissionLedger::get_instance()->apply_full_refund($commission);
|
||||
if (is_wp_error($applied) || $wpdb->query('COMMIT') === false) { $wpdb->query('ROLLBACK'); return is_wp_error($applied) ? $applied : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); }
|
||||
OrderRepository::get_instance()->dispatch_managed_status_events();
|
||||
CommissionLedger::get_instance()->clawback_after_refund($commission);
|
||||
return $this->get((int) $row['id']);
|
||||
}
|
||||
|
||||
private function sync_gateway_refund(array $row, string $note) {
|
||||
$order = OrderRepository::get_instance()->get((string) $row['order_number']);
|
||||
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||
$gateway = Gateway::get_instance()->get_gateway($method);
|
||||
$request_id = (string) ($row['gateway_request_id'] ?? '');
|
||||
if (!$order || !$gateway || !method_exists($gateway, 'query_refund') || $request_id === '') return new \WP_Error('aksrt_after_sale_sync_unavailable', __('该支付渠道暂不支持自动查询退款状态', 'aksrt'), ['status' => 409]);
|
||||
try {
|
||||
$result = $gateway->query_refund($request_id, (string) $row['order_number']);
|
||||
} catch (\Throwable $error) {
|
||||
$result = ['success' => false, 'status' => 'unknown', 'message' => $error->getMessage()];
|
||||
}
|
||||
if (!is_array($result)) {
|
||||
$result = ['success' => false, 'status' => 'unknown', 'message' => __('支付渠道未返回有效退款结果', 'aksrt')];
|
||||
}
|
||||
$this->update((int) $row['id'], ['gateway_result' => wp_json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'gateway_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? $row['gateway_refund_id'] ?? ''))]);
|
||||
$status = sanitize_key((string) ($result['status'] ?? 'unknown'));
|
||||
if ($status === 'succeeded' && !empty($result['success'])) {
|
||||
$finished = $this->finish_refund($row, $order, $note !== '' ? $note : (string) ($row['admin_note'] ?? ''));
|
||||
return is_wp_error($finished) ? $finished : $this->get((int) $row['id']);
|
||||
}
|
||||
if ($status === 'failed' && !empty($result['success'])) {
|
||||
$type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? []));
|
||||
$revoke = $this->assets_to_revoke($order, $type, (array) ($order['meta_payload'] ?? []));
|
||||
if (is_wp_error($revoke)) {
|
||||
$this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但无法确认资产补偿内容;申请保持处理中,请人工核对。', 'aksrt') . ' ' . $revoke->get_error_message()]);
|
||||
return $this->get((int) $row['id']);
|
||||
}
|
||||
if ($revoke !== []) {
|
||||
$compensated = $this->compensate_revoke($row, $order, $revoke, $note);
|
||||
if (is_wp_error($compensated)) {
|
||||
$this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但资产补偿失败;申请保持处理中,可再次同步重试补偿。', 'aksrt') . ' ' . $compensated->get_error_message()]);
|
||||
return $this->get((int) $row['id']);
|
||||
}
|
||||
}
|
||||
$this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => $note !== '' ? $note : __('支付渠道退款失败,已恢复撤销的资产', 'aksrt')]);
|
||||
} elseif ($status !== 'succeeded') {
|
||||
$this->update((int) $row['id'], ['admin_note' => $note !== '' ? $note : __('退款结果仍待支付渠道确认,请稍后同步或人工核对', 'aksrt')]);
|
||||
}
|
||||
return $this->get((int) $row['id']);
|
||||
}
|
||||
|
||||
private function compensate_revoke(array $row, array $order, array $entries, string $note): bool|\WP_Error {
|
||||
$compensate = array_map(static function (array $entry): array { $entry['delta_minor'] = -(int) $entry['delta_minor']; $entry['entry_type'] = 'credit'; $entry['remark'] = __('退款渠道失败,恢复已扣回资产', 'aksrt'); return $entry; }, $entries);
|
||||
$result = $this->post_asset_entries($row, $order, $compensate, 'after-sale-revoke-compensate', 'after_sale_revoke_compensate', $note);
|
||||
return is_wp_error($result) ? $result : true;
|
||||
}
|
||||
|
||||
private function assets_to_revoke(array $order, string $type, array $meta): array|\WP_Error {
|
||||
$user_id = (int) ($order['user_id'] ?? 0);
|
||||
if ($type === DomainModel::ORDER_PRODUCT_RECHARGE) {
|
||||
$card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : [];
|
||||
try { $minor = AssetAmount::balance((string) ($meta['credited_amount'] ?? $meta['credit_amount'] ?? $card_meta['amount'] ?? ''))->minor(); } catch (\InvalidArgumentException $e) { return new \WP_Error('aksrt_after_sale_asset', __('充值到账金额缺失,不能退款', 'aksrt'), ['status' => 409]); }
|
||||
return $this->debit_entry($user_id, AssetType::BALANCE, $minor, __('撤销充值余额', 'aksrt'));
|
||||
}
|
||||
if ($type === DomainModel::ORDER_PRODUCT_POINTS) {
|
||||
$card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : [];
|
||||
$points = max(0, (int) ($meta['points'] ?? $card_meta['points'] ?? 0));
|
||||
if ($points <= 0) return new \WP_Error('aksrt_after_sale_asset', __('购买积分数量缺失,不能退款', 'aksrt'), ['status' => 409]);
|
||||
$entries = $this->debit_entry($user_id, AssetType::POINTS, $points, __('撤销订单发放积分', 'aksrt'));
|
||||
if (sanitize_key((string) ($order['payment_method'] ?? '')) === 'balance') {
|
||||
try { $paid = AssetAmount::balance((string) ($order['amount'] ?? ''))->minor(); } catch (\InvalidArgumentException $e) { return new \WP_Error('aksrt_after_sale_amount', __('积分订单支付金额无效', 'aksrt'), ['status' => 409]); }
|
||||
$entries[] = ['user_id' => $user_id, 'asset' => AssetType::BALANCE->value, 'delta_minor' => $paid, 'entry_type' => 'credit', 'remark' => __('退回购买积分使用的余额', 'aksrt')];
|
||||
}
|
||||
return $entries;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private function debit_entry(int $user_id, AssetType $asset, int $minor, string $remark): array|\WP_Error {
|
||||
if ($user_id <= 0 || $minor <= 0) return new \WP_Error('aksrt_after_sale_asset', __('待撤销资产金额无效', 'aksrt'), ['status' => 409]);
|
||||
return [['user_id' => $user_id, 'asset' => $asset->value, 'delta_minor' => -$minor, 'entry_type' => 'debit', 'remark' => $remark]];
|
||||
}
|
||||
|
||||
private function finish_refund(array $row, array $order, string $note, bool $manage_transaction = true) {
|
||||
global $wpdb;
|
||||
$number = (string) $order['order_number'];
|
||||
$commission = CommissionLedger::get_instance()->prepare_full_refund($number);
|
||||
if (is_wp_error($commission)) return $commission;
|
||||
if ($manage_transaction && $wpdb->query('START TRANSACTION') === false) return new \WP_Error('aksrt_after_sale_finish', __('无法开始退款状态更新', 'aksrt'), ['status' => 500]);
|
||||
$guarded = CommissionLedger::get_instance()->guard_full_refund($commission);
|
||||
if (is_wp_error($guarded) || $guarded !== true) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($guarded) ? $guarded : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); }
|
||||
$finished = $this->finish_order_and_application($row, $order, $note);
|
||||
if (is_wp_error($finished) || !$finished) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($finished) ? $finished : new \WP_Error('aksrt_after_sale_finish', __('退款成功但本地订单更新失败,请重试同步', 'aksrt'), ['status' => 500]); }
|
||||
$applied = CommissionLedger::get_instance()->apply_full_refund($commission);
|
||||
if (is_wp_error($applied) || ($manage_transaction && $wpdb->query('COMMIT') === false)) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($applied) ? $applied : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); }
|
||||
if ($manage_transaction) {
|
||||
OrderRepository::get_instance()->dispatch_managed_status_events();
|
||||
CommissionLedger::get_instance()->clawback_after_refund($commission);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function finish_order_and_application(array $row, array $order, string $note): bool|\WP_Error {
|
||||
$number = (string) $order['order_number'];
|
||||
if (!$this->revoke_vip_if_needed($order)) return new \WP_Error('aksrt_after_sale_vip', __('会员权益撤销失败', 'aksrt'), ['status' => 409]);
|
||||
if (!OrderRepository::get_instance()->update_status($number, Order::STATUS_REFUNDED, ['allow_refund' => true, 'after_sale_id' => (int) $row['id'], 'transaction_managed' => true, 'note' => $note !== '' ? $note : __('售后退款完成', 'aksrt')])) return new \WP_Error('aksrt_after_sale_order', __('订单退款状态更新失败', 'aksrt'), ['status' => 409]);
|
||||
return $this->update((int) $row['id'], ['status' => self::STATUS_COMPLETED, 'admin_note' => $note, 'completed_at' => current_time('mysql'), 'reviewed_at' => current_time('mysql')]);
|
||||
}
|
||||
|
||||
private function vip_reversal_is_safe(array $order, array $meta): bool {
|
||||
$before = is_array($meta['vip_before'] ?? null) ? $meta['vip_before'] : null;
|
||||
$after = is_array($meta['vip_after'] ?? null) ? $meta['vip_after'] : null;
|
||||
if (!$before || !$after || !class_exists('\\AKSRT\\Content\\VipService')) return false;
|
||||
$current = \AKSRT\Content\VipService::get_instance()->get_membership_snapshot((int) $order['user_id']);
|
||||
foreach (['level', 'expires', 'base_level', 'base_expires', 'expired_level'] as $key) {
|
||||
if ((int) ($current[$key] ?? 0) !== (int) ($after[$key] ?? 0)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function revoke_vip_if_needed(array $order): bool {
|
||||
$type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? []));
|
||||
if ($type !== DomainModel::ORDER_PRODUCT_VIP) return true;
|
||||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||||
if (!$this->vip_reversal_is_safe($order, $meta)) return false;
|
||||
$before = $meta['vip_before'];
|
||||
$service = \AKSRT\Content\VipService::get_instance();
|
||||
if ((int) ($before['level'] ?? 0) <= 0) {
|
||||
$result = $service->mutate_membership((int) $order['user_id'], 'disable', [], ['reason' => 'order_refund', 'source' => 'after_sale']);
|
||||
} elseif ((int) ($before['base_level'] ?? 0) === 0 && (int) ($before['base_expires'] ?? 0) === 0 && (int) ($before['expired_level'] ?? 0) === 0) {
|
||||
$result = $service->mutate_membership((int) $order['user_id'], 'set', ['level' => (int) $before['level'], 'expires' => (int) $before['expires']], ['reason' => 'order_refund', 'source' => 'after_sale']);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return !is_wp_error($result);
|
||||
}
|
||||
|
||||
private function refund_amount(array $order): ?string {
|
||||
if ((string) ($order['currency'] ?? '') === 'points') return (string) max(0, (int) ($order['amount'] ?? 0));
|
||||
try { return AssetAmount::balance((string) ($order['amount'] ?? '0'))->decimal(); } catch (\InvalidArgumentException $e) { return null; }
|
||||
}
|
||||
|
||||
private function cash_minor(string $value): int { try { return AssetAmount::balance($value)->minor(); } catch (\InvalidArgumentException $e) { return 0; } }
|
||||
|
||||
/** @param array<int,array<string,mixed>> $entries */
|
||||
private function asset_entries_available(array $entries): bool {
|
||||
$required = [];
|
||||
foreach ($entries as $entry) {
|
||||
$delta = (int) ($entry['delta_minor'] ?? 0);
|
||||
if ($delta < 0) {
|
||||
$key = (int) ($entry['user_id'] ?? 0) . ':' . (string) ($entry['asset'] ?? '');
|
||||
$required[$key] = ($required[$key] ?? 0) + abs($delta);
|
||||
}
|
||||
}
|
||||
foreach ($required as $key => $amount) {
|
||||
[$user_id, $asset] = explode(':', $key, 2);
|
||||
$balance = (string) $asset === AssetType::POINTS->value
|
||||
? \AKSRT\Credits\MetaAssetService::get_instance()->balance_points((int) $user_id)
|
||||
: \AKSRT\Credits\MetaAssetService::get_instance()->balance_minor((int) $user_id);
|
||||
if ($balance < (int) $amount) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $entries */
|
||||
private function find_active(string $number): ?array {
|
||||
global $wpdb;
|
||||
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s AND status IN ('pending','processing','approved') ORDER BY id DESC LIMIT 1", $number), ARRAY_A);
|
||||
return is_array($row) ? $this->normalize($row) : null;
|
||||
}
|
||||
|
||||
private function get(int $id): ?array {
|
||||
global $wpdb;
|
||||
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d", $id), ARRAY_A);
|
||||
return is_array($row) ? $this->normalize($row) : null;
|
||||
}
|
||||
|
||||
private function update(int $id, array $data): bool {
|
||||
global $wpdb;
|
||||
$formats = array_map(static fn($value): string => is_int($value) ? '%d' : '%s', array_values($data));
|
||||
return $wpdb->update($this->table(), $data, ['id' => $id], $formats, ['%d']) !== false;
|
||||
}
|
||||
|
||||
private function table(): string { return \AKSRT\Core\DBMigrator::table('order_aftersale'); }
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
private function normalize(array $row): array { $row['id'] = (int) ($row['id'] ?? 0); $row['user_id'] = (int) ($row['user_id'] ?? 0); return $row; }
|
||||
}
|
||||
@@ -10,7 +10,7 @@ defined('ABSPATH') || exit;
|
||||
/**
|
||||
* 站内余额支付网关(内置实现)。
|
||||
*
|
||||
* 仅扣统一资产账本中的现金余额。
|
||||
* 余额由 user meta 管理,LedgerService 在统一入口路由到资产服务。
|
||||
*/
|
||||
|
||||
class BalanceGateway implements GatewayInterface {
|
||||
@@ -54,7 +54,6 @@ class BalanceGateway implements GatewayInterface {
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return ['success' => false, 'message' => __('支付金额无效', 'aksrt')];
|
||||
}
|
||||
$transaction_code = 'BAL_' . $order_number;
|
||||
$guard = static function () use ($order_number, $user_id, $amount_minor) {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('order');
|
||||
@@ -88,10 +87,9 @@ class BalanceGateway implements GatewayInterface {
|
||||
sprintf(__('订单 %s 支付', 'aksrt'), $order_number),
|
||||
$user_id,
|
||||
['order_number' => $order_number],
|
||||
static function () use ($order_number, $transaction_code, $order) {
|
||||
static function () use ($order_number, $order) {
|
||||
$paid = OrderRepository::get_instance()->update_status($order_number, Order::STATUS_PAID, [
|
||||
'payment_method' => 'balance',
|
||||
'payment_transaction_id' => $transaction_code,
|
||||
'note' => (string) ($order['_ledger_paid_note'] ?? __('余额支付成功', 'aksrt')),
|
||||
'require_transition' => true,
|
||||
]);
|
||||
@@ -110,9 +108,7 @@ class BalanceGateway implements GatewayInterface {
|
||||
}
|
||||
return [
|
||||
'success' => true,
|
||||
'transaction_id' => $transaction_code,
|
||||
'ledger_transaction_id' => $result->transaction_id,
|
||||
'flow_number' => $result->flow_number,
|
||||
'transaction_id' => '',
|
||||
'replayed' => $result->replayed,
|
||||
'order_committed' => true,
|
||||
'message' => __('余额支付成功', 'aksrt'),
|
||||
@@ -140,141 +136,8 @@ class BalanceGateway implements GatewayInterface {
|
||||
}
|
||||
|
||||
public function refund(string $order_number, string $amount, string $request_id = ''): array {
|
||||
$order_repo = OrderRepository::get_instance();
|
||||
$order = $order_repo->get($order_number);
|
||||
|
||||
if (!$order) {
|
||||
return ['success' => false, 'message' => __('订单不存在', 'aksrt')];
|
||||
}
|
||||
|
||||
$user_id = (int) $order['user_id'];
|
||||
if (!$this->balance_enabled()) {
|
||||
return ['success' => false, 'message' => __('余额功能未启用', 'aksrt')];
|
||||
}
|
||||
|
||||
$request_id = sanitize_text_field($request_id);
|
||||
try {
|
||||
$amount_minor = \AKSRT\Ledger\AssetAmount::balance($amount)->minor();
|
||||
if ($amount_minor <= 0) {
|
||||
return ['success' => false, 'message' => __('退款金额无效', 'aksrt')];
|
||||
}
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::deterministic('order-refund', [$request_id !== '' ? $request_id : $order_number]);
|
||||
$history = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['operation_key' => $operation_key, 'page' => 1, 'per_page' => 1]);
|
||||
if (!empty($history['items'][0])) {
|
||||
$committed = $history['items'][0];
|
||||
$refund_entry = null;
|
||||
foreach ((array) ($committed['entries'] ?? []) as $entry) {
|
||||
if ((int) ($entry['user_id'] ?? 0) === $user_id
|
||||
&& (string) ($entry['asset'] ?? '') === \AKSRT\Ledger\AssetType::BALANCE->value
|
||||
&& (string) ($entry['entry_type'] ?? '') === 'refund'
|
||||
) {
|
||||
$refund_entry = $entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ((string) ($committed['reason'] ?? '') !== 'order_refund'
|
||||
|| (string) ($committed['ref_type'] ?? '') !== 'order'
|
||||
|| (string) ($committed['ref_key'] ?? '') !== $order_number
|
||||
|| !is_array($refund_entry)
|
||||
|| (int) ($refund_entry['delta_minor'] ?? 0) !== $amount_minor
|
||||
) {
|
||||
return ['success' => false, 'message' => __('相同退款标识已用于其他请求', 'aksrt')];
|
||||
}
|
||||
return [
|
||||
'success' => true,
|
||||
'refund_id' => 'BAL_REF_' . $order_number,
|
||||
'transaction_id' => (int) ($committed['id'] ?? 0),
|
||||
'ledger_transaction_id' => (int) ($committed['id'] ?? 0),
|
||||
'replayed' => true,
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => __('退款成功', 'aksrt'),
|
||||
];
|
||||
}
|
||||
$commission = CommissionLedger::get_instance();
|
||||
$commission_plan = $commission->prepare_full_refund($order_number);
|
||||
if (is_wp_error($commission_plan)) {
|
||||
return ['success' => false, 'message' => $commission_plan->get_error_message()];
|
||||
}
|
||||
$guard = static function () use ($order_number, $user_id, $amount_minor, $commission, $commission_plan) {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('order');
|
||||
$current = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT user_id, amount, payment_method, status FROM {$table} WHERE order_number = %s FOR UPDATE",
|
||||
$order_number
|
||||
), ARRAY_A);
|
||||
try {
|
||||
$order_minor = is_array($current)
|
||||
? \AKSRT\Ledger\AssetAmount::balance((string) ($current['amount'] ?? ''))->minor()
|
||||
: -1;
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$order_minor = -1;
|
||||
}
|
||||
if (!is_array($current)
|
||||
|| (int) ($current['user_id'] ?? 0) !== $user_id
|
||||
|| sanitize_key((string) ($current['payment_method'] ?? '')) !== 'balance'
|
||||
|| $order_minor !== $amount_minor
|
||||
|| !in_array((string) ($current['status'] ?? ''), [Order::STATUS_PAID, Order::STATUS_REFUNDING], true)
|
||||
) {
|
||||
return new \WP_Error('aksrt_order_refund_conflict', __('退款订单状态或金额已变化', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
return $commission->guard_full_refund($commission_plan);
|
||||
};
|
||||
$mutation = static function () use ($order_number, $commission, $commission_plan) {
|
||||
$repo = OrderRepository::get_instance();
|
||||
$current = $repo->get($order_number);
|
||||
if (!is_array($current)) {
|
||||
return new \WP_Error('aksrt_order_refund_failed', __('退款订单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
if ((string) ($current['status'] ?? '') !== Order::STATUS_REFUNDING
|
||||
&& !$repo->update_status($order_number, Order::STATUS_REFUNDING, ['note' => __('余额退款处理中', 'aksrt')])) {
|
||||
return new \WP_Error('aksrt_order_refund_failed', __('退款状态更新失败', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
if (!$repo->update_status($order_number, Order::STATUS_REFUNDED, ['note' => __('余额退款已完成', 'aksrt')])) {
|
||||
return new \WP_Error('aksrt_order_refund_failed', __('退款状态更新失败', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
return $commission->apply_full_refund($commission_plan);
|
||||
};
|
||||
// 只退买家余额。佣金收回不再作为分录混入本事务:即时入账模型下佣金在
|
||||
// 独立 COMMISSION 池,收回可能超出池余额(转为欠款),若作为分录会因池
|
||||
// 不足而中止买家退款。故 apply_full_refund 仅登记佣金明细账的冲正,真正
|
||||
// 的池收回 + 欠款挂账由 clawback_after_refund 在本事务提交后独立执行。
|
||||
$entries = [[
|
||||
'user_id' => $user_id,
|
||||
'asset' => \AKSRT\Ledger\AssetType::BALANCE->value,
|
||||
'delta_minor' => $amount_minor,
|
||||
'entry_type' => 'refund',
|
||||
'remark' => sprintf(__('订单 %s 退款', 'aksrt'), $order_number),
|
||||
]];
|
||||
$result = \AKSRT\Ledger\LedgerService::get_instance()->post(new \AKSRT\Ledger\LedgerCommand(
|
||||
$operation_key,
|
||||
'order_refund',
|
||||
$entries,
|
||||
'order',
|
||||
$order_number,
|
||||
0,
|
||||
['request_id' => $request_id, 'commission_id' => (int) ($commission_plan['id'] ?? 0)]
|
||||
), $guard, $mutation);
|
||||
if (is_wp_error($result)) {
|
||||
return ['success' => false, 'message' => $result->get_error_message() ?: __('退款失败', 'aksrt')];
|
||||
}
|
||||
// 退款事务已提交:从佣金池收回本单佣金,不足部分挂账为欠款(非关键路径)。
|
||||
if (!$result->replayed) {
|
||||
$commission->clawback_after_refund($commission_plan);
|
||||
}
|
||||
return [
|
||||
'success' => true,
|
||||
'refund_id' => 'BAL_REF_' . $order_number,
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'ledger_transaction_id' => $result->transaction_id,
|
||||
'flow_number' => $result->flow_number,
|
||||
'replayed' => $result->replayed,
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => __('退款成功', 'aksrt'),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
return ['success' => false, 'status' => 'failed', 'message' => __('余额订单售后请通过用户中心申请并由管理员审核', 'aksrt')];
|
||||
}
|
||||
|
||||
private function balance_enabled(): bool {
|
||||
if (!class_exists('\AKSRT\Settings\Settings')) {
|
||||
|
||||
+12
-44
@@ -403,7 +403,6 @@ class ChargeCard {
|
||||
if (is_wp_error($order_number)) {
|
||||
return $order_number;
|
||||
}
|
||||
$transaction_number = $this->card_pass_transaction_number();
|
||||
$guard = function () use ($card_id, $user_id, $submitted_card, $type) {
|
||||
global $wpdb;
|
||||
$current = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d FOR UPDATE", $card_id), ARRAY_A);
|
||||
@@ -424,9 +423,9 @@ class ChargeCard {
|
||||
}
|
||||
return true;
|
||||
};
|
||||
$mutation = function () use ($user_id, $type, $meta, $card_id, $order_number, $operation_key, $transaction_number) {
|
||||
$mutation = function () use ($user_id, $type, $meta, $card_id, $order_number, $operation_key) {
|
||||
global $wpdb;
|
||||
$created = $this->create_redeem_order($user_id, $type, $meta, $card_id, '', $operation_key, $order_number, Order::STATUS_PAID, $transaction_number);
|
||||
$created = $this->create_redeem_order($user_id, $type, $meta, $card_id, '', $operation_key, $order_number, Order::STATUS_PAID);
|
||||
if ($created !== $order_number) {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
@@ -464,21 +463,12 @@ class ChargeCard {
|
||||
if (is_array($existing_order) && (string) ($existing_order['order_number'] ?? '') !== '') {
|
||||
$order_number = (string) $existing_order['order_number'];
|
||||
}
|
||||
} else {
|
||||
// 账本命令必须保持确定性(payload_hash 含 metadata,塞入随机订单号会破坏幂等重放),
|
||||
// 因此在账本提交之后再把流水行挂到订单上。
|
||||
\AKSRT\Finance\FinancialFlowService::get_instance()->attach_order_to_ledger_flow(
|
||||
(int) $result->transaction_id,
|
||||
$order_number,
|
||||
$transaction_number
|
||||
);
|
||||
}
|
||||
$balance = $result->balance($user_id, $asset);
|
||||
return [
|
||||
'success' => true,
|
||||
'type' => $type,
|
||||
'order_number' => $order_number,
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'replayed' => $result->replayed,
|
||||
'message' => $asset === \AKSRT\Ledger\AssetType::BALANCE
|
||||
? sprintf(__('卡密充值成功,充值金额:%s 元', 'aksrt'), \AKSRT\Ledger\AssetAmount::from_minor($asset, $amount_minor)->decimal())
|
||||
@@ -516,8 +506,6 @@ class ChargeCard {
|
||||
if (is_wp_error($order_number)) {
|
||||
return $order_number;
|
||||
}
|
||||
$transaction_number = $this->card_pass_transaction_number();
|
||||
|
||||
for ($attempt = 1; $attempt <= 3; $attempt++) {
|
||||
if ($wpdb->query('START TRANSACTION') === false) {
|
||||
return new \WP_Error('aksrt_card_db', __('无法开启卡密兑换事务', 'aksrt'), ['status' => 500]);
|
||||
@@ -566,6 +554,8 @@ class ChargeCard {
|
||||
$current_level = max(0, (int) get_user_meta($user_id, 'aksrt_vip_level', true));
|
||||
$current_expiry = max(0, (int) get_user_meta($user_id, 'aksrt_vip_expires', true));
|
||||
$activation_base = max($current_expiry, time());
|
||||
$vip_service = \AKSRT\Content\VipService::get_instance();
|
||||
$vip_before = $vip_service->get_membership_snapshot($user_id);
|
||||
$created = $this->create_redeem_order(
|
||||
$user_id,
|
||||
self::TYPE_VIP,
|
||||
@@ -574,29 +564,13 @@ class ChargeCard {
|
||||
(string) ($current['password'] ?? ''),
|
||||
$operation_key,
|
||||
$order_number,
|
||||
Order::STATUS_PAID,
|
||||
$transaction_number
|
||||
Order::STATUS_PAID
|
||||
);
|
||||
if ($created !== $order_number) {
|
||||
throw new \RuntimeException('vip_card_order_create_failed');
|
||||
}
|
||||
|
||||
// 会员卡没有账本参与(入账的是 usermeta 而非账本资产),流水要单独补一条,
|
||||
// 且它不产生任何现金/资产分录(0 元凭证)。同一事务内写入,失败即整体回滚。
|
||||
$flow_number = \AKSRT\Finance\FinancialFlowService::get_instance()->record_card_pass_payment([
|
||||
'order_number' => $order_number,
|
||||
'user_id' => $user_id,
|
||||
'amount' => '0.00',
|
||||
'currency' => 'CNY',
|
||||
'status' => Order::STATUS_PAID,
|
||||
'payment_method' => 'card_pass',
|
||||
'payment_transaction_id' => $transaction_number,
|
||||
], true);
|
||||
if (is_wp_error($flow_number)) {
|
||||
throw new \RuntimeException('vip_card_flow_failed');
|
||||
}
|
||||
|
||||
\AKSRT\Content\VipService::get_instance()->activate_vip($user_id, $level, $days);
|
||||
$vip_service->activate_vip($user_id, $level, $days);
|
||||
$actual_level = max(0, (int) get_user_meta($user_id, 'aksrt_vip_level', true));
|
||||
$actual_expiry = max(0, (int) get_user_meta($user_id, 'aksrt_vip_expires', true));
|
||||
$permanent_preserved = $days > 0 && $current_level > 0 && $level <= $current_level && $current_expiry === 0;
|
||||
@@ -606,6 +580,10 @@ class ChargeCard {
|
||||
if ($actual_level < max($current_level, $level) || !$expiry_valid) {
|
||||
throw new \RuntimeException('vip_card_fulfillment_failed');
|
||||
}
|
||||
$vip_after = $vip_service->get_membership_snapshot($user_id);
|
||||
if (!OrderRepository::get_instance()->merge_meta($order_number, ['vip_before' => $vip_before, 'vip_after' => $vip_after])) {
|
||||
throw new \RuntimeException('vip_card_snapshot_failed');
|
||||
}
|
||||
|
||||
$used = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$this->table()} SET status = %s, used_by = %d, used_at = %s, order_number = %s, balance = 0 WHERE id = %d AND status = %s",
|
||||
@@ -639,7 +617,6 @@ class ChargeCard {
|
||||
'success' => true,
|
||||
'type' => self::TYPE_VIP,
|
||||
'order_number' => $order_number,
|
||||
'transaction_id' => 0,
|
||||
'replayed' => false,
|
||||
'message' => $message,
|
||||
'level' => $level,
|
||||
@@ -699,7 +676,6 @@ class ChargeCard {
|
||||
'success' => true,
|
||||
'type' => self::TYPE_VIP,
|
||||
'order_number' => (string) $order['order_number'],
|
||||
'transaction_id' => 0,
|
||||
'replayed' => true,
|
||||
'message' => $this->vip_card_message($level, $days),
|
||||
'level' => $level,
|
||||
@@ -742,18 +718,10 @@ class ChargeCard {
|
||||
return $order_number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡密核销订单的渠道交易号:与资金流水号同源,统一走流水号分配器,
|
||||
* 不再消费任何后台前缀配置。
|
||||
*/
|
||||
private function card_pass_transaction_number(): string {
|
||||
return \AKSRT\Finance\BusinessNumber::allocate();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $meta
|
||||
*/
|
||||
private function create_redeem_order(int $user_id, string $type, array $meta, int $card_id, string $password, string $request_key = '', string $order_number = '', string $status = '', string $transaction_number = ''): string {
|
||||
private function create_redeem_order(int $user_id, string $type, array $meta, int $card_id, string $password, string $request_key = '', string $order_number = '', string $status = ''): string {
|
||||
if (!class_exists('AKSRT\\Payment\\OrderRepository') || !class_exists('\AKSRT\Core\DomainModel')) {
|
||||
return '';
|
||||
}
|
||||
@@ -791,7 +759,7 @@ class ChargeCard {
|
||||
'currency' => $currency,
|
||||
'status' => $order_status,
|
||||
'payment_method' => 'card_pass',
|
||||
'payment_transaction_id' => $order_status === Order::STATUS_PAID ? $transaction_number : '',
|
||||
'payment_transaction_id' => '',
|
||||
'paid_at' => $order_status === Order::STATUS_PAID ? current_time('mysql') : null,
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'request_key' => $request_key,
|
||||
|
||||
@@ -10,6 +10,7 @@ final class CommissionLedger {
|
||||
private function __construct() {}
|
||||
public function init(): void {
|
||||
add_action('rest_api_init', [$this, 'register_routes']);
|
||||
add_action('aksrt_order_commission', [$this, 'record_order_transition']);
|
||||
// 佣金已改为即时入账,历史遗留的结算 cron 需主动清理(无兼容层)。
|
||||
$timestamp = wp_next_scheduled('aksrt_affiliate_settle_due');
|
||||
if ($timestamp) {
|
||||
@@ -45,7 +46,14 @@ final class CommissionLedger {
|
||||
if ($currency === \AKSRT\Ledger\AssetType::POINTS->value) return 0;
|
||||
if (!\AKSRT\Settings\Settings::affiliate_enabled()) return 0;
|
||||
$type = sanitize_key((string) ($order['product_type'] ?? ''));
|
||||
$map = ['vip' => 'membership', 'resource' => 'content'];
|
||||
$map = [
|
||||
'vip' => 'membership',
|
||||
'resource' => 'content',
|
||||
'reading' => 'content',
|
||||
'download' => 'content',
|
||||
'image' => 'content',
|
||||
'video' => 'content',
|
||||
];
|
||||
$order_type = $map[$type] ?? $type;
|
||||
$settings = \AKSRT\Settings\Settings::get_affiliate_settings();
|
||||
$order_no = sanitize_text_field((string) ($order['order_number'] ?? ''));
|
||||
|
||||
@@ -250,7 +250,7 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
|
||||
|
||||
public function query_refund(string $request_id, string $order_number = ''): array {
|
||||
if (!$this->is_configured() || $request_id === '') {
|
||||
return ['success' => false, 'status' => 'failed', 'message' => __('支付宝退款查询参数无效', 'aksrt')];
|
||||
return ['success' => false, 'status' => 'unknown', 'message' => __('支付宝退款查询不可用,结果待确认', 'aksrt')];
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -225,7 +225,7 @@ class EpayGateway implements \AKSRT\Payment\GatewayInterface {
|
||||
public function refund(string $order_number, string $amount, string $request_id = ''): array {
|
||||
return [
|
||||
'success' => false,
|
||||
'status' => 'failed',
|
||||
'status' => 'manual',
|
||||
'message' => __('易支付退款请在商户后台人工处理', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
|
||||
public function refund(string $order_number, string $amount, string $request_id = ''): array {
|
||||
return [
|
||||
'success' => false,
|
||||
'status' => 'manual',
|
||||
'message' => __('PayPal 退款需要通过后台手动处理', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
|
||||
|
||||
public function query_refund(string $request_id, string $order_number = ''): array {
|
||||
if (!$this->is_configured() || $request_id === '') {
|
||||
return ['success' => false, 'status' => 'failed', 'message' => __('Stripe 退款查询参数无效', 'aksrt')];
|
||||
return ['success' => false, 'status' => 'unknown', 'message' => __('Stripe 退款查询不可用,结果待确认', 'aksrt')];
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -344,7 +344,7 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
|
||||
|
||||
public function query_refund(string $request_id, string $order_number = ''): array {
|
||||
if (!$this->is_configured() || $request_id === '') {
|
||||
return ['success' => false, 'status' => 'failed', 'message' => __('微信退款查询参数无效', 'aksrt')];
|
||||
return ['success' => false, 'status' => 'unknown', 'message' => __('微信退款查询不可用,结果待确认', 'aksrt')];
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -11,7 +11,6 @@ class Order {
|
||||
|
||||
public const STATUS_PENDING = \AKSRT\Core\DomainModel::ORDER_STATUS_PENDING;
|
||||
public const STATUS_PAID = \AKSRT\Core\DomainModel::ORDER_STATUS_PAID;
|
||||
public const STATUS_REFUNDING = \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING;
|
||||
public const STATUS_REFUNDED = \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED;
|
||||
public const STATUS_CLOSED = \AKSRT\Core\DomainModel::ORDER_STATUS_CLOSED;
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ final class OrderCenterQuery {
|
||||
public const STATUS_ALL = 'all';
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PAID = 'paid';
|
||||
public const STATUS_REFUNDING = 'refunding';
|
||||
public const STATUS_REFUNDED = 'refunded';
|
||||
public const STATUS_CLOSED = 'closed';
|
||||
|
||||
@@ -24,7 +23,6 @@ final class OrderCenterQuery {
|
||||
self::STATUS_ALL,
|
||||
self::STATUS_PENDING,
|
||||
self::STATUS_PAID,
|
||||
self::STATUS_REFUNDING,
|
||||
self::STATUS_REFUNDED,
|
||||
self::STATUS_CLOSED,
|
||||
];
|
||||
@@ -44,6 +42,9 @@ final class OrderCenterQuery {
|
||||
*/
|
||||
public static function from_array(array $raw, int $default_per_page = 20): self {
|
||||
$status = sanitize_key((string) ($raw['status'] ?? self::STATUS_ALL));
|
||||
if ($status === 'refunding') {
|
||||
$status = self::STATUS_PAID;
|
||||
}
|
||||
if (!in_array($status, self::STATUS_GROUPS, true)) {
|
||||
$status = self::STATUS_ALL;
|
||||
}
|
||||
@@ -92,7 +93,10 @@ final class OrderCenterQuery {
|
||||
/** @return string[] */
|
||||
public function database_statuses(): array {
|
||||
if ($this->status() === self::STATUS_ALL) {
|
||||
return array_keys(DomainModel::order_status_labels());
|
||||
return array_merge(array_keys(DomainModel::order_status_labels()), ['refunding']);
|
||||
}
|
||||
if ($this->status() === self::STATUS_PAID) {
|
||||
return [self::STATUS_PAID, 'refunding'];
|
||||
}
|
||||
return [$this->status()];
|
||||
}
|
||||
|
||||
@@ -193,8 +193,31 @@ trait OrderRepositoryAdmin {
|
||||
return ['items' => [], 'total' => 0];
|
||||
}
|
||||
|
||||
$where = ['product_type = %s', 'product_id > 0'];
|
||||
$params = [$product_type];
|
||||
$content_types = [
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO,
|
||||
];
|
||||
$legacy_pay_types = [
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING => \AKSRT\Core\DomainModel::PAY_TYPE_READING,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD => \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE => \AKSRT\Core\DomainModel::PAY_TYPE_GALLERY,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO => \AKSRT\Core\DomainModel::PAY_TYPE_VIDEO,
|
||||
];
|
||||
$where = ['product_id > 0'];
|
||||
$params = [];
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
|
||||
$where[] = 'product_type IN (' . implode(', ', array_fill(0, count($content_types), '%s')) . ')';
|
||||
array_push($params, ...$content_types);
|
||||
} elseif (isset($legacy_pay_types[$product_type])) {
|
||||
$where[] = "(product_type = %s OR (product_type = %s AND note LIKE %s))";
|
||||
array_push($params, $product_type, \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE, '%"pay_type":"' . $wpdb->esc_like($legacy_pay_types[$product_type]) . '"%');
|
||||
} else {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
}
|
||||
|
||||
$status_placeholders = implode(', ', array_fill(0, count($status_in), '%s'));
|
||||
$where[] = "status IN ({$status_placeholders})";
|
||||
@@ -236,14 +259,14 @@ trait OrderRepositoryAdmin {
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
|
||||
$list_sql = "SELECT product_id,
|
||||
product_type,
|
||||
MIN(product_type) AS product_type,
|
||||
currency,
|
||||
COUNT(*) AS sales_count,
|
||||
AVG(amount) AS avg_amount,
|
||||
SUM(amount) AS revenue
|
||||
FROM {$table}
|
||||
{$where_sql}
|
||||
GROUP BY product_id, product_type, currency
|
||||
GROUP BY product_id, currency
|
||||
ORDER BY {$orderby_col} {$order_dir}
|
||||
LIMIT %d OFFSET %d";
|
||||
$list_params = $params;
|
||||
@@ -252,6 +275,7 @@ trait OrderRepositoryAdmin {
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare($list_sql, $list_params), ARRAY_A);
|
||||
$items = [];
|
||||
$aggregate = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$sales_count = max(1, (int) ($row['sales_count'] ?? 0));
|
||||
$is_points = (string) ($row['currency'] ?? '') === 'points';
|
||||
@@ -269,12 +293,37 @@ trait OrderRepositoryAdmin {
|
||||
intdiv($revenue_minor + intdiv($sales_count, 2), $sales_count)
|
||||
);
|
||||
}
|
||||
$items[] = [
|
||||
$pid = (int) ($row['product_id'] ?? 0);
|
||||
if (!isset($aggregate[$pid])) {
|
||||
$aggregate[$pid] = [
|
||||
'product_id' => (int) ($row['product_id'] ?? 0),
|
||||
'product_type' => (string) ($row['product_type'] ?? $product_type),
|
||||
'sales_count' => (int) ($row['sales_count'] ?? 0),
|
||||
'avg_amount' => $avg_amount,
|
||||
'revenue' => $revenue,
|
||||
'product_type' => $product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE ? $product_type : (string) ($row['product_type'] ?? $product_type),
|
||||
'sales_count' => 0,
|
||||
'avg_amount' => 0,
|
||||
'revenue_minor' => 0,
|
||||
'currency' => (string) ($row['currency'] ?? 'CNY'),
|
||||
];
|
||||
}
|
||||
$aggregate[$pid]['sales_count'] += (int) ($row['sales_count'] ?? 0);
|
||||
if ((string) ($row['currency'] ?? '') === 'points') {
|
||||
$aggregate[$pid]['revenue_minor'] += (int) $revenue;
|
||||
} else {
|
||||
$aggregate[$pid]['revenue_minor'] += \AKSRT\Ledger\AssetAmount::balance_minor((string) $revenue);
|
||||
}
|
||||
}
|
||||
foreach ($aggregate as $item) {
|
||||
$count = max(1, (int) $item['sales_count']);
|
||||
$is_points = $item['currency'] === 'points';
|
||||
$revenue = $is_points
|
||||
? (int) $item['revenue_minor']
|
||||
: \AKSRT\Ledger\AssetAmount::decimal_from_minor((int) $item['revenue_minor']);
|
||||
$average_minor = intdiv((int) $item['revenue_minor'] + intdiv($count, 2), $count);
|
||||
$items[] = [
|
||||
'product_id' => (int) $item['product_id'],
|
||||
'product_type' => (string) $item['product_type'],
|
||||
'sales_count' => (int) $item['sales_count'],
|
||||
'avg_amount' => $is_points ? $average_minor : \AKSRT\Ledger\AssetAmount::decimal_from_minor($average_minor),
|
||||
'revenue' => $revenue,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -344,17 +393,39 @@ trait OrderRepositoryAdmin {
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
$valid_statuses = array_keys(\AKSRT\Core\DomainModel::order_status_labels());
|
||||
if ($status !== '' && $status !== 'all' && in_array($status, $valid_statuses, true)) {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
if ($status === 'paid') {
|
||||
$where[] = "status IN (%s, 'refunding')";
|
||||
$params[] = $status;
|
||||
} else {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
} else {
|
||||
$where[] = 'status IN (' . implode(', ', array_fill(0, count($valid_statuses), '%s')) . ')';
|
||||
array_push($params, ...$valid_statuses);
|
||||
$where[] = "status IN (" . implode(', ', array_fill(0, count($valid_statuses), '%s')) . ", 'refunding')";
|
||||
array_push($params, ...$valid_statuses);
|
||||
}
|
||||
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
|
||||
if ($product_type !== '') {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
|
||||
$types = \AKSRT\Core\DomainModel::resource_order_product_types();
|
||||
$where[] = 'product_type IN (' . implode(', ', array_fill(0, count($types), '%s')) . ')';
|
||||
array_push($params, ...$types);
|
||||
} else {
|
||||
$legacy_pay_types = [
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING => \AKSRT\Core\DomainModel::PAY_TYPE_READING,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD => \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE => \AKSRT\Core\DomainModel::PAY_TYPE_GALLERY,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO => \AKSRT\Core\DomainModel::PAY_TYPE_VIDEO,
|
||||
];
|
||||
if (isset($legacy_pay_types[$product_type])) {
|
||||
$where[] = "(product_type = %s OR (product_type = %s AND note LIKE %s))";
|
||||
array_push($params, $product_type, \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE, '%"pay_type":"' . $wpdb->esc_like($legacy_pay_types[$product_type]) . '"%');
|
||||
} else {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$product_id = (int) ($args['product_id'] ?? 0);
|
||||
@@ -383,24 +454,35 @@ trait OrderRepositoryAdmin {
|
||||
}
|
||||
|
||||
if ($search !== '') {
|
||||
$flow_table = \AKSRT\Core\DBMigrator::table('financial_flow');
|
||||
$flow_match = "EXISTS (SELECT 1 FROM {$flow_table} ff WHERE ff.order_number = " . $this->table() . '.order_number AND ff.flow_number LIKE %s)';
|
||||
// Numeric search may mean user_id or product_id; also match order or flow number / type.
|
||||
if (ctype_digit($search)) {
|
||||
$where[] = "(order_number LIKE %s OR product_type LIKE %s OR user_id = %d OR product_id = %d OR {$flow_match})";
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = (int) $search;
|
||||
$params[] = (int) $search;
|
||||
$params[] = $like;
|
||||
} else {
|
||||
$where[] = "(order_number LIKE %s OR product_type LIKE %s OR payment_method LIKE %s OR {$flow_match})";
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$user_table = $wpdb->users;
|
||||
$user_match = "EXISTS (SELECT 1 FROM {$user_table} u WHERE u.ID = " . $this->table() . '.user_id AND (u.user_login LIKE %s OR u.user_email LIKE %s OR u.display_name LIKE %s))';
|
||||
$post_table = $wpdb->posts;
|
||||
$product_match = "EXISTS (SELECT 1 FROM {$post_table} p WHERE p.ID = " . $this->table() . '.product_id AND p.post_title LIKE %s)';
|
||||
$meta_match = "note LIKE %s";
|
||||
// Search matches the public order number, account, product title, and stored product label.
|
||||
if (ctype_digit($search)) {
|
||||
$where[] = "(order_number LIKE %s OR product_type LIKE %s OR user_id = %d OR product_id = %d OR {$user_match} OR {$product_match} OR {$meta_match})";
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = (int) $search;
|
||||
$params[] = (int) $search;
|
||||
array_push($params, $like, $like, $like, $like, $like);
|
||||
} else {
|
||||
$where[] = "(order_number LIKE %s OR product_type LIKE %s OR payment_method LIKE %s OR {$user_match} OR {$product_match} OR {$meta_match})";
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
array_push($params, $like, $like, $like, $like, $like);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
|
||||
$date = sanitize_text_field((string) ($args[$key] ?? ''));
|
||||
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
$where[] = 'created_at ' . $operator . ' %s';
|
||||
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ trait OrderRepositoryCore {
|
||||
* their existing user-wide semantics, so changing a filter does not change the
|
||||
* summary cards. The subquery avoids four independent COUNT statements.
|
||||
*
|
||||
* @return array{total:int,stats:array{total:int,pending:int,paid:int}}
|
||||
* @return array{total:int,stats:array<string,int>}
|
||||
*/
|
||||
public function get_user_order_center_summary(int $user_id, array $args = []): array {
|
||||
global $wpdb;
|
||||
@@ -251,7 +251,7 @@ trait OrderRepositoryCore {
|
||||
if ($user_id <= 0) {
|
||||
return [
|
||||
'total' => 0,
|
||||
'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0],
|
||||
'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0, 'refunded' => 0, 'closed' => 0, 'after_sale_pending' => 0],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -264,12 +264,15 @@ trait OrderRepositoryCore {
|
||||
(SELECT COUNT(*) FROM {$this->table()} {$parts['where']}) AS filtered_total,
|
||||
COUNT(*) AS known_total,
|
||||
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS pending,
|
||||
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS paid
|
||||
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS paid,
|
||||
SUM(CASE WHEN status = 'refunding' THEN 1 ELSE 0 END) AS paid_legacy,
|
||||
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS refunded,
|
||||
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS closed
|
||||
FROM {$this->table()}
|
||||
WHERE user_id = %d AND status IN ({$known_placeholders})";
|
||||
$params = array_merge(
|
||||
$parts['params'],
|
||||
[Order::STATUS_PENDING, Order::STATUS_PAID],
|
||||
[Order::STATUS_PENDING, Order::STATUS_PAID, Order::STATUS_REFUNDED, Order::STATUS_CLOSED],
|
||||
[$user_id],
|
||||
$known_statuses
|
||||
);
|
||||
@@ -280,7 +283,10 @@ trait OrderRepositoryCore {
|
||||
'stats' => [
|
||||
'total' => (int) ($row['known_total'] ?? 0),
|
||||
'pending' => (int) ($row['pending'] ?? 0),
|
||||
'paid' => (int) ($row['paid'] ?? 0),
|
||||
'paid' => (int) ($row['paid'] ?? 0) + (int) ($row['paid_legacy'] ?? 0),
|
||||
'refunded' => (int) ($row['refunded'] ?? 0),
|
||||
'closed' => (int) ($row['closed'] ?? 0),
|
||||
'after_sale_pending' => class_exists(AfterSaleService::class) ? AfterSaleService::get_instance()->count_user_open($user_id) : 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -340,8 +346,16 @@ trait OrderRepositoryCore {
|
||||
|
||||
|
||||
|
||||
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
|
||||
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
|
||||
global $wpdb;
|
||||
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED && empty($extra['allow_refund'])) {
|
||||
return false;
|
||||
}
|
||||
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED
|
||||
&& (!class_exists(AfterSaleService::class) || !AfterSaleService::get_instance()->can_finalize_order_refund((int) ($extra['after_sale_id'] ?? 0), $order_number))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$preflight = $this->get($order_number);
|
||||
if (!$preflight) {
|
||||
@@ -349,7 +363,7 @@ trait OrderRepositoryCore {
|
||||
}
|
||||
if (
|
||||
$new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID
|
||||
&& (string) ($preflight['status'] ?? '') !== \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING
|
||||
&& (string) ($preflight['status'] ?? '') !== 'refunding'
|
||||
&& sanitize_key((string) ($preflight['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|
||||
&& class_exists('\AKSRT\Credits\BalanceService')
|
||||
) {
|
||||
@@ -367,7 +381,8 @@ trait OrderRepositoryCore {
|
||||
if ($root_call) {
|
||||
$this->status_after_commit_events = [];
|
||||
}
|
||||
$owns_transaction = $root_call && !$ledger_active;
|
||||
$transaction_managed = !empty($extra['transaction_managed']);
|
||||
$owns_transaction = $root_call && !$ledger_active && !$transaction_managed;
|
||||
if ($owns_transaction && $wpdb->query('START TRANSACTION') === false) {
|
||||
return false;
|
||||
}
|
||||
@@ -398,6 +413,8 @@ trait OrderRepositoryCore {
|
||||
\AKSRT\Ledger\LedgerService::get_instance()->after_commit(function () use ($events): void {
|
||||
$this->dispatch_status_events($events);
|
||||
});
|
||||
} elseif ($transaction_managed) {
|
||||
$this->status_after_commit_events = $events;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -425,14 +442,18 @@ trait OrderRepositoryCore {
|
||||
$valid = \AKSRT\Core\DomainModel::order_status_labels();
|
||||
if (!isset($valid[$new_status])) return false;
|
||||
|
||||
$order_row = $wpdb->get_row($wpdb->prepare(
|
||||
$order_row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table()} WHERE order_number = %s FOR UPDATE",
|
||||
$order_number
|
||||
), ARRAY_A);
|
||||
$database_status = is_array($order_row) ? sanitize_key((string) ($order_row['status'] ?? '')) : '';
|
||||
$order = is_array($order_row) ? $this->normalize_row($order_row) : null;
|
||||
if (!$order) return false;
|
||||
|
||||
$current_status = (string) ($order['status'] ?? '');
|
||||
$current_status = (string) ($order['status'] ?? '');
|
||||
if ($database_status === 'refunding' && $new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED) {
|
||||
$current_status = \AKSRT\Core\DomainModel::ORDER_STATUS_PAID;
|
||||
}
|
||||
|
||||
if (!\AKSRT\Core\DomainModel::can_transition_order_status($current_status, $new_status)) {
|
||||
return false;
|
||||
@@ -457,12 +478,7 @@ trait OrderRepositoryCore {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
in_array($new_status, [
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING,
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED,
|
||||
], true)
|
||||
) {
|
||||
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED) {
|
||||
$existing_paid = is_string($order['paid_at'] ?? null) ? trim((string) $order['paid_at']) : '';
|
||||
if ($existing_paid === '' || str_starts_with($existing_paid, '0000-00-00')) {
|
||||
if (!isset($update_data['paid_at'])) {
|
||||
@@ -502,7 +518,7 @@ trait OrderRepositoryCore {
|
||||
$result = $wpdb->update(
|
||||
$this->table(),
|
||||
$update_data,
|
||||
['order_number' => $order_number, 'status' => $current_status],
|
||||
['order_number' => $order_number, 'status' => $database_status],
|
||||
$format,
|
||||
['%s', '%s']
|
||||
);
|
||||
@@ -550,26 +566,13 @@ trait OrderRepositoryCore {
|
||||
}
|
||||
}
|
||||
|
||||
if (!$ledger_active) {
|
||||
$flow_number = \AKSRT\Finance\FinancialFlowService::get_instance()->record_order_payment($paid_order, true);
|
||||
if (is_wp_error($flow_number)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (class_exists('\AKSRT\Payment\CommissionLedger')) {
|
||||
$commission = \AKSRT\Payment\CommissionLedger::get_instance()->record_order_transition(
|
||||
$order_number,
|
||||
$new_status,
|
||||
$paid_order
|
||||
);
|
||||
if (is_wp_error($commission)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$this->status_after_commit_events[] = [
|
||||
'hook' => 'aksrt_order_commission',
|
||||
'args' => [$order_number, $new_status, $paid_order],
|
||||
];
|
||||
|
||||
$product_type = sanitize_key((string) ($paid_order['product_type'] ?? ''));
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE && class_exists('\AKSRT\Content\Pay')) {
|
||||
if (\AKSRT\Core\DomainModel::is_resource_product_type($product_type) && class_exists('\AKSRT\Content\Pay')) {
|
||||
$fulfilled = \AKSRT\Content\Pay::get_instance()->fulfill_paid_order($order_number, $paid_order);
|
||||
if (is_wp_error($fulfilled)) {
|
||||
return false;
|
||||
@@ -594,16 +597,6 @@ trait OrderRepositoryCore {
|
||||
];
|
||||
}
|
||||
|
||||
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED
|
||||
&& $current_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING
|
||||
&& !$ledger_active
|
||||
) {
|
||||
$refunded_order = array_merge($order, $update_data, ['status' => $new_status]);
|
||||
$flow_number = \AKSRT\Finance\FinancialFlowService::get_instance()->record_order_refund($refunded_order, true);
|
||||
if (is_wp_error($flow_number)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result !== false;
|
||||
@@ -681,11 +674,10 @@ trait OrderRepositoryCore {
|
||||
$sql = "SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
|
||||
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid,
|
||||
SUM(CASE WHEN status = 'refunding' THEN 1 ELSE 0 END) as refunding,
|
||||
SUM(CASE WHEN status IN ('paid','refunding') THEN 1 ELSE 0 END) as paid,
|
||||
SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) as refunded,
|
||||
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed,
|
||||
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) as revenue
|
||||
SUM(CASE WHEN status IN ('paid','refunding') THEN amount ELSE 0 END) as revenue
|
||||
FROM {$this->table()} {$where}";
|
||||
|
||||
if (!empty($params)) {
|
||||
@@ -698,13 +690,66 @@ trait OrderRepositoryCore {
|
||||
'total' => (int) ($row['total'] ?? 0),
|
||||
'pending' => (int) ($row['pending'] ?? 0),
|
||||
'paid' => (int) ($row['paid'] ?? 0),
|
||||
'refunding' => (int) ($row['refunding'] ?? 0),
|
||||
'refunded' => (int) ($row['refunded'] ?? 0),
|
||||
'closed' => (int) ($row['closed'] ?? 0),
|
||||
'revenue' => $this->cash_decimal((string) ($row['revenue'] ?? '0')),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function get_admin_order_stats(array $args = []): array {
|
||||
global $wpdb;
|
||||
$this->expire_pending_orders();
|
||||
$where = [];
|
||||
$params = [];
|
||||
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
|
||||
$date = sanitize_text_field((string) ($args[$key] ?? ''));
|
||||
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
$where[] = 'created_at ' . $operator . ' %s';
|
||||
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
|
||||
}
|
||||
}
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
$sql = "SELECT COUNT(*) AS total,
|
||||
SUM(status = 'pending') AS pending,
|
||||
SUM(status IN ('paid','refunding')) AS paid,
|
||||
SUM(status = 'refunded') AS refunded,
|
||||
SUM(status = 'closed') AS closed,
|
||||
SUM(CASE WHEN status IN ('paid','refunding') AND currency <> 'points' THEN amount ELSE 0 END) AS revenue
|
||||
FROM {$this->table()} {$where_sql}";
|
||||
$row = $params ? $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A) : $wpdb->get_row($sql, ARRAY_A);
|
||||
$type_sql = "SELECT product_type, note, COUNT(*) AS total FROM {$this->table()} {$where_sql} GROUP BY product_type, note";
|
||||
$type_rows = $params ? $wpdb->get_results($wpdb->prepare($type_sql, $params), ARRAY_A) : $wpdb->get_results($type_sql, ARRAY_A);
|
||||
$by_type = array_fill_keys(array_keys(\AKSRT\Core\DomainModel::order_product_type_labels()), 0);
|
||||
foreach ((array) $type_rows as $type_row) {
|
||||
$meta = json_decode((string) ($type_row['note'] ?? ''), true);
|
||||
$type = \AKSRT\Core\DomainModel::resolve_order_product_type((string) ($type_row['product_type'] ?? ''), is_array($meta) ? $meta : []);
|
||||
if (isset($by_type[$type])) {
|
||||
$by_type[$type] += (int) ($type_row['total'] ?? 0);
|
||||
}
|
||||
}
|
||||
return [
|
||||
'total' => (int) ($row['total'] ?? 0),
|
||||
'pending' => (int) ($row['pending'] ?? 0),
|
||||
'paid' => (int) ($row['paid'] ?? 0),
|
||||
'refunded' => (int) ($row['refunded'] ?? 0),
|
||||
'closed' => (int) ($row['closed'] ?? 0),
|
||||
'revenue' => $this->cash_decimal((string) ($row['revenue'] ?? '0')),
|
||||
'by_type' => $by_type,
|
||||
'after_sale_pending' => class_exists(AfterSaleService::class) ? AfterSaleService::get_instance()->count([
|
||||
'status' => AfterSaleService::STATUS_PENDING,
|
||||
'date_from' => $args['date_from'] ?? '',
|
||||
'date_to' => $args['date_to'] ?? '',
|
||||
]) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function dispatch_managed_status_events(): void {
|
||||
$events = $this->status_after_commit_events;
|
||||
$this->status_after_commit_events = [];
|
||||
$this->dispatch_status_events($events);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function has_paid_access(int $user_id, string $product_type, int $product_id): bool {
|
||||
@@ -712,7 +757,10 @@ trait OrderRepositoryCore {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null !== $this->find_latest_success_order($user_id, $product_type, $product_id);
|
||||
if (\AKSRT\Core\DomainModel::is_resource_product_type($product_type)) {
|
||||
return null !== $this->find_latest_success_content_order($user_id, $product_id);
|
||||
}
|
||||
return null !== $this->find_latest_success_order($user_id, $product_type, $product_id);
|
||||
}
|
||||
|
||||
|
||||
@@ -756,8 +804,25 @@ trait OrderRepositoryCore {
|
||||
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
|
||||
if ($product_type !== '') {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
|
||||
$types = \AKSRT\Core\DomainModel::resource_order_product_types();
|
||||
$where[] = 'product_type IN (' . implode(', ', array_fill(0, count($types), '%s')) . ')';
|
||||
array_push($params, ...$types);
|
||||
} else {
|
||||
$legacy_pay_types = [
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING => \AKSRT\Core\DomainModel::PAY_TYPE_READING,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD => \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE => \AKSRT\Core\DomainModel::PAY_TYPE_GALLERY,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO => \AKSRT\Core\DomainModel::PAY_TYPE_VIDEO,
|
||||
];
|
||||
if (isset($legacy_pay_types[$product_type])) {
|
||||
$where[] = "(product_type = %s OR (product_type = %s AND note LIKE %s))";
|
||||
array_push($params, $product_type, \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE, '%"pay_type":"' . $wpdb->esc_like($legacy_pay_types[$product_type]) . '"%');
|
||||
} else {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$order_number_prefix = strtoupper(preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($args['order_number_prefix'] ?? '')) ?? '');
|
||||
@@ -780,7 +845,10 @@ trait OrderRepositoryCore {
|
||||
if ($row['amount'] === null) {
|
||||
$row['amount'] = (string) ($row['currency'] ?? '') === 'points' ? 0 : '0.00';
|
||||
}
|
||||
$row['status'] = sanitize_key((string) ($row['status'] ?? ''));
|
||||
$row['status'] = sanitize_key((string) ($row['status'] ?? ''));
|
||||
if ($row['status'] === 'refunding') {
|
||||
$row['status'] = Order::STATUS_PAID;
|
||||
}
|
||||
|
||||
$row['meta_payload'] = [];
|
||||
if (!empty($row['note']) && is_string($row['note'])) {
|
||||
@@ -794,6 +862,10 @@ trait OrderRepositoryCore {
|
||||
$row['note_text'] = $row['note'];
|
||||
}
|
||||
}
|
||||
$row['product_type'] = \AKSRT\Core\DomainModel::resolve_order_product_type(
|
||||
sanitize_key((string) ($row['product_type'] ?? '')),
|
||||
$row['meta_payload']
|
||||
);
|
||||
|
||||
return $row;
|
||||
}
|
||||
@@ -860,6 +932,20 @@ trait OrderRepositoryCore {
|
||||
return is_array($row) ? $this->normalize_row($row) : null;
|
||||
}
|
||||
|
||||
private function find_latest_success_content_order(int $user_id, int $product_id): ?array {
|
||||
global $wpdb;
|
||||
$types = \AKSRT\Core\DomainModel::resource_order_product_types();
|
||||
$placeholders = implode(', ', array_fill(0, count($types), '%s'));
|
||||
$statuses = \AKSRT\Core\DomainModel::paid_order_statuses();
|
||||
$status_placeholders = implode(', ', array_fill(0, count($statuses), '%s'));
|
||||
$params = array_merge([$user_id, $product_id], $types, $statuses);
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table()} WHERE user_id = %d AND product_id = %d AND product_type IN ({$placeholders}) AND status IN ({$status_placeholders}) ORDER BY COALESCE(paid_at, created_at) DESC, id DESC LIMIT 1",
|
||||
$params
|
||||
), ARRAY_A);
|
||||
return is_array($row) ? $this->normalize_row($row) : null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@@ -130,8 +130,6 @@ class PaymentOrchestrator {
|
||||
'success' => true,
|
||||
'redirect' => false,
|
||||
'transaction_id' => $transaction_id,
|
||||
'ledger_transaction_id' => (int) ($result['ledger_transaction_id'] ?? 0),
|
||||
'flow_number' => (string) ($result['flow_number'] ?? ''),
|
||||
'replayed' => !empty($result['replayed']),
|
||||
'message' => (string) ($result['message'] ?? __('支付成功。', 'aksrt')),
|
||||
];
|
||||
|
||||
@@ -39,7 +39,7 @@ class Revenue {
|
||||
return 0;
|
||||
}
|
||||
$product_type = sanitize_key((string) ($order['product_type'] ?? ''));
|
||||
if ($product_type !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
|
||||
if (!\AKSRT\Core\DomainModel::is_resource_product_type($product_type)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ class UserOrders {
|
||||
*/
|
||||
public function decorate_order_view(array $order, bool $detail = true, ?array $reconciliation = null): array {
|
||||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||||
$product_type = sanitize_key((string) ($order['product_type'] ?? ''));
|
||||
$product_type = DomainModel::resolve_order_product_type(sanitize_key((string) ($order['product_type'] ?? '')), $meta);
|
||||
$product_id = (int) ($order['product_id'] ?? 0);
|
||||
$status = sanitize_key((string) ($order['status'] ?? Order::STATUS_PENDING));
|
||||
$currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
|
||||
@@ -195,6 +195,8 @@ class UserOrders {
|
||||
$can_pay = $group === OrderCenterQuery::STATUS_PENDING;
|
||||
$can_cancel = $group === OrderCenterQuery::STATUS_PENDING;
|
||||
$order_number = (string) ($order['order_number'] ?? '');
|
||||
$after_sales = $detail && class_exists(AfterSaleService::class) ? AfterSaleService::get_instance()->list_for_order($order_number) : [];
|
||||
$has_open_after_sale = (bool) array_filter($after_sales, static fn(array $item): bool => in_array((string) ($item['status'] ?? ''), ['pending', 'processing', 'approved'], true));
|
||||
$updated_at = $this->resolve_updated_at($order);
|
||||
|
||||
$view = [
|
||||
@@ -222,6 +224,7 @@ class UserOrders {
|
||||
'allowed_actions' => [
|
||||
'can_pay' => $can_pay,
|
||||
'can_cancel' => $can_cancel,
|
||||
'can_after_sale' => $group === OrderCenterQuery::STATUS_PAID && !$has_open_after_sale,
|
||||
],
|
||||
'time_left' => $this->get_order_countdown((string) ($order['expires_at'] ?? ''), $status),
|
||||
'updated_at' => $updated_at,
|
||||
@@ -237,6 +240,7 @@ class UserOrders {
|
||||
$view['paid_at'] = (string) ($order['paid_at'] ?? '');
|
||||
$view['expires_at'] = (string) ($order['expires_at'] ?? '');
|
||||
$view['reconciliation'] = $reconciliation ?: ['state' => 'not_needed', 'required' => false];
|
||||
$view['after_sales'] = $after_sales;
|
||||
}
|
||||
return $view;
|
||||
}
|
||||
@@ -249,7 +253,7 @@ class UserOrders {
|
||||
'total_pages' => 1,
|
||||
'page' => 1,
|
||||
'query' => $query->url_values(),
|
||||
'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0],
|
||||
'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0, 'refunded' => 0, 'closed' => 0, 'after_sale_pending' => 0],
|
||||
'invalidation_tags' => ['orders'],
|
||||
'product_types' => $this->product_type_filter_options(),
|
||||
];
|
||||
@@ -431,21 +435,15 @@ class UserOrders {
|
||||
return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')];
|
||||
}
|
||||
if ($this->status_group((string) ($order['status'] ?? '')) === OrderCenterQuery::STATUS_PAID) {
|
||||
$ledger_key = \AKSRT\Ledger\OperationKey::deterministic('order-payment', [$order_number]);
|
||||
$history = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['operation_key' => $ledger_key, 'page' => 1, 'per_page' => 1]);
|
||||
if (!empty($history['items'][0])) {
|
||||
return ['success' => true, 'data' => [
|
||||
return ['success' => true, 'data' => [
|
||||
'redirect' => false,
|
||||
'payment_url' => '',
|
||||
'order_number' => $order_number,
|
||||
'order' => $this->get_order_detail_view($user_id, $order_number),
|
||||
'transaction_id' => (int) $history['items'][0]['id'],
|
||||
'flow_number' => \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number),
|
||||
'replayed' => true,
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => __('支付成功。', 'aksrt'),
|
||||
]];
|
||||
}
|
||||
}
|
||||
if ($this->status_group((string) ($order['status'] ?? '')) !== OrderCenterQuery::STATUS_PENDING) {
|
||||
return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')];
|
||||
@@ -476,8 +474,6 @@ class UserOrders {
|
||||
'payment_url' => (string) ($result['payment_url'] ?? ''),
|
||||
'order_number' => $order_number,
|
||||
'order' => empty($result['redirect']) ? $this->get_order_detail_view($user_id, $order_number) : null,
|
||||
'transaction_id' => (int) ($result['ledger_transaction_id'] ?? 0),
|
||||
'flow_number' => (string) ($result['flow_number'] ?? ''),
|
||||
'replayed' => !empty($result['replayed']),
|
||||
'balance' => $payment_method === 'balance' ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : null,
|
||||
'message' => (string) ($result['message'] ?? ''),
|
||||
|
||||
@@ -133,6 +133,46 @@ class Withdrawal {
|
||||
if ($net_minor < 1) {
|
||||
return new \WP_Error('aksrt_withdraw_net', __('扣除手续费后到账金额必须至少为 0.01 元', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$request_hash = hash('sha256', (string) wp_json_encode([
|
||||
'user_id' => $user_id,
|
||||
'gross_minor' => $gross_minor,
|
||||
'net_minor' => $net_minor,
|
||||
'fee_rate' => $fee_rate,
|
||||
'method' => sanitize_key($method),
|
||||
'account' => sanitize_text_field($account),
|
||||
'source' => $source,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
$existing_request = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table()} WHERE operation_key = %s LIMIT 1",
|
||||
$operation_key
|
||||
), ARRAY_A);
|
||||
if (is_array($existing_request)) {
|
||||
$existing_meta = $this->parse_account_meta((string) ($existing_request['account_info'] ?? ''));
|
||||
if ((int) ($existing_request['user_id'] ?? 0) !== $user_id
|
||||
|| !hash_equals((string) ($existing_meta['request_hash'] ?? ''), $request_hash)) {
|
||||
return new \WP_Error('aksrt_withdraw_idempotency_conflict', __('相同提现操作标识已用于其他请求', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
|
||||
$usage = $this->daily_usage_minor_in_window($user_id, $settings, $window, false, $source);
|
||||
return [
|
||||
'withdrawal_id' => (int) $existing_request['id'],
|
||||
'withdrawal_number' => (string) ($existing_request['withdrawal_number'] ?? ''),
|
||||
'net_amount' => $this->database_balance_decimal((string) ($existing_request['amount'] ?? '0.00')),
|
||||
'source' => $source,
|
||||
'wallet_balance' => $this->source_balance_decimal($user_id, $source),
|
||||
'transaction_id' => max(0, (int) ($existing_request['ledger_transaction_id'] ?? 0)),
|
||||
'flow_number' => $source === self::SOURCE_COMMISSION
|
||||
? \AKSRT\Finance\FinancialFlowService::get_instance()->number_for_operation($operation_key)
|
||||
: '',
|
||||
'replayed' => true,
|
||||
'usage' => [
|
||||
'count' => $usage['count'],
|
||||
'total' => $this->balance_decimal($usage['total_minor']),
|
||||
'remaining_count' => $usage['remaining_count'],
|
||||
'remaining_amount' => $this->balance_decimal($usage['remaining_minor']),
|
||||
],
|
||||
];
|
||||
}
|
||||
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
|
||||
$usage = null;
|
||||
$withdrawal_id = 0;
|
||||
@@ -163,7 +203,7 @@ class Withdrawal {
|
||||
}
|
||||
return true;
|
||||
};
|
||||
$mutation = function (int $transaction_id) use ($user_id, $gross_minor, $net_minor, $fee_rate, $method, $account, $operation_key, $source, &$withdrawal_id, &$withdrawal_number) {
|
||||
$mutation = function (int $transaction_id) use ($user_id, $gross_minor, $net_minor, $fee_rate, $method, $account, $operation_key, $request_hash, $source, &$withdrawal_id, &$withdrawal_number) {
|
||||
global $wpdb;
|
||||
$payload = wp_json_encode([
|
||||
'account' => $account,
|
||||
@@ -171,11 +211,12 @@ class Withdrawal {
|
||||
'fee_rate' => $fee_rate,
|
||||
'source' => $source,
|
||||
'operation_key' => $operation_key,
|
||||
'request_hash' => $request_hash,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account;
|
||||
$ok = false;
|
||||
for ($attempt = 1; $attempt <= 5; $attempt++) {
|
||||
$withdrawal_number = \AKSRT\Finance\BusinessNumber::allocate();
|
||||
$ok = $wpdb->insert($this->table(), [
|
||||
$insert_data = [
|
||||
'withdrawal_number' => $withdrawal_number,
|
||||
'user_id' => $user_id,
|
||||
'amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(),
|
||||
@@ -183,9 +224,15 @@ class Withdrawal {
|
||||
'method' => sanitize_key($method),
|
||||
'account_info' => $payload,
|
||||
'status' => self::STATUS_PENDING,
|
||||
'ledger_transaction_id' => $transaction_id,
|
||||
'operation_key' => $operation_key,
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s']);
|
||||
];
|
||||
$insert_formats = ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s'];
|
||||
if ($transaction_id > 0) {
|
||||
$insert_data['ledger_transaction_id'] = $transaction_id;
|
||||
$insert_formats[] = '%d';
|
||||
}
|
||||
$ok = $wpdb->insert($this->table(), $insert_data, $insert_formats);
|
||||
if ($ok === 1) {
|
||||
break;
|
||||
}
|
||||
@@ -214,7 +261,9 @@ class Withdrawal {
|
||||
}
|
||||
if ($withdrawal_id <= 0) {
|
||||
$replayed_row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT id, withdrawal_number FROM {$this->table()} WHERE ledger_transaction_id = %d",
|
||||
"SELECT id, withdrawal_number FROM {$this->table()} WHERE operation_key = %s OR (%d > 0 AND ledger_transaction_id = %d) ORDER BY id DESC LIMIT 1",
|
||||
$operation_key,
|
||||
$result->transaction_id,
|
||||
$result->transaction_id
|
||||
), ARRAY_A);
|
||||
$withdrawal_id = (int) ($replayed_row['id'] ?? 0);
|
||||
@@ -329,7 +378,7 @@ class Withdrawal {
|
||||
return $preflight_replay;
|
||||
}
|
||||
if (is_array($preflight_replay)) {
|
||||
$preflight_replay['wallet_balance'] = \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal((int) ($row['user_id'] ?? 0));
|
||||
$preflight_replay['wallet_balance'] = $this->source_balance_decimal((int) ($row['user_id'] ?? 0), $this->normalize_source((string) ($meta['source'] ?? self::SOURCE_BALANCE)));
|
||||
$preflight_replay['withdrawal_number'] = (string) ($row['withdrawal_number'] ?? '');
|
||||
$preflight_replay['flow_number'] = \AKSRT\Finance\FinancialFlowService::get_instance()->number_for_operation($operation_key);
|
||||
return $preflight_replay;
|
||||
@@ -497,10 +546,16 @@ class Withdrawal {
|
||||
'flow_number' => $ledger_result instanceof \AKSRT\Ledger\LedgerResult ? $ledger_result->flow_number : $completed_flow_number,
|
||||
'withdrawal_number' => (string) ($row['withdrawal_number'] ?? ''),
|
||||
'replayed' => $replayed,
|
||||
'wallet_balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'wallet_balance' => $this->source_balance_decimal($user_id, $refund_source),
|
||||
];
|
||||
}
|
||||
|
||||
private function source_balance_decimal(int $user_id, string $source): string {
|
||||
$asset = $this->source_asset($source);
|
||||
$minor = \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, $asset);
|
||||
return \AKSRT\Ledger\AssetAmount::from_minor($asset, $minor)->decimal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether user has open (pending/processing) withdrawals.
|
||||
*/
|
||||
|
||||
@@ -448,8 +448,8 @@ class PaymentRuntime {
|
||||
$box_position = 'top';
|
||||
}
|
||||
|
||||
$status_keys = ['pending', 'paid', 'refunding', 'refunded', 'closed'];
|
||||
$status_defaults = ['待支付', '已支付', '退款中', '已退款', '已关闭'];
|
||||
$status_keys = ['pending', 'paid', 'refunded', 'closed'];
|
||||
$status_defaults = ['待支付', '已支付', '已退款', '已关闭'];
|
||||
$status_texts = [];
|
||||
foreach ($status_keys as $i => $key) {
|
||||
$val = trim((string) ($s["payment_order_status_{$key}"] ?? ''));
|
||||
|
||||
+12
-4
@@ -609,17 +609,25 @@ class Badges {
|
||||
if ($type === '' || $type === 'manually_add') {
|
||||
continue;
|
||||
}
|
||||
if (in_array($type, ['creator_income', 'affiliate_commission'], true)) {
|
||||
if ($type === 'creator_income') {
|
||||
try {
|
||||
$need_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($item['get_val'] ?? '0'))->minor();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
continue;
|
||||
}
|
||||
$revenue_stats = \AKSRT\Payment\Revenue::get_instance()->get_stats($user_id);
|
||||
$have_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($revenue_stats['cash']['settled'] ?? '0'))->minor();
|
||||
$qualified = $have_minor >= $need_minor;
|
||||
} elseif ($type === 'affiliate_commission') {
|
||||
try {
|
||||
$need_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($item['get_val'] ?? '0'))->minor();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
continue;
|
||||
}
|
||||
$reason = $type;
|
||||
$have_minor = \AKSRT\Ledger\LedgerQuery::get_instance()->sum_positive(
|
||||
$user_id,
|
||||
\AKSRT\Ledger\AssetType::BALANCE,
|
||||
$reason
|
||||
\AKSRT\Ledger\AssetType::COMMISSION,
|
||||
$type
|
||||
);
|
||||
$qualified = $have_minor >= $need_minor;
|
||||
} else {
|
||||
|
||||
@@ -68,10 +68,10 @@ class BalanceAdmin {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('asset_account');
|
||||
$query->query_from .= $wpdb->prepare(" LEFT JOIN {$table} aksrt_cash_account ON aksrt_cash_account.user_id = {$wpdb->users}.ID AND aksrt_cash_account.asset = %s", \AKSRT\Ledger\AssetType::BALANCE->value);
|
||||
$meta_table = $wpdb->usermeta;
|
||||
$query->query_from .= $wpdb->prepare(" LEFT JOIN {$meta_table} aksrt_cash_meta ON aksrt_cash_meta.user_id = {$wpdb->users}.ID AND aksrt_cash_meta.meta_key = %s", 'balance');
|
||||
$order = strtoupper((string) ($query->query_vars['order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
|
||||
$query->query_orderby = "ORDER BY COALESCE(aksrt_cash_account.balance_minor, 0) {$order}";
|
||||
$query->query_orderby = "ORDER BY CAST(COALESCE(aksrt_cash_meta.meta_value, '0') AS DECIMAL(20,2)) {$order}";
|
||||
}
|
||||
|
||||
public function render_section(\WP_User $user): void {
|
||||
|
||||
@@ -107,10 +107,10 @@ class CreditsAdmin {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('asset_account');
|
||||
$query->query_from .= $wpdb->prepare(" LEFT JOIN {$table} aksrt_points_account ON aksrt_points_account.user_id = {$wpdb->users}.ID AND aksrt_points_account.asset = %s", \AKSRT\Ledger\AssetType::POINTS->value);
|
||||
$meta_table = $wpdb->usermeta;
|
||||
$query->query_from .= $wpdb->prepare(" LEFT JOIN {$meta_table} aksrt_points_meta ON aksrt_points_meta.user_id = {$wpdb->users}.ID AND aksrt_points_meta.meta_key = %s", 'points');
|
||||
$order = strtoupper((string) ($query->query_vars['order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
|
||||
$query->query_orderby = "ORDER BY COALESCE(aksrt_points_account.balance_minor, 0) {$order}";
|
||||
$query->query_orderby = "ORDER BY CAST(COALESCE(aksrt_points_meta.meta_value, '0') AS SIGNED) {$order}";
|
||||
}
|
||||
|
||||
// ========== 用户列表:行操作 ==========
|
||||
|
||||
@@ -1,30 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 卡密核销的订单号 / 渠道交易号 / 支付流水号契约。
|
||||
*
|
||||
* 背景(已修复的缺陷):
|
||||
* 卡密充值(balance/credits)与兑换(vip)虽已建订单,但订单号硬编码为
|
||||
* `CARD{卡ID}`、渠道交易号硬编码为 `card_pass_{卡ID}`;且卡密核销产生的资金流水
|
||||
* 没有挂到订单上(order_number 为 NULL),订单详情页的「账本流水号」永远显示「—」。
|
||||
*
|
||||
* 处置:
|
||||
* - 订单号走 OrderRepository::allocate_order_number();
|
||||
* - 渠道交易号走 BusinessNumber::allocate();
|
||||
* - 两者与资金流水号、提现单号同源,均为无前缀的统一流水号,
|
||||
* 后台不再提供任何前缀配置项;
|
||||
* - 余额/积分卡的流水由账本落库后用 attach_order_to_ledger_flow() 补挂订单号,
|
||||
* 并把 event_type 归一为 order_payment;会员卡(0 元、无账本参与)由
|
||||
* record_card_pass_payment() 直接落一条零分录的 order_payment 流水。
|
||||
*
|
||||
* 本契约额外钉住两条容易回退的坑:
|
||||
* 1) **账本命令必须保持确定性** —— LedgerCommand::payload_hash() 覆盖 metadata,
|
||||
* 把随机订单号塞进 credit() 的 metadata 会让重放从「幂等返回」变成
|
||||
* `aksrt_ledger_idempotency_conflict`(实测复现过),因此 metadata 只允许 card_type。
|
||||
* 2) **会员卡的零分录流水是刻意设计** —— prepare_entries() 拒绝 delta=0,
|
||||
* 0 元凭证核销既无现金也无资产分录,只能以「零分录流水行」表达,
|
||||
* record() 为此提供 allow_empty_entries 通道;不得用占位分录伪造。
|
||||
*/
|
||||
/** Card redemption uses the public 22-digit order number as its only business number. */
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
@@ -33,7 +9,7 @@ const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
const card = fs.readFileSync(path.join(root, 'src/Payment/ChargeCard.php'), 'utf8');
|
||||
const flows = fs.readFileSync(path.join(root, 'src/Finance/FinancialFlowService.php'), 'utf8');
|
||||
const domain = fs.readFileSync(path.join(root, 'src/Core/DomainModel.php'), 'utf8');
|
||||
|
||||
test('卡密订单号不再硬编码 CARD 前缀,改走统一分配器', () => {
|
||||
assert.doesNotMatch(card, /'CARD'\s*\.\s*\$card_id/);
|
||||
@@ -48,43 +24,18 @@ test('卡密订单号不再硬编码 CARD 前缀,改走统一分配器', () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('渠道交易号不再硬编码 card_pass_ 前缀,改走统一流水号分配器', () => {
|
||||
assert.doesNotMatch(card, /'card_pass_'\s*\.\s*\$card_id/);
|
||||
assert.match(card, /card_pass_transaction_number\(\)/);
|
||||
// 不再读取任何前缀配置,直接取统一流水号
|
||||
assert.doesNotMatch(card, /normalize_prefix|flow_prefix|get_order_settings/);
|
||||
assert.match(card, /private function card_pass_transaction_number\(\): string \{\s*return \\AKSRT\\Finance\\BusinessNumber::allocate\(\);/);
|
||||
// create_redeem_order 只接收调用方传入的交易号,不再自行拼前缀
|
||||
assert.match(card, /'payment_transaction_id'\s*=>\s*\$order_status === Order::STATUS_PAID \? \$transaction_number : ''/);
|
||||
test('card redemption does not create a second flow number', () => {
|
||||
const numberHelper = card.slice(card.indexOf('private function allocate_redeem_order_number'), card.indexOf('private function create_redeem_order'));
|
||||
assert.doesNotMatch(card, /card_pass_transaction_number|FinancialFlowService|record_card_pass_payment|attach_order_to_ledger_flow/);
|
||||
assert.match(numberHelper, /OrderRepository::get_instance\(\)->allocate_order_number\(\)/);
|
||||
assert.match(card, /'payment_transaction_id'\s*=>\s*''/);
|
||||
});
|
||||
|
||||
test('账本命令保持确定性:credit() 的 metadata 不得携带随机订单号', () => {
|
||||
const assetPart = card.slice(card.indexOf('private function redeem_asset_card'), card.indexOf('private function redeem_vip_card'));
|
||||
// metadata 只允许 card_type;order_number 一旦进 metadata,payload_hash 随机化,
|
||||
// 重复请求会撞 uniq_operation_key 后走 replay、hash 不一致即报幂等冲突。
|
||||
assert.match(assetPart, /\['card_type'\s*=>\s*\$type\]/);
|
||||
assert.doesNotMatch(assetPart, /\['card_type'\s*=>\s*\$type,[^\]]*order_number/);
|
||||
// 重放路径必须回取已落库订单号,而不是把本次新分配的号回给前端
|
||||
assert.match(assetPart, /if\s*\(\$result->replayed\)/);
|
||||
assert.match(assetPart, /get_by_request_key\(\$operation_key\)/);
|
||||
});
|
||||
|
||||
test('余额/积分卡:账本提交后补挂订单号,会员卡:直接落零分录支付流水', () => {
|
||||
assert.match(flows, /public function attach_order_to_ledger_flow\(int \$ledger_transaction_id, string \$order_number/);
|
||||
// 只挂到已支付且支付方式为卡密的订单,且不得重复覆盖
|
||||
const attach = flows.slice(flows.indexOf('public function attach_order_to_ledger_flow'));
|
||||
assert.match(attach, /'card_pass'/);
|
||||
assert.match(attach, /order_number IS NULL OR order_number = ''/);
|
||||
assert.match(attach, /event_type = 'order_payment'/);
|
||||
|
||||
assert.match(flows, /public function record_card_pass_payment\(array \$order, bool \$caller_transaction = false\)/);
|
||||
assert.match(flows, /\$voucher_settled = false\)/);
|
||||
// 凭证结算模式豁免两条门槛,且不生成现金分录
|
||||
assert.match(flows, /if \(\$voucher_settled\) \{[\s\S]*?\$metadata\['voucher'\] = 'card_pass';/);
|
||||
assert.match(flows, /private function record\(array \$flow, array \$entries, bool \$caller_transaction, bool \$allow_empty_entries = false\)/);
|
||||
assert.match(flows, /\(\$entries === \[\] && !\$allow_empty_entries\)/);
|
||||
// is_external_recharge 不把卡密当外部收款渠道(核销当下没有现金流入)
|
||||
assert.match(flows, /in_array\(\$method, \['', 'balance', 'points', 'card_pass'\], true\)/);
|
||||
// is_successful_order_payment 认可卡密凭证结算
|
||||
assert.match(flows, /'card_pass'\)\s*\{\s*return true;/);
|
||||
test('card redemption keeps seven order types and canonical labels', () => {
|
||||
assert.match(card, /ORDER_PRODUCT_RECHARGE/);
|
||||
assert.match(card, /ORDER_PRODUCT_VIP/);
|
||||
assert.match(card, /ORDER_PRODUCT_POINTS/);
|
||||
for (const label of ['付费阅读', '付费下载', '付费图片', '付费视频', '购买会员', '余额充值', '购买积分']) {
|
||||
assert.ok(domain.includes(label), `missing order label: ${label}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -190,10 +190,12 @@ test('user asset submissions send idempotency keys', () => {
|
||||
test('profile asset records show only the reason label and the changed amount', () => {
|
||||
for (const file of ['credits-transactions.php', 'credits-wallet-ledger.php']) {
|
||||
const source = fs.readFileSync(path.join(root, 'inc', 'helpers', 'user-center-renderers', file), 'utf8')
|
||||
assert.match(source, /aksrt_asset_reason_label\(\(string\) \$row\['reason'\]\)/)
|
||||
// 记录名称后不再拼接(引用键 / 备注 / 提现单号)。
|
||||
assert.doesNotMatch(source, /\$record_note|uc-transactions__remark|\$row\['ref_key'\]|\$row\['remark'\]|withdrawal_number/)
|
||||
assert.doesNotMatch(source, /uc-transactions__balance|交易 #%d|\$row\['fee_minor'\]|\$row\['ref_id'\]/)
|
||||
assert.match(source, /get_user_meta\([^\n]*'(?:points_record|balance_record)'/)
|
||||
assert.match(source, /\$row\['value'\]/)
|
||||
assert.match(source, /\$row\['desc'\]/)
|
||||
assert.match(source, /\$row\['time'\]/)
|
||||
assert.match(source, /\$row\['order_num'\]/)
|
||||
assert.doesNotMatch(source, /ledger_entry|transaction_id|flow_number|withdrawal_number/)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -321,12 +323,18 @@ test('VIP card redemption is one locked transaction with bounded retry and repla
|
||||
assert.match(method, /vip_card_replay_result/)
|
||||
})
|
||||
|
||||
test('admin ledger keeps cash minor values as decimal strings', () => {
|
||||
test('admin recent asset records read the user-meta histories', () => {
|
||||
const controller = fs.readFileSync(path.join(root, 'src', 'Admin', 'UserController.php'), 'utf8')
|
||||
const page = fs.readFileSync(path.join(root, 'assets', 'admin', 'src', 'pages', 'CmsCredits.vue'), 'utf8')
|
||||
assert.match(controller, /asset_minor_for_response/)
|
||||
assert.match(controller, /decimal_from_minor/)
|
||||
assert.doesNotMatch(page, /\/\s*100(?:\D|$)/)
|
||||
assert.match(controller, /'balance_record'/)
|
||||
assert.match(controller, /'points_record'/)
|
||||
assert.match(controller, /'balance_value'/)
|
||||
assert.match(controller, /'points_value'/)
|
||||
assert.match(page, /用户近期资产记录/)
|
||||
assert.match(page, /api\.getCreditLog\(params\)/)
|
||||
assert.doesNotMatch(page, /统一资产账本|transaction_id|operation_key|toFixed|number_format/)
|
||||
assert.match(page, /Math\.trunc\(raw\)/)
|
||||
assert.match(page, /padStart\(3, '0'\)/)
|
||||
})
|
||||
|
||||
test('asset purchases share the order center and refresh every wallet surface', () => {
|
||||
@@ -346,8 +354,12 @@ test('asset purchases share the order center and refresh every wallet surface',
|
||||
const header = fs.readFileSync(path.join(root, 'inc', 'helpers', 'aksrt-header-userbox.php'), 'utf8')
|
||||
|
||||
assert.match(domain, /ORDER_PRODUCT_POINTS\s*=\s*'points'/)
|
||||
assert.match(domain, /self::ORDER_PRODUCT_POINTS\s*=>\s*__\('积分购买'/)
|
||||
const statuses = ['PENDING', 'PAID', 'REFUNDING', 'REFUNDED', 'CLOSED']
|
||||
const productTypes = ['READING', 'DOWNLOAD', 'IMAGE', 'VIDEO', 'VIP', 'RECHARGE', 'POINTS']
|
||||
for (const type of productTypes) assert.match(domain, new RegExp(`ORDER_PRODUCT_${type}\\s*=`))
|
||||
for (const label of ['付费阅读', '付费下载', '付费图片', '付费视频', '购买会员', '余额充值', '购买积分']) {
|
||||
assert.ok(domain.includes(label), `missing order product label: ${label}`)
|
||||
}
|
||||
const statuses = ['PENDING', 'PAID', 'REFUNDED', 'CLOSED']
|
||||
assert.deepEqual(
|
||||
[...domain.matchAll(/public const ORDER_STATUS_([A-Z_]+)\s*=\s*'([^']+)'/g)].map((match) => [match[1], match[2]]),
|
||||
statuses.map((status) => [status, status.toLowerCase()]),
|
||||
@@ -374,20 +386,21 @@ test('asset purchases share the order center and refresh every wallet surface',
|
||||
)
|
||||
assert.deepEqual(transitions, {
|
||||
PENDING: ['PAID', 'CLOSED'],
|
||||
PAID: ['REFUNDING'],
|
||||
REFUNDING: ['REFUNDED', 'PAID'],
|
||||
PAID: ['REFUNDED'],
|
||||
REFUNDED: [],
|
||||
CLOSED: [],
|
||||
})
|
||||
assert.match(userOrders, /return isset\(DomainModel::order_status_labels\(\)\[\$status\]\) \? \$status : ''/)
|
||||
assert.doesNotMatch(userOrders, /can_open|entitlement_label|resolve_entitlement_url|post_permalink_cache/)
|
||||
assert.match(repository, /SUM\(CASE WHEN status = %s THEN 1 ELSE 0 END\) AS paid/)
|
||||
assert.match(repository, /SUM\(CASE WHEN status = %s THEN 1 ELSE 0 END\) AS paid,/)
|
||||
assert.match(repository, /SUM\(CASE WHEN status = 'refunding' THEN 1 ELSE 0 END\) AS paid_legacy/)
|
||||
assert.match(repository, /'paid' => \(int\) \(\$row\['paid'\] \?\? 0\) \+ \(int\) \(\$row\['paid_legacy'\] \?\? 0\)/)
|
||||
assert.doesNotMatch(repository, /\$completed_statuses|\$completed_placeholders/)
|
||||
assert.match(adminRepository, /\$valid_statuses = array_keys\(\\AKSRT\\Core\\DomainModel::order_status_labels\(\)\)/)
|
||||
assert.doesNotMatch(payOrders, /<select name="new_status">/)
|
||||
assert.doesNotMatch(`${order}\n${repository}\n${userOrders}\n${payOrders}`, /['"]force['"]\s*=>\s*true/)
|
||||
assert.match(paymentRuntime, /\$status_keys = \['pending', 'paid', 'refunding', 'refunded', 'closed'\]/)
|
||||
for (const status of ['pending', 'paid', 'refunding', 'refunded', 'closed']) {
|
||||
assert.match(paymentRuntime, /\$status_keys = \['pending', 'paid', 'refunded', 'closed'\]/)
|
||||
for (const status of ['pending', 'paid', 'refunded', 'closed']) {
|
||||
assert.match(paymentSchema, new RegExp(`payment_order_status_${status}`))
|
||||
}
|
||||
assert.doesNotMatch(paymentSchema, /payment_order_status_(?:completed|cancelled|processing)/)
|
||||
|
||||
@@ -42,25 +42,27 @@ test('order, flow and withdrawal numbers share one prefix-free allocator', () =>
|
||||
assert.match(read('src/Payment/ChargeCard.php'), /BusinessNumber::allocate\(\)/)
|
||||
})
|
||||
|
||||
test('ledger and business state write their flow before commit', () => {
|
||||
test('commission ledger and withdrawals retain their flow while order flows are removed', () => {
|
||||
const ledger = read('src/Ledger/LedgerService.php')
|
||||
const order = read('src/Payment/OrderRepositoryCore.php')
|
||||
const withdrawal = read('src/Payment/Withdrawal.php')
|
||||
assert.ok(ledger.indexOf('record_ledger(') < ledger.indexOf("query('COMMIT')"))
|
||||
assert.match(order, /record_order_payment\(\$paid_order, true\)/)
|
||||
assert.match(order, /record_order_refund\(\$refunded_order, true\)/)
|
||||
assert.doesNotMatch(order, /record_order_(?:payment|refund)/)
|
||||
assert.match(withdrawal, /record_withdrawal_payout\([\s\S]*?true[\s\S]*?query\('COMMIT'\)/)
|
||||
})
|
||||
|
||||
test('native and front-end surfaces expose immutable business numbers', () => {
|
||||
assert.match(read('src/Admin/Native/PayMenus.php'), /PayFlows::PAGE_SLUG/)
|
||||
assert.match(read('src/Admin/Native/PayFlows.php'), /流水明细/)
|
||||
// 后台同时提供业务流水号与账本流水号两个口径
|
||||
assert.match(read('src/Admin/Native/PayOrders.php'), /账本流水号/)
|
||||
test('orders have no independent flow page or visible flow number', () => {
|
||||
const menu = read('src/Admin/Native/PayMenus.php')
|
||||
const payOrders = read('src/Admin/Native/PayOrders.php')
|
||||
assert.doesNotMatch(menu, /PayFlows|流水明细/)
|
||||
assert.equal(fs.existsSync(path.join(root, 'src/Admin/Native/PayFlows.php')), false)
|
||||
assert.equal(fs.existsSync(path.join(root, 'src/Finance/FinancialFlowQuery.php')), false)
|
||||
assert.match(payOrders, /订单明细/)
|
||||
assert.doesNotMatch(payOrders, /账本流水号|资金流水号/)
|
||||
assert.match(read('src/Admin/Native/PayWithdrawals.php'), /esc_html__\('流水号'/)
|
||||
assert.doesNotMatch(read('src/Admin/Native/PayWithdrawals.php'), /提现单号/)
|
||||
// 前台订单详情只对外暴露统一流水号:账本流水号既不下发也不展示
|
||||
assert.match(read('assets/js/order-center.js'), /\["流水号", order\.order_number\]/)
|
||||
// 前台订单详情只展示公开订单号,账本流水号不下发也不展示。
|
||||
assert.match(read('assets/js/order-center.js'), /\["订单号", order\.order_number\]/)
|
||||
assert.doesNotMatch(read('assets/js/order-center.js'), /flow_number|账本流水号/)
|
||||
assert.doesNotMatch(read('src/Payment/UserOrders.php'), /\$view\['flow_number'\]/)
|
||||
})
|
||||
@@ -123,13 +125,13 @@ test('flow retention and archive were deliberately removed', () => {
|
||||
assert.equal(fs.existsSync(path.join(root, 'src/Finance/FinancialFlowRetention.php')), false)
|
||||
})
|
||||
|
||||
test('admin flow search uses an equality lookup for full identifiers', () => {
|
||||
const query = read('src/Finance/FinancialFlowQuery.php')
|
||||
assert.match(query, /private function keyword_hits_identifier\(string \$keyword\): bool/)
|
||||
assert.match(query, /if \(\$this->keyword_hits_identifier\(\$keyword\)\)/)
|
||||
// 精确分支走等值(各列有索引),模糊分支保留 LIKE 兜底,前缀搜索行为不变。
|
||||
assert.match(query, /f\.flow_number = %s OR f\.operation_key = %s/)
|
||||
assert.match(query, /f\.flow_number LIKE %s OR f\.operation_key LIKE %s/)
|
||||
test('order detail workbench includes statistics, orders, after-sales and the new filters', () => {
|
||||
const page = read('src/Admin/Native/PayOrders.php')
|
||||
for (const tab of ['统计', '订单', '售后']) assert.ok(page.includes(`__('${tab}'`))
|
||||
const domain = read('src/Core/DomainModel.php')
|
||||
for (const status of ['pending', 'paid', 'refunded', 'closed']) assert.ok(domain.includes(`ORDER_STATUS_${status.toUpperCase()}`))
|
||||
assert.match(page, /STATUS_PENDING[\s\S]*?STATUS_PROCESSING[\s\S]*?STATUS_APPROVED[\s\S]*?STATUS_REJECTED[\s\S]*?STATUS_CANCELLED[\s\S]*?STATUS_COMPLETED/)
|
||||
assert.match(page, /ORDER_PRODUCT_READING[\s\S]*?付费阅读[\s\S]*?ORDER_PRODUCT_DOWNLOAD[\s\S]*?付费下载[\s\S]*?ORDER_PRODUCT_IMAGE[\s\S]*?付费图片[\s\S]*?ORDER_PRODUCT_VIDEO[\s\S]*?付费视频[\s\S]*?ORDER_PRODUCT_VIP[\s\S]*?购买会员[\s\S]*?ORDER_PRODUCT_RECHARGE[\s\S]*?余额充值[\s\S]*?ORDER_PRODUCT_POINTS[\s\S]*?购买积分/)
|
||||
})
|
||||
|
||||
test('order creation retries only on order-number unique key collisions', () => {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const root = path.resolve(__dirname, '../..')
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
|
||||
|
||||
test('orders expose four states, seven product categories and retain 22-digit IDs', () => {
|
||||
const domain = read('src/Core/DomainModel.php')
|
||||
const repository = read('src/Payment/OrderRepositoryCore.php')
|
||||
const order = read('src/Payment/Order.php')
|
||||
|
||||
for (const status of ['pending', 'paid', 'refunded', 'closed']) {
|
||||
assert.match(domain, new RegExp(`ORDER_STATUS_${status.toUpperCase()}\\s*=\\s*'${status}'`))
|
||||
}
|
||||
assert.ok(repository.includes("ORDER_NUMBER_PATTERN = '/^\\d{20}$|^\\d{22}$/';"))
|
||||
for (const label of ['付费阅读', '付费下载', '付费图片', '付费视频', '购买会员', '余额充值', '购买积分']) {
|
||||
assert.ok(domain.includes(label), `missing product category ${label}`)
|
||||
}
|
||||
assert.match(repository, /\$row\['status'\] === 'refunding'[\s\S]*?Order::STATUS_PAID/)
|
||||
assert.doesNotMatch(order, /STATUS_REFUNDING\s*=/)
|
||||
})
|
||||
|
||||
test('balance and points mutations use user meta, guard debits in SQL and cap history at 50', () => {
|
||||
const assets = read('src/Credits/MetaAssetService.php')
|
||||
const ledger = read('src/Ledger/LedgerService.php')
|
||||
|
||||
assert.match(assets, /MAX_RECORDS\s*=\s*50/)
|
||||
assert.match(assets, /SELECT GET_LOCK\(%s, 5\)/)
|
||||
assert.match(assets, /FOR UPDATE/)
|
||||
assert.match(assets, /CAST\(meta_value AS SIGNED\) >= %d/)
|
||||
assert.match(assets, /CAST\(meta_value AS DECIMAL\(20,2\)\) >= CAST\(%s AS DECIMAL\(20,2\)\)/)
|
||||
assert.match(assets, /array_slice\(\$history, 0, self::MAX_RECORDS - 1\)/)
|
||||
assert.match(assets, /'balance_record'/)
|
||||
assert.match(assets, /'points_record'/)
|
||||
assert.match(ledger, /post_meta_command\(\$command, \$guard, \$business_mutation\)/)
|
||||
assert.match(ledger, /AssetType::BALANCE, AssetType::POINTS/)
|
||||
})
|
||||
|
||||
test('free points keep a bounded daily summary alongside the recent points history', () => {
|
||||
const freePoints = read('src/Credits/PointsFree.php')
|
||||
const assets = read('src/Credits/MetaAssetService.php')
|
||||
|
||||
assert.match(freePoints, /META_DETAIL\s*=\s*'free_points_detail'/)
|
||||
assert.match(freePoints, /remaining_today\(\$user_id\) < \$points/)
|
||||
assert.match(freePoints, /array_slice\(\$detail, 0, 50, true\)/)
|
||||
assert.match(freePoints, /LedgerService::get_instance\(\)->credit\([\s\S]*?self::bump_detail\(\$user_id, \$points\)/)
|
||||
assert.match(assets, /'points_record'/)
|
||||
})
|
||||
|
||||
test('after-sale submissions are order-locked and only completed by their own refund application', () => {
|
||||
const afterSale = read('src/Payment/AfterSaleService.php')
|
||||
const schema = read('src/Core/DBMigratorPart2.php')
|
||||
const repository = read('src/Payment/OrderRepositoryCore.php')
|
||||
|
||||
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_order_aftersale[\s\S]*?gateway_request_id[\s\S]*?gateway_result[\s\S]*?completed_at/)
|
||||
assert.match(afterSale, /GET_LOCK\(%s, 5\)[\s\S]*?get_user_order\(\$user_id, \$order_number\)[\s\S]*?find_active\(\$order_number\)/)
|
||||
assert.match(afterSale, /Order::STATUS_PAID/)
|
||||
assert.match(afterSale, /\$gateway->refund\(\$order_number, \(string\) \$row\['refund_amount'\], \$request_id\)/)
|
||||
assert.match(afterSale, /query_refund\(\$request_id, \(string\) \$row\['order_number'\]\)/)
|
||||
assert.match(afterSale, /sanitize_key\(\(string\) \(\$gateway_result\['status'\] \?\? ''\)\) !== 'manual'/)
|
||||
assert.match(afterSale, /\$decision === 'reject'[\s\S]*?compensate_revoke\(\$row, \$order, \$revoke, \$note\)/)
|
||||
assert.match(afterSale, /Order::STATUS_REFUNDED/)
|
||||
assert.match(repository, /can_finalize_order_refund\(\(int\) \(\$extra\['after_sale_id'\] \?\? 0\), \$order_number\)/)
|
||||
assert.match(read('src/Payment/Gateways/PayPalGateway.php'), /'status' => 'manual'/)
|
||||
assert.match(read('src/Payment/Gateways/EpayGateway.php'), /'status'\s*=>\s*'manual'/)
|
||||
})
|
||||
|
||||
test('withdrawal holds and returns use the selected user-meta or commission pool', () => {
|
||||
const withdrawal = read('src/Payment/Withdrawal.php')
|
||||
const assets = read('src/Credits/MetaAssetService.php')
|
||||
|
||||
assert.match(withdrawal, /source_balance_decimal\(\$user_id, \$source\)/)
|
||||
assert.match(withdrawal, /private function source_balance_decimal\(int \$user_id, string \$source\)/)
|
||||
assert.match(withdrawal, /\$this->source_asset\(\$source\)/)
|
||||
assert.match(withdrawal, /\$this->source_balance_decimal\(\$user_id, \$refund_source\)/)
|
||||
assert.match(assets, /AssetType::BALANCE->value, AssetType::POINTS->value/)
|
||||
assert.match(assets, /function write_balance\([\s\S]*?\$wpdb->usermeta/)
|
||||
})
|
||||
@@ -9,10 +9,11 @@ const read = (relativePath) => fs.readFileSync(path.join(root, relativePath), 'u
|
||||
test('admin order filters expose only the current order and payment categories', () => {
|
||||
const page = read('src/Admin/Native/PayOrders.php')
|
||||
|
||||
assert.match(page, /ORDER_PRODUCT_RESOURCE\s*=>\s*__\('内容付费'/)
|
||||
assert.match(page, /ORDER_PRODUCT_VIP\s*=>\s*__\('VIP'/)
|
||||
assert.match(page, /ORDER_PRODUCT_RECHARGE\s*=>\s*__\('充值'/)
|
||||
assert.match(page, /ORDER_PRODUCT_POINTS\s*=>\s*__\('购买积分'/)
|
||||
for (const [type, label] of [
|
||||
['READING', '付费阅读'], ['DOWNLOAD', '付费下载'], ['IMAGE', '付费图片'], ['VIDEO', '付费视频'],
|
||||
['VIP', '购买会员'], ['RECHARGE', '余额充值'], ['POINTS', '购买积分'],
|
||||
]) assert.match(page, new RegExp(`ORDER_PRODUCT_${type}\\s*=>\\s*__\\('${label}'`))
|
||||
assert.doesNotMatch(page, /ORDER_PRODUCT_RESOURCE\s*=>/)
|
||||
assert.doesNotMatch(page, /shop_product|商城商品/)
|
||||
|
||||
assert.match(page, /''\s*=>\s*__\('支付方式'/)
|
||||
|
||||
Reference in New Issue
Block a user