feat(payment): rebuild order details and asset records

This commit is contained in:
2026-10-05 16:42:25 +08:00
parent 476e2f961d
commit 7b55cfb190
57 changed files with 2080 additions and 1294 deletions
+37 -44
View File
@@ -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: '已关闭' },
],
+95 -6
View File
@@ -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);
+3
View File
@@ -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();
}
+2 -2
View File
@@ -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';
}
}
+1 -2
View File
@@ -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'] ?? '');
-1
View File
@@ -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; ?>
-195
View File
@@ -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>' : '&mdash;') . '</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' => '&laquo;',
'next_text' => '&raquo;',
'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 '&mdash;';
}
$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) : '&mdash;';
}
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'));
}
}
+1 -8
View File
@@ -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
View File
@@ -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 {
-1
View File
@@ -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' => '已关闭'],
]],
+76 -74
View File
@@ -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
View File
@@ -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;
}
}
+1 -3
View File
@@ -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;
}
+10 -11
View File
@@ -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,
],
+1 -1
View File
@@ -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';
+25
View File
@@ -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),
+1
View File
@@ -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',
+49 -9
View File
@@ -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',
];
}
-11
View File
@@ -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'),
+10 -12
View File
@@ -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),
+348
View File
@@ -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));
}
}
}
+1 -1
View File
@@ -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';
-3
View File
@@ -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);
}
-198
View File
@@ -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');
}
}
+1 -216
View File
@@ -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');
}
+20
View File
@@ -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(
+110 -3
View File
@@ -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;
+554
View File
@@ -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; }
}
+5 -142
View File
@@ -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
View File
@@ -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,
+9 -1
View File
@@ -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'] ?? ''));
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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'),
];
}
+1
View File
@@ -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'),
];
}
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 {
-1
View File
@@ -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;
+7 -3
View File
@@ -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()];
}
+115 -33
View File
@@ -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';
}
}
+139 -53
View File
@@ -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;
}
/**
-2
View File
@@ -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')),
];
+1 -1
View File
@@ -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;
}
+7 -11
View File
@@ -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'] ?? ''),
+62 -7
View File
@@ -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.
*/
+2 -2
View File
@@ -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
View File
@@ -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 {
+3 -3
View File
@@ -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 {
+3 -3
View File
@@ -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}";
}
// ========== 用户列表:行操作 ==========
+14 -63
View File
@@ -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*__\('支付方式'/)