diff --git a/assets/admin/src/pages/CmsCredits.vue b/assets/admin/src/pages/CmsCredits.vue index 34933637..56437018 100644 --- a/assets/admin/src/pages/CmsCredits.vue +++ b/assets/admin/src/pages/CmsCredits.vue @@ -23,15 +23,13 @@ - + - - - - - - + + + + 查询 重置 @@ -40,13 +38,13 @@ :data="ledgerRows" :loading="ledgerLoading" :row-key="row => row.id" - :scroll-x="1600" + :scroll-x="1100" size="small" /> @@ -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() }) diff --git a/assets/admin/src/schema/sections/payment/knowledge.js b/assets/admin/src/schema/sections/payment/knowledge.js index fd9264c3..6e5749a8 100644 --- a/assets/admin/src/schema/sections/payment/knowledge.js +++ b/assets/admin/src/schema/sections/payment/knowledge.js @@ -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: '已关闭' }, ], diff --git a/assets/js/order-center.js b/assets/js/order-center.js index 0c198322..83f94c39 100644 --- a/assets/js/order-center.js +++ b/assets/js/order-center.js @@ -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 "
" + escapeHtml(label) + "
" + cell + "
"; } + 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 += '

交易信息

'; html += '
'; [ - ["流水号", 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 += "
"; + var afterSales = Array.isArray(order.after_sales) ? order.after_sales : []; + if (afterSales.length) { + html += '

售后记录

    '; + afterSales.forEach(function (item) { + var labels = { pending: "待审核", processing: "处理中", approved: "已通过", rejected: "已拒绝", cancelled: "已取消", completed: "已完成" }; + html += "
  • " + escapeHtml(labels[item.status] || item.status || "") + "" + escapeHtml(item.reason || "") + "退款金额:" + escapeHtml(afterSaleAmount(item.refund_amount, order)) + ""; + if (item.admin_note) html += "处理备注:" + escapeHtml(item.admin_note) + ""; + if (item.gateway_result) html += "渠道结果:" + escapeHtml(gatewayResultText(item.gateway_result)) + ""; + if (item.status === "pending") html += ''; + html += "
  • "; + }); + html += "
"; + } + if (reconciliation.state === "refresh_available") { html += '
'; html += ''; @@ -166,6 +199,7 @@ html += ''; if (allowedActions.can_cancel) html += ''; if (allowedActions.can_pay) html += ''; + if (allowedActions.can_after_sale) html += '
'; html += ""; session.content.innerHTML = html; @@ -244,6 +278,7 @@ var html = ''; if (allowedActions.can_cancel) html += ''; if (allowedActions.can_pay) html += ''; + if (allowedActions.can_after_sale) html += ''; return html; } @@ -256,7 +291,7 @@ : ""; return '
' + '
' + escapeHtml(order.product_type_label || "") + '' + escapeHtml(order.status_label || "") + "
" + - '
' + image + '

' + escapeHtml(order.product_name || "订单") + "

流水号 " + escapeHtml(order.order_number) + "

" + formatMoney(order) + "实付
" + + '
' + image + '

' + escapeHtml(order.product_name || "订单") + "

订单号 " + escapeHtml(order.order_number) + "

" + formatMoney(order) + "实付
" + '
' + countdown + actionHtml(order) + "
"; } @@ -265,7 +300,10 @@ if (!root) return; root.innerHTML = '
全部' + Number(stats.total || 0) + "
" + '
待支付' + Number(stats.pending || 0) + "
" + - '
已支付' + Number(stats.paid || 0) + "
"; + '
已支付' + Number(stats.paid || 0) + "
" + + '
已退款' + Number(stats.refunded || 0) + "
" + + '
已关闭' + Number(stats.closed || 0) + "
" + + '
售后待处理' + Number(stats.after_sale_pending || 0) + "
"; 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); diff --git a/functions.php b/functions.php index 6125947a..7c817caf 100644 --- a/functions.php +++ b/functions.php @@ -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(); } diff --git a/inc/helpers/aksrt-credits.php b/inc/helpers/aksrt-credits.php index 6ae64bb7..a383cfb9 100644 --- a/inc/helpers/aksrt-credits.php +++ b/inc/helpers/aksrt-credits.php @@ -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'; } } diff --git a/inc/helpers/aksrt-pay.php b/inc/helpers/aksrt-pay.php index fa947cb4..305eb66d 100644 --- a/inc/helpers/aksrt-pay.php +++ b/inc/helpers/aksrt-pay.php @@ -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'] ?? ''); diff --git a/inc/helpers/aksrt-rewards.php b/inc/helpers/aksrt-rewards.php index 93459e17..c692934a 100644 --- a/inc/helpers/aksrt-rewards.php +++ b/inc/helpers/aksrt-rewards.php @@ -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(), ]); diff --git a/inc/helpers/user-center-renderers/credits-transactions.php b/inc/helpers/user-center-renderers/credits-transactions.php index b5652661..3fe78cd2 100644 --- a/inc/helpers/user-center-renderers/credits-transactions.php +++ b/inc/helpers/user-center-renderers/credits-transactions.php @@ -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) : ?>
- = 0 ? 'is-positive' : 'is-negative'; ?>
@@ -35,12 +37,12 @@ defined('ABSPATH') || exit;
- +
- +
-
+
@@ -51,7 +53,7 @@ defined('ABSPATH') || exit;
1 && function_exists('aksrt_render_asset_pagination')) { aksrt_render_asset_pagination($total_pages, $credits_page, 'points'); } @@ -61,7 +63,7 @@ defined('ABSPATH') || exit;
-

+

diff --git a/inc/helpers/user-center-renderers/credits-wallet-ledger.php b/inc/helpers/user-center-renderers/credits-wallet-ledger.php index 5a7ffb2e..98dacd39 100644 --- a/inc/helpers/user-center-renderers/credits-wallet-ledger.php +++ b/inc/helpers/user-center-renderers/credits-wallet-ledger.php @@ -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); ?>
@@ -27,12 +24,14 @@ $wlog = \AKSRT\Ledger\LedgerQuery::get_instance()->history([
- +
- 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(); ?>
@@ -40,9 +39,10 @@ $wlog = \AKSRT\Ledger\LedgerQuery::get_instance()->history([
- +
-
+
+
@@ -51,7 +51,7 @@ $wlog = \AKSRT\Ledger\LedgerQuery::get_instance()->history([
1 && function_exists('aksrt_render_asset_pagination')) { aksrt_render_asset_pagination($total_pages, $wallet_page, 'balance'); } diff --git a/inc/helpers/user-center-renderers/profile-orders.php b/inc/helpers/user-center-renderers/profile-orders.php index 114d3673..c73930f0 100644 --- a/inc/helpers/user-center-renderers/profile-orders.php +++ b/inc/helpers/user-center-renderers/profile-orders.php @@ -39,7 +39,6 @@ $status_options = [ 'all' => __('全部', 'aksrt'), 'pending' => __('待支付', 'aksrt'), 'paid' => __('已支付', 'aksrt'), - 'refunding' => __('退款中', 'aksrt'), 'refunded' => __('已退款', 'aksrt'), 'closed' => __('已关闭', 'aksrt'), ]; @@ -58,13 +57,15 @@ $status_options = [
+
+
@@ -112,7 +113,7 @@ $status_options = [
-

+

@@ -120,6 +121,7 @@ $status_options = [ +
diff --git a/src/Admin/Native/PayFlows.php b/src/Admin/Native/PayFlows.php deleted file mode 100644 index 95a39ad4..00000000 --- a/src/Admin/Native/PayFlows.php +++ /dev/null @@ -1,195 +0,0 @@ -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 '

' . esc_html__('流水明细', 'aksrt') . '

'; - echo '

' . esc_html__('仅展示已经确认发生的现金、余额和积分变动;流水不可编辑。', 'aksrt') . '

'; - echo '
'; - echo ''; - echo ''; - echo ''; - echo ''; - $this->render_select('asset', $asset, __('全部资产', 'aksrt'), ['cash' => __('现金', 'aksrt'), 'balance' => __('余额', 'aksrt'), 'points' => __('积分', 'aksrt')]); - $this->render_select('direction', $direction, __('全部方向', 'aksrt'), ['in' => __('收入', 'aksrt'), 'out' => __('支出', 'aksrt')]); - echo ''; - echo ''; - submit_button(__('筛选', 'aksrt'), 'secondary', '', false); - if ($keyword !== '' || $event_type !== '' || $asset !== '' || $direction !== '' || $user_id > 0 || $date_from !== '' || $date_to !== '') { - echo '' . esc_html__('清除条件', 'aksrt') . ''; - } - echo '
'; - echo '

' . esc_html(sprintf(__('共 %d 条流水', 'aksrt'), (int) $result['total'])) . '

'; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - if ($result['items'] === []) { - echo ''; - } - foreach ($result['items'] as $flow) { - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - } - echo '
' . esc_html__('账本流水号 / 时间', 'aksrt') . '' . esc_html__('事件', 'aksrt') . '' . esc_html__('用户与分录', 'aksrt') . '' . esc_html__('关联业务', 'aksrt') . '' . esc_html__('渠道交易号', 'aksrt') . '
' . esc_html__('暂无匹配流水', 'aksrt') . '
' . esc_html((string) $flow['flow_number']) . '
' . esc_html($this->local_time((string) $flow['created_at'])) . '
' . esc_html($this->event_label((string) $flow['event_type'])) . '
' . esc_html((string) $flow['event_type']) . '
' . $this->entries_html((array) $flow['entries']) . '' . $this->reference_html($flow) . '' . ((string) ($flow['provider_transaction_id'] ?? '') !== '' ? '' . esc_html((string) $flow['provider_transaction_id']) . '' : '—') . '
'; - if ($pages > 1) { - echo '
' . wp_kses_post((string) paginate_links([ - 'base' => add_query_arg('paged', '%#%'), - 'format' => '', - 'prev_text' => '«', - 'next_text' => '»', - 'total' => $pages, - 'current' => $page, - ])) . '
'; - } - echo '
'; - } - - /** @param array $options */ - private function render_select(string $name, string $value, string $empty_label, array $options): void { - echo ''; - } - - /** @param array> $entries */ - private function entries_html(array $entries): string { - if ($entries === []) { - return '—'; - } - $lines = []; - foreach ($entries as $entry) { - $user_id = (int) ($entry['user_id'] ?? 0); - $user = $user_id > 0 ? get_userdata($user_id) : null; - $user_label = $user ? $user->display_name . ' (#' . $user_id . ')' : '#' . $user_id; - $asset = (string) ($entry['asset'] ?? ''); - $delta = (int) ($entry['delta_minor'] ?? 0); - $amount = $asset === 'points' - ? (string) abs($delta) - : \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, abs($delta))->decimal(); - $asset_label = ['cash' => __('现金', 'aksrt'), 'balance' => __('余额', 'aksrt'), 'points' => __('积分', 'aksrt')][$asset] ?? $asset; - $lines[] = esc_html($user_label) . ' · ' . esc_html($asset_label) . ' ' . ($delta > 0 ? '+' : '-') . esc_html($amount) . ''; - } - return implode('
', $lines); - } - - /** @param array $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') . ' ' . esc_html((string) $flow['order_number']) . ''; - } - if (!empty($flow['withdrawal_number'])) { - $lines[] = esc_html__('提现', 'aksrt') . ' ' . esc_html((string) $flow['withdrawal_number']) . ''; - } - if ($lines === [] && !empty($flow['ref_key'])) { - $lines[] = esc_html((string) ($flow['ref_type'] ?? '')) . ' · ' . esc_html((string) $flow['ref_key']); - } - return $lines !== [] ? implode('
', $lines) : '—'; - } - - private function event_label(string $event_type): string { - $labels = [ - 'order_payment' => __('订单支付', 'aksrt'), - 'order_refund' => __('订单退款', 'aksrt'), - 'withdraw_hold' => __('提现冻结', 'aksrt'), - 'withdraw_refund' => __('提现退回', 'aksrt'), - 'withdraw_payout' => __('提现打款', 'aksrt'), - 'creator_income' => __('创作收益', 'aksrt'), - 'admin_adjustment' => __('后台调账', 'aksrt'), - 'points_exchange' => __('积分兑换', 'aksrt'), - 'transfer' => __('用户转账', 'aksrt'), - ]; - return $labels[$event_type] ?? $event_type; - } - - private function sanitize_date(string $date): string { - return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1 ? $date : ''; - } - - private function date_bound_utc(string $date, bool $exclusive_end): string { - if ($date === '') { - return ''; - } - $local = new \DateTimeImmutable($date . ' 00:00:00', wp_timezone()); - if ($exclusive_end) { - $local = $local->modify('+1 day'); - } - return $local->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s'); - } - - private function local_time(string $utc): string { - if ($utc === '') { - return ''; - } - try { - return (new \DateTimeImmutable($utc, new \DateTimeZone('UTC')))->setTimezone(wp_timezone())->format('Y-m-d H:i:s'); - } catch (\Exception $e) { - return $utc; - } - } - - private function page_url(): string { - return add_query_arg(['page' => self::PAGE_SLUG], admin_url('admin.php')); - } -} diff --git a/src/Admin/Native/PayMenus.php b/src/Admin/Native/PayMenus.php index ae565855..0df6a43c 100644 --- a/src/Admin/Native/PayMenus.php +++ b/src/Admin/Native/PayMenus.php @@ -43,7 +43,7 @@ class PayMenus { ], 'orders' => [ 'slug' => 'aksrt_orders', - 'title' => __('订单', 'aksrt'), + 'title' => __('订单明细', 'aksrt'), 'callback' => static function (): void { if (class_exists('AKSRT\Admin\Native\PayOrders')) { PayOrders::get_instance()->render_orders_page(); @@ -52,13 +52,6 @@ class PayMenus { wp_die(esc_html__('订单模块未初始化。', 'aksrt')); }, ], - 'flows' => [ - 'slug' => PayFlows::PAGE_SLUG, - 'title' => __('流水明细', 'aksrt'), - 'callback' => static function (): void { - PayFlows::get_instance()->render_page(); - }, - ], 'products' => [ 'slug' => 'aksrt_pay_products', 'title' => __('付费文章', 'aksrt'), diff --git a/src/Admin/Native/PayOrders.php b/src/Admin/Native/PayOrders.php index e66ad008..8aa34857 100644 --- a/src/Admin/Native/PayOrders.php +++ b/src/Admin/Native/PayOrders.php @@ -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 '

' . esc_html__('订单服务不可用。', 'aksrt') . '

'; @@ -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 ''; } + echo ' '; + echo ' '; echo ' '; echo ' '; - echo ' '; + echo ' '; 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 ' ' . esc_html(implode(' · ', $filter_bits)) . ''; echo ' ' . esc_html__('清除过滤', 'aksrt') . ''; } @@ -200,7 +230,7 @@ class PayOrders { echo ''; echo ''; - echo ''; + echo ''; echo ''; echo ''; echo ''; @@ -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 ''; echo ''; echo ''; - echo ''; + echo ''; echo ''; echo ''; echo ''; @@ -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 '
' . esc_html__('流水号', 'aksrt') . '' . esc_html__('订单号', 'aksrt') . '' . esc_html__('用户', 'aksrt') . '' . esc_html__('商品信息', 'aksrt') . '' . esc_html__('金额 / 支付方式', 'aksrt') . '
' . esc_html($order_number) . '' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . '' . esc_html($product_title !== '' ? $product_title : $product_type) . '
' . esc_html($product_type) . ($product_id > 0 ? ' #' . esc_html((string) $product_id) : '') . '
' . esc_html($product_title !== '' ? $product_title : ($type_labels[$product_type] ?? $product_type)) . '
' . esc_html($type_labels[$product_type] ?? $product_type) . ($product_id > 0 ? ' #' . esc_html((string) $product_id) : '') . '
' . esc_html($this->order_amount($row)) . '
' . esc_html((string) ($row['payment_method'] ?? '')) . '
' . esc_html($status_label) . '' . esc_html((string) ($row['created_at'] ?? '')) . '
' . esc_html((string) ($row['paid_at'] ?? '') ?: '—') . '
'; $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 ''; } + $after_sales = \AKSRT\Payment\AfterSaleService::get_instance()->list_for_order($order_number); + if ($after_sales) { + echo ''; + } if (!empty($context)) { echo ''; } @@ -331,6 +372,92 @@ class PayOrders { echo ''; } + private function render_workbench_tabs(string $active): void { + echo ''; + } + + 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 ''; + echo ' '; + echo ' '; + submit_button(__('筛选', 'aksrt'), 'secondary', '', false); + echo '
'; + 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 '

' . esc_html__('按商品类型统计', 'aksrt') . '

' . esc_html($label) . '' . esc_html((string) $value) . '
' . esc_html__('售后记录', 'aksrt') . ''; + $status_labels = ['pending' => '待审核', 'processing' => '处理中', 'approved' => '已通过', 'rejected' => '已拒绝', 'cancelled' => '已取消', 'completed' => '已完成']; + foreach ($after_sales as $application) { + echo '

' . esc_html($status_labels[(string) ($application['status'] ?? '')] ?? (string) ($application['status'] ?? '')) . ' · ' . esc_html((string) ($application['refund_amount'] ?? '')) . '
' . esc_html((string) ($application['reason'] ?? '')); + if (!empty($application['admin_note'])) echo '
' . esc_html__('处理备注:', 'aksrt') . esc_html((string) $application['admin_note']); + if (!empty($application['gateway_result'])) echo '
' . esc_html((string) $application['gateway_result']) . ''; + echo '

'; + } + echo '
' . esc_html__('附加上下文', 'aksrt') . '
' . esc_html(wp_json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) . '
'; + foreach ((array) ($stats['by_type'] ?? []) as $type => $count) echo ''; + echo '
' . esc_html(\AKSRT\Core\DomainModel::order_product_type_labels()[$type] ?? $type) . '' . esc_html((string) $count) . '
'; + } + + 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 '
'; + echo ' '; + submit_button(__('筛选', 'aksrt'), 'secondary', '', false); + echo '
'; + if (!$items) echo ''; + 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 ''; + } + echo '
ID订单号用户原因退款金额状态操作 / 处理记录
' . esc_html__('暂无售后申请', 'aksrt') . '
' . esc_html((string) $id) . '
' . esc_html((string) ($item['created_at'] ?? '')) . '
' . esc_html($number) . '
' . esc_html($type_label) . ($order ? ' · ' . esc_html(\AKSRT\Payment\UserOrders::get_instance()->decorate_order_view($order, false)['product_name'] ?? '') : '') . '
' . esc_html($user ? $user->display_name . ' (#' . $uid . ')' : '#' . $uid) . '' . esc_html((string) $item['reason']) . '' . esc_html($refund_amount) . '' . esc_html($status_labels[(string) $item['status']] ?? (string) $item['status']) . ''; + if ((string) $item['status'] === 'pending') foreach (['approve' => '通过', 'reject' => '拒绝'] as $decision => $label) { + echo '
'; wp_nonce_field('aksrt_after_sale_review_' . $id); echo '
'; + } + 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 '
'; wp_nonce_field('aksrt_after_sale_review_' . $id); echo '
'; + } + } + if (!empty($item['admin_note'])) echo '' . esc_html__('备注:', 'aksrt') . esc_html((string) $item['admin_note']) . '
'; + 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 '' . esc_html__('渠道结果:', 'aksrt') . ' ' . esc_html($result_text) . ''; + } + echo '
'; + } + + 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 '
'; echo ''; @@ -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 '

' . esc_html($map[$code]) . '

'; + if (!isset($map[$code])) { + return; + } + $class = str_contains($code, 'failed') ? 'notice-error' : 'notice-success'; + echo '

' . esc_html($map[$code]) . '

'; + if ($code === 'after_sale_review_failed' && isset($_GET['after_sale_error'])) { + echo '

' . esc_html(sanitize_text_field((string) wp_unslash($_GET['after_sale_error']))) . '

'; + } } private function page_url(string $slug, array $args = []): string { diff --git a/src/Admin/Schema/SchemaPayment.php b/src/Admin/Schema/SchemaPayment.php index 3688fc9b..58d049ff 100644 --- a/src/Admin/Schema/SchemaPayment.php +++ b/src/Admin/Schema/SchemaPayment.php @@ -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' => '已关闭'], ]], diff --git a/src/Admin/UserController.php b/src/Admin/UserController.php index ac1cfcb9..c6ff20b1 100644 --- a/src/Admin/UserController.php +++ b/src/Admin/UserController.php @@ -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; } + } diff --git a/src/Content/PayOrders.php b/src/Content/PayOrders.php index f0d0e4f8..83d8789f 100644 --- a/src/Content/PayOrders.php +++ b/src/Content/PayOrders.php @@ -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; } } diff --git a/src/Content/PayRest.php b/src/Content/PayRest.php index 01bbad58..bbf8adc0 100644 --- a/src/Content/PayRest.php +++ b/src/Content/PayRest.php @@ -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; } diff --git a/src/Content/VipServicePurchase.php b/src/Content/VipServicePurchase.php index fc356379..89ca6ce2 100644 --- a/src/Content/VipServicePurchase.php +++ b/src/Content/VipServicePurchase.php @@ -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, ], diff --git a/src/Core/DBMigrator.php b/src/Core/DBMigrator.php index d85c6af1..30478cc9 100644 --- a/src/Core/DBMigrator.php +++ b/src/Core/DBMigrator.php @@ -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'; diff --git a/src/Core/DBMigratorPart2.php b/src/Core/DBMigratorPart2.php index 5022f034..52de207e 100644 --- a/src/Core/DBMigratorPart2.php +++ b/src/Core/DBMigratorPart2.php @@ -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), diff --git a/src/Core/DBMigratorPart3.php b/src/Core/DBMigratorPart3.php index 9b89e4b1..608ebb80 100644 --- a/src/Core/DBMigratorPart3.php +++ b/src/Core/DBMigratorPart3.php @@ -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', diff --git a/src/Core/DomainModel.php b/src/Core/DomainModel.php index 15f7fabd..c21c2add 100644 --- a/src/Core/DomainModel.php +++ b/src/Core/DomainModel.php @@ -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', ]; } diff --git a/src/Credits/BalanceServiceCharge.php b/src/Credits/BalanceServiceCharge.php index e0b379f4..75c0df1c 100644 --- a/src/Credits/BalanceServiceCharge.php +++ b/src/Credits/BalanceServiceCharge.php @@ -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'), diff --git a/src/Credits/BalanceServiceRest.php b/src/Credits/BalanceServiceRest.php index 0ff7be39..51cf696c 100644 --- a/src/Credits/BalanceServiceRest.php +++ b/src/Credits/BalanceServiceRest.php @@ -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), diff --git a/src/Credits/MetaAssetService.php b/src/Credits/MetaAssetService.php new file mode 100644 index 00000000..4c4e086a --- /dev/null +++ b/src/Credits/MetaAssetService.php @@ -0,0 +1,348 @@ +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|\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> $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)); + } + } +} diff --git a/src/Credits/PointsFree.php b/src/Credits/PointsFree.php index 2b55c35c..08dcd04b 100644 --- a/src/Credits/PointsFree.php +++ b/src/Credits/PointsFree.php @@ -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'; diff --git a/src/Credits/Signin.php b/src/Credits/Signin.php index a913238c..760cacf5 100644 --- a/src/Credits/Signin.php +++ b/src/Credits/Signin.php @@ -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); } diff --git a/src/Finance/FinancialFlowQuery.php b/src/Finance/FinancialFlowQuery.php deleted file mode 100644 index 4d39685d..00000000 --- a/src/Finance/FinancialFlowQuery.php +++ /dev/null @@ -1,198 +0,0 @@ -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 $order_numbers @return array */ - 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 $args - * @return array{items:array>,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 */ - 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 $flow_ids @return array>> */ - 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'); - } -} diff --git a/src/Finance/FinancialFlowService.php b/src/Finance/FinancialFlowService.php index 66cda609..eb96a1dd 100644 --- a/src/Finance/FinancialFlowService.php +++ b/src/Finance/FinancialFlowService.php @@ -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 $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 $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 */ - 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 $order @param array> $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 $order @param array> $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'); } diff --git a/src/Ledger/LedgerQuery.php b/src/Ledger/LedgerQuery.php index b099dac1..17d32e5b 100644 --- a/src/Ledger/LedgerQuery.php +++ b/src/Ledger/LedgerQuery.php @@ -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( diff --git a/src/Ledger/LedgerService.php b/src/Ledger/LedgerService.php index 7ef09b6b..3c894116 100644 --- a/src/Ledger/LedgerService.php +++ b/src/Ledger/LedgerService.php @@ -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; diff --git a/src/Payment/AfterSaleService.php b/src/Payment/AfterSaleService.php new file mode 100644 index 00000000..4f1a835b --- /dev/null +++ b/src/Payment/AfterSaleService.php @@ -0,0 +1,554 @@ +[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> */ + 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> */ + 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> $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> $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 $row */ + private function normalize(array $row): array { $row['id'] = (int) ($row['id'] ?? 0); $row['user_id'] = (int) ($row['user_id'] ?? 0); return $row; } +} diff --git a/src/Payment/BalanceGateway.php b/src/Payment/BalanceGateway.php index e5f37e92..fa9d6425 100644 --- a/src/Payment/BalanceGateway.php +++ b/src/Payment/BalanceGateway.php @@ -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')) { diff --git a/src/Payment/ChargeCard.php b/src/Payment/ChargeCard.php index 78b89b0d..7d6fd949 100644 --- a/src/Payment/ChargeCard.php +++ b/src/Payment/ChargeCard.php @@ -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 $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, diff --git a/src/Payment/CommissionLedger.php b/src/Payment/CommissionLedger.php index fc0da93e..ca523c63 100644 --- a/src/Payment/CommissionLedger.php +++ b/src/Payment/CommissionLedger.php @@ -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'] ?? '')); diff --git a/src/Payment/Gateways/AlipayGateway.php b/src/Payment/Gateways/AlipayGateway.php index b1755ba4..801ac889 100644 --- a/src/Payment/Gateways/AlipayGateway.php +++ b/src/Payment/Gateways/AlipayGateway.php @@ -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 { diff --git a/src/Payment/Gateways/EpayGateway.php b/src/Payment/Gateways/EpayGateway.php index b4d1c34d..9dc1187c 100644 --- a/src/Payment/Gateways/EpayGateway.php +++ b/src/Payment/Gateways/EpayGateway.php @@ -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'), ]; } diff --git a/src/Payment/Gateways/PayPalGateway.php b/src/Payment/Gateways/PayPalGateway.php index 6cb9d690..9cac2779 100644 --- a/src/Payment/Gateways/PayPalGateway.php +++ b/src/Payment/Gateways/PayPalGateway.php @@ -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'), ]; } diff --git a/src/Payment/Gateways/StripeGateway.php b/src/Payment/Gateways/StripeGateway.php index 38e27575..9a53282d 100644 --- a/src/Payment/Gateways/StripeGateway.php +++ b/src/Payment/Gateways/StripeGateway.php @@ -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 { diff --git a/src/Payment/Gateways/WechatPayGateway.php b/src/Payment/Gateways/WechatPayGateway.php index 0fb10ea5..1cf66174 100644 --- a/src/Payment/Gateways/WechatPayGateway.php +++ b/src/Payment/Gateways/WechatPayGateway.php @@ -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 { diff --git a/src/Payment/Order.php b/src/Payment/Order.php index c7db2baf..72e82c45 100644 --- a/src/Payment/Order.php +++ b/src/Payment/Order.php @@ -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; diff --git a/src/Payment/OrderCenterQuery.php b/src/Payment/OrderCenterQuery.php index 6e80c438..348e466a 100644 --- a/src/Payment/OrderCenterQuery.php +++ b/src/Payment/OrderCenterQuery.php @@ -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()]; } diff --git a/src/Payment/OrderRepositoryAdmin.php b/src/Payment/OrderRepositoryAdmin.php index e1da818c..930efeb1 100644 --- a/src/Payment/OrderRepositoryAdmin.php +++ b/src/Payment/OrderRepositoryAdmin.php @@ -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'; } } diff --git a/src/Payment/OrderRepositoryCore.php b/src/Payment/OrderRepositoryCore.php index 6866998e..87df58b9 100644 --- a/src/Payment/OrderRepositoryCore.php +++ b/src/Payment/OrderRepositoryCore.php @@ -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} */ 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 */ + 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; + } + /** diff --git a/src/Payment/PaymentOrchestrator.php b/src/Payment/PaymentOrchestrator.php index a6b6690a..8aa79047 100644 --- a/src/Payment/PaymentOrchestrator.php +++ b/src/Payment/PaymentOrchestrator.php @@ -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')), ]; diff --git a/src/Payment/Revenue.php b/src/Payment/Revenue.php index 1066d78e..d646b662 100644 --- a/src/Payment/Revenue.php +++ b/src/Payment/Revenue.php @@ -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; } diff --git a/src/Payment/UserOrders.php b/src/Payment/UserOrders.php index 25e705b8..05199d8b 100644 --- a/src/Payment/UserOrders.php +++ b/src/Payment/UserOrders.php @@ -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'] ?? ''), diff --git a/src/Payment/Withdrawal.php b/src/Payment/Withdrawal.php index 66304a49..98e22931 100644 --- a/src/Payment/Withdrawal.php +++ b/src/Payment/Withdrawal.php @@ -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. */ diff --git a/src/Settings/PaymentRuntime.php b/src/Settings/PaymentRuntime.php index a7ca17ef..900f0c75 100644 --- a/src/Settings/PaymentRuntime.php +++ b/src/Settings/PaymentRuntime.php @@ -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}"] ?? '')); diff --git a/src/User/Badges.php b/src/User/Badges.php index bf41c06e..727dcc1e 100644 --- a/src/User/Badges.php +++ b/src/User/Badges.php @@ -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 { diff --git a/src/User/BalanceAdmin.php b/src/User/BalanceAdmin.php index f464221a..2ba1b596 100644 --- a/src/User/BalanceAdmin.php +++ b/src/User/BalanceAdmin.php @@ -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 { diff --git a/src/User/CreditsAdmin.php b/src/User/CreditsAdmin.php index c0ff6610..63a63231 100644 --- a/src/User/CreditsAdmin.php +++ b/src/User/CreditsAdmin.php @@ -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}"; } // ========== 用户列表:行操作 ========== diff --git a/tests/admin/card-pass-order-numbers.test.cjs b/tests/admin/card-pass-order-numbers.test.cjs index 3f3b7d8d..1b9fd277 100644 --- a/tests/admin/card-pass-order-numbers.test.cjs +++ b/tests/admin/card-pass-order-numbers.test.cjs @@ -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}`); + } }); diff --git a/tests/widget-system/asset-ledger-contract.test.cjs b/tests/widget-system/asset-ledger-contract.test.cjs index eb4f42e2..cc518200 100644 --- a/tests/widget-system/asset-ledger-contract.test.cjs +++ b/tests/widget-system/asset-ledger-contract.test.cjs @@ -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, /