diff --git a/inc/helpers/user-center-renderers/sidebar-commerce.php b/inc/helpers/user-center-renderers/sidebar-commerce.php
index c2647206..4b7dc508 100644
--- a/inc/helpers/user-center-renderers/sidebar-commerce.php
+++ b/inc/helpers/user-center-renderers/sidebar-commerce.php
@@ -26,7 +26,7 @@ $points = max(0, (int) ($credit_balance ?? 0));
-
+
@@ -35,7 +35,7 @@ $points = max(0, (int) ($credit_balance ?? 0));
-
+
diff --git a/src/Core/DomainModel.php b/src/Core/DomainModel.php
index d623b3c2..af0b5533 100644
--- a/src/Core/DomainModel.php
+++ b/src/Core/DomainModel.php
@@ -27,6 +27,7 @@ class DomainModel {
public const ORDER_PRODUCT_RESOURCE = 'resource';
public const ORDER_PRODUCT_VIP = 'vip';
public const ORDER_PRODUCT_RECHARGE = 'recharge';
+ public const ORDER_PRODUCT_POINTS = 'points';
/**
* Human labels for aksrt_order.product_type (front-end order center).
@@ -38,6 +39,7 @@ class DomainModel {
self::ORDER_PRODUCT_RESOURCE => __('付费内容', 'aksrt'),
self::ORDER_PRODUCT_VIP => __('VIP 会员', 'aksrt'),
self::ORDER_PRODUCT_RECHARGE => __('余额充值', 'aksrt'),
+ self::ORDER_PRODUCT_POINTS => __('积分购买', 'aksrt'),
];
}
diff --git a/src/Credits/BalanceServiceRest.php b/src/Credits/BalanceServiceRest.php
index c0c4da80..93d7953f 100644
--- a/src/Credits/BalanceServiceRest.php
+++ b/src/Credits/BalanceServiceRest.php
@@ -12,6 +12,12 @@ trait BalanceServiceRest {
public function register_routes(): void {
+ register_rest_route('aksrt/v1', '/pay/assets', [
+ 'methods' => \WP_REST_Server::READABLE,
+ 'callback' => [$this, 'rest_asset_snapshot'],
+ 'permission_callback' => [$this, 'check_asset_rest'],
+ ]);
+
register_rest_route('aksrt/v1', '/pay/balance/charge', [
[
'methods' => \WP_REST_Server::READABLE,
@@ -68,6 +74,17 @@ trait BalanceServiceRest {
}
+ public function rest_asset_snapshot(\WP_REST_Request $request) {
+ $user_id = get_current_user_id();
+ return rest_ensure_response([
+ 'success' => true,
+ 'data' => [
+ 'wallet_balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
+ 'credit_balance' => (string) \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
+ ],
+ ]);
+ }
+
public function check_asset_rest(\WP_REST_Request $request) {
if (!is_user_logged_in()) {
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
@@ -356,9 +373,36 @@ trait BalanceServiceRest {
}
return true;
};
- $business_mutation = static function () use ($user_id, $amount, $points, $operation_key) {
+ $order_number = 'PX' . strtoupper(substr(hash('sha256', $operation_key), 0, 30));
+ $business_mutation = static function (int $transaction_id) use ($user_id, $amount, $points, $operation_key, $order_number) {
+ if (!class_exists('\\AKSRT\\Payment\\OrderRepository')) {
+ return new \WP_Error('aksrt_order_unavailable', __('订单服务不可用', 'aksrt'), ['status' => 500]);
+ }
+ $note = wp_json_encode([
+ 'points_exchange' => 1,
+ 'points' => $points,
+ 'product_title' => sprintf(__('购买 %d 积分', 'aksrt'), $points),
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ $created = \AKSRT\Payment\OrderRepository::get_instance()->create([
+ 'order_number' => $order_number,
+ 'user_id' => $user_id,
+ 'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS,
+ 'product_id' => 0,
+ 'amount' => $amount->decimal(),
+ 'currency' => 'CNY',
+ 'status' => \AKSRT\Payment\Order::STATUS_COMPLETED,
+ 'payment_method' => 'balance',
+ 'payment_transaction_id' => 'ledger_' . $transaction_id,
+ 'paid_at' => current_time('mysql'),
+ 'completed_at' => current_time('mysql'),
+ 'note' => is_string($note) ? $note : '',
+ 'request_key' => $operation_key,
+ ]);
+ if ($created !== $order_number) {
+ return new \WP_Error('aksrt_order_create_failed', __('积分订单创建失败', 'aksrt'), ['status' => 500]);
+ }
if (class_exists('\\AKSRT\\Payment\\CommissionLedger')) {
- return \AKSRT\Payment\CommissionLedger::get_instance()->record_points_exchange($user_id, $amount->decimal(), $points, $operation_key);
+ return \AKSRT\Payment\CommissionLedger::get_instance()->record_points_exchange($user_id, $amount->decimal(), $points, $operation_key, $order_number);
}
return true;
};
@@ -368,7 +412,7 @@ trait BalanceServiceRest {
$points,
$operation_key,
'points_exchange',
- ['type' => 'points_exchange', 'key' => $operation_key],
+ ['type' => 'order', 'key' => $order_number],
sprintf(__('余额兑换 %d 积分', 'aksrt'), $points),
['rate' => $rate],
$guard,
@@ -384,6 +428,7 @@ trait BalanceServiceRest {
'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),
'points' => $points,
+ 'order_number' => $order_number,
'reload' => true,
]]);
}
diff --git a/src/Payment/ChargeCard.php b/src/Payment/ChargeCard.php
index 592fa579..62b748db 100644
--- a/src/Payment/ChargeCard.php
+++ b/src/Payment/ChargeCard.php
@@ -714,6 +714,7 @@ class ChargeCard {
$amount = $parsed_amount->decimal();
$currency = 'CNY';
} elseif ($type === self::TYPE_CREDITS) {
+ $product_type = \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS;
$amount = (int) ($meta['points'] ?? 0);
$currency = 'points';
}
diff --git a/src/Payment/CommissionLedger.php b/src/Payment/CommissionLedger.php
index cdeddef1..53b48af7 100644
--- a/src/Payment/CommissionLedger.php
+++ b/src/Payment/CommissionLedger.php
@@ -28,9 +28,9 @@ final class CommissionLedger {
? true
: new \WP_Error('aksrt_commission_write_failed', __('返佣记录写入失败', 'aksrt'), ['status' => 500]);
}
- public function record_points_exchange(int $user_id, string $amount, int $points, string $operation_key): bool {
+ public function record_points_exchange(int $user_id, string $amount, int $points, string $operation_key, string $order_number): bool {
$result = $this->record_paid_order([
- 'order_number' => 'PX' . substr(hash('sha256', $operation_key), 0, 30),
+ 'order_number' => $order_number,
'product_type' => 'credits',
'user_id' => $user_id,
'amount' => $amount,
diff --git a/src/Payment/OrderCenterQuery.php b/src/Payment/OrderCenterQuery.php
index dd367861..95ce708d 100644
--- a/src/Payment/OrderCenterQuery.php
+++ b/src/Payment/OrderCenterQuery.php
@@ -89,14 +89,29 @@ final class OrderCenterQuery {
public function database_statuses(): array {
return match ($this->status()) {
self::STATUS_PENDING => [Order::STATUS_CREATED, Order::STATUS_PENDING],
- self::STATUS_COMPLETED => [Order::STATUS_PAID],
- self::STATUS_CLOSED => [Order::STATUS_CANCELLED, Order::STATUS_EXPIRED],
+ self::STATUS_COMPLETED => [
+ Order::STATUS_PAID,
+ Order::STATUS_DELIVERED,
+ Order::STATUS_COMPLETED,
+ Order::STATUS_PENDING_REVIEW,
+ ],
+ self::STATUS_CLOSED => [
+ Order::STATUS_CANCELLED,
+ Order::STATUS_EXPIRED,
+ Order::STATUS_REFUNDING,
+ Order::STATUS_REFUNDED,
+ ],
default => [
Order::STATUS_CREATED,
Order::STATUS_PENDING,
Order::STATUS_PAID,
+ Order::STATUS_DELIVERED,
+ Order::STATUS_COMPLETED,
+ Order::STATUS_PENDING_REVIEW,
Order::STATUS_CANCELLED,
Order::STATUS_EXPIRED,
+ Order::STATUS_REFUNDING,
+ Order::STATUS_REFUNDED,
],
};
}
diff --git a/src/Payment/OrderRepositoryCore.php b/src/Payment/OrderRepositoryCore.php
index 2915ad85..0392b24c 100644
--- a/src/Payment/OrderRepositoryCore.php
+++ b/src/Payment/OrderRepositoryCore.php
@@ -59,9 +59,10 @@ trait OrderRepositoryCore {
'payment_transaction_id' => sanitize_text_field((string) ($data['payment_transaction_id'] ?? '')),
'created_at' => $data['created_at'] ?? current_time('mysql'),
'paid_at' => $data['paid_at'] ?? null,
+ 'completed_at' => $data['completed_at'] ?? null,
'note' => $note,
];
- $formats = ['%s', '%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s'];
+ $formats = ['%s', '%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s'];
// Pending orders always receive an expiry. Explicit caller values remain
// supported for compatibility, while new flows use the shared runtime policy.
@@ -204,20 +205,22 @@ trait OrderRepositoryCore {
$parts = $this->get_user_order_query_parts($user_id, $args);
$pending_statuses = [Order::STATUS_CREATED, Order::STATUS_PENDING];
- $paid_status = Order::STATUS_PAID;
+ $completed_statuses = [Order::STATUS_PAID, Order::STATUS_DELIVERED, Order::STATUS_COMPLETED, Order::STATUS_PENDING_REVIEW];
+ $completed_placeholders = implode(', ', array_fill(0, count($completed_statuses), '%s'));
$known_statuses = OrderCenterQuery::from_array([])->database_statuses();
$known_placeholders = implode(', ', array_fill(0, count($known_statuses), '%s'));
$sql = "SELECT
(SELECT COUNT(*) FROM {$this->table()} {$parts['where']}) AS filtered_total,
COUNT(*) AS known_total,
SUM(CASE WHEN status IN (%s, %s) THEN 1 ELSE 0 END) AS pending,
- SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS completed
+ SUM(CASE WHEN status IN ({$completed_placeholders}) THEN 1 ELSE 0 END) AS completed
FROM {$this->table()}
WHERE user_id = %d AND status IN ({$known_placeholders})";
$params = array_merge(
$parts['params'],
$pending_statuses,
- [$paid_status, $user_id],
+ $completed_statuses,
+ [$user_id],
$known_statuses
);
$row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A);
diff --git a/src/Payment/UserOrders.php b/src/Payment/UserOrders.php
index 5102090e..c4108e9f 100644
--- a/src/Payment/UserOrders.php
+++ b/src/Payment/UserOrders.php
@@ -286,8 +286,8 @@ class UserOrders {
private function status_group(string $status): string {
return match ($status) {
Order::STATUS_CREATED, Order::STATUS_PENDING => OrderCenterQuery::STATUS_PENDING,
- Order::STATUS_PAID => OrderCenterQuery::STATUS_COMPLETED,
- Order::STATUS_CANCELLED, Order::STATUS_EXPIRED => OrderCenterQuery::STATUS_CLOSED,
+ Order::STATUS_PAID, Order::STATUS_DELIVERED, Order::STATUS_COMPLETED, Order::STATUS_PENDING_REVIEW => OrderCenterQuery::STATUS_COMPLETED,
+ Order::STATUS_CANCELLED, Order::STATUS_EXPIRED, Order::STATUS_REFUNDING, Order::STATUS_REFUNDED => OrderCenterQuery::STATUS_CLOSED,
default => OrderCenterQuery::STATUS_CLOSED,
};
}
@@ -313,6 +313,9 @@ class UserOrders {
if ($type === DomainModel::ORDER_PRODUCT_RECHARGE) {
return __('余额充值', 'aksrt');
}
+ if ($type === DomainModel::ORDER_PRODUCT_POINTS) {
+ return __('积分购买', 'aksrt');
+ }
if ($product_id > 0) {
$cache_key = (string) $product_id;
if (!array_key_exists($cache_key, $this->post_title_cache)) {
@@ -354,7 +357,7 @@ class UserOrders {
if ($type === DomainModel::ORDER_PRODUCT_VIP) {
$url = function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('vip') : '';
- } elseif ($type === DomainModel::ORDER_PRODUCT_RECHARGE) {
+ } elseif (in_array($type, [DomainModel::ORDER_PRODUCT_RECHARGE, DomainModel::ORDER_PRODUCT_POINTS], true)) {
$url = function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('credits') : '';
} else {
$url = $post_id > 0 ? get_permalink($post_id) : '';
@@ -368,6 +371,7 @@ class UserOrders {
return match ($type) {
DomainModel::ORDER_PRODUCT_VIP => __('查看会员权益', 'aksrt'),
DomainModel::ORDER_PRODUCT_RECHARGE => __('查看余额', 'aksrt'),
+ DomainModel::ORDER_PRODUCT_POINTS => __('查看积分', 'aksrt'),
default => __('打开已购内容', 'aksrt'),
};
}
@@ -421,7 +425,7 @@ class UserOrders {
if (!$order) {
return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')];
}
- if ($this->status_group((string) ($order['status'] ?? '')) === OrderCenterQuery::STATUS_PAID) {
+ if ($this->status_group((string) ($order['status'] ?? '')) === OrderCenterQuery::STATUS_COMPLETED) {
$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])) {
diff --git a/tests/widget-system/asset-ledger-contract.test.cjs b/tests/widget-system/asset-ledger-contract.test.cjs
index 4c5e3597..7a8052ff 100644
--- a/tests/widget-system/asset-ledger-contract.test.cjs
+++ b/tests/widget-system/asset-ledger-contract.test.cjs
@@ -168,3 +168,39 @@ test('admin ledger keeps cash minor values as decimal strings', () => {
assert.match(controller, /decimal_from_minor/)
assert.doesNotMatch(page, /\/\s*100(?:\D|$)/)
})
+
+test('asset purchases share the order center and refresh every wallet surface', () => {
+ const domain = fs.readFileSync(path.join(root, 'src', 'Core', 'DomainModel.php'), 'utf8')
+ const query = fs.readFileSync(path.join(root, 'src', 'Payment', 'OrderCenterQuery.php'), 'utf8')
+ const userOrders = fs.readFileSync(path.join(root, 'src', 'Payment', 'UserOrders.php'), 'utf8')
+ const repository = fs.readFileSync(path.join(root, 'src', 'Payment', 'OrderRepositoryCore.php'), 'utf8')
+ const balanceRest = fs.readFileSync(path.join(root, 'src', 'Credits', 'BalanceServiceRest.php'), 'utf8')
+ const chargeCard = fs.readFileSync(path.join(root, 'src', 'Payment', 'ChargeCard.php'), 'utf8')
+ const creditsJs = fs.readFileSync(path.join(root, 'assets', 'js', 'credits.js'), 'utf8')
+ const sidebar = fs.readFileSync(path.join(root, 'inc', 'helpers', 'user-center-renderers', 'sidebar-commerce.php'), 'utf8')
+ 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*__\('积分购买'/)
+ for (const status of ['STATUS_PAID', 'STATUS_DELIVERED', 'STATUS_COMPLETED', 'STATUS_PENDING_REVIEW']) {
+ assert.match(query, new RegExp(`Order::${status}`))
+ }
+ assert.match(userOrders, /Order::STATUS_DELIVERED, Order::STATUS_COMPLETED, Order::STATUS_PENDING_REVIEW/)
+ assert.doesNotMatch(userOrders, /OrderCenterQuery::STATUS_PAID/)
+ assert.match(repository, /\$completed_statuses = \[Order::STATUS_PAID, Order::STATUS_DELIVERED, Order::STATUS_COMPLETED, Order::STATUS_PENDING_REVIEW\]/)
+ assert.match(repository, /SUM\(CASE WHEN status IN \(\{\$completed_placeholders\}\) THEN 1 ELSE 0 END\) AS completed/)
+
+ assert.match(balanceRest, /register_rest_route\('aksrt\/v1', '\/pay\/assets'/)
+ assert.match(balanceRest, /'product_type'\s*=>\s*\\AKSRT\\Core\\DomainModel::ORDER_PRODUCT_POINTS/)
+ assert.match(balanceRest, /\['type'\s*=>\s*'order',\s*'key'\s*=>\s*\$order_number\]/)
+ assert.match(balanceRest, /'order_number'\s*=>\s*\$order_number/)
+ assert.match(chargeCard, /self::TYPE_CREDITS[\s\S]*?ORDER_PRODUCT_POINTS/)
+
+ assert.match(creditsJs, /function refreshAssetBalances\(/)
+ assert.match(creditsJs, /querySelectorAll\('\[data-aksrt-wallet-value\]'/)
+ assert.match(creditsJs, /querySelectorAll\('\[data-aksrt-points-value\]'/)
+ assert.match(sidebar, /data-aksrt-wallet-value/)
+ assert.match(sidebar, /data-aksrt-points-value/)
+ assert.match(header, /data-aksrt-wallet-value/)
+ assert.match(header, /data-aksrt-points-value/)
+})
diff --git a/tests/widget-system/post-visual-contract.test.cjs b/tests/widget-system/post-visual-contract.test.cjs
index c5444594..59887632 100644
--- a/tests/widget-system/post-visual-contract.test.cjs
+++ b/tests/widget-system/post-visual-contract.test.cjs
@@ -366,6 +366,8 @@ test('profile routes load versioned leaf styles and keep dialogs hidden initiall
assert.match(profileOrders, /data-aksrt-orders-filter-panel hidden/);
assert.match(ordersCss, /\.aksrt-orders-page__filter-panel\[hidden\]\s*\{\s*display:\s*none\s*!important;/);
assert.match(orderCenterJs, /if \(panel\) panel\.hidden = expanded;/);
+ assert.match(userCenterJs, /name === 'follow' \|\| name === 'orders'/);
+ assert.match(userCenterJs, /\['order_status', 'order_type', 'order_number', 'order_page'\]/);
});
test('public user home stays param-free and swaps panels via REST fragments', () => {