积分与余额账务系统硬切重构

This commit is contained in:
2026-08-24 15:59:11 +08:00
parent ca12226b39
commit 5cae5b5c93
159 changed files with 7275 additions and 4718 deletions
+199 -62
View File
@@ -41,13 +41,19 @@ trait OrderRepositoryCore {
$note = sanitize_textarea_field($note_raw);
}
$row = [
$currency = sanitize_key((string) ($data['currency'] ?? 'CNY'));
$amount = $this->normalize_order_amount($data['amount'] ?? null, $currency);
if ($amount === null) {
return '';
}
$row = [
'order_number' => $order_number,
'user_id' => (int) ($data['user_id'] ?? 0),
'product_type' => sanitize_key((string) ($data['product_type'] ?? '')),
'product_id' => (int) ($data['product_id'] ?? 0),
'amount' => (float) ($data['amount'] ?? 0),
'currency' => sanitize_key((string) ($data['currency'] ?? 'CNY')),
'amount' => $amount,
'currency' => $currency,
'status' => \AKSRT\Core\DomainModel::normalize_order_status((string) ($data['status'] ?? \AKSRT\Core\DomainModel::ORDER_STATUS_PENDING)),
'payment_method' => sanitize_key((string) ($data['payment_method'] ?? '')),
'payment_transaction_id' => sanitize_text_field((string) ($data['payment_transaction_id'] ?? '')),
@@ -55,7 +61,7 @@ trait OrderRepositoryCore {
'paid_at' => $data['paid_at'] ?? null,
'note' => $note,
];
$formats = ['%s', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s'];
$formats = ['%s', '%d', '%s', '%d', '%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.
@@ -67,6 +73,11 @@ trait OrderRepositoryCore {
$formats[] = '%s';
}
if (!empty($data['request_key'])) {
$row['request_key'] = substr(sanitize_text_field((string) $data['request_key']), 0, 191);
$formats[] = '%s';
}
$wpdb->insert($this->table(), $row, $formats);
if ($wpdb->insert_id) {
@@ -96,6 +107,19 @@ trait OrderRepositoryCore {
return null;
}
public function get_by_request_key(string $request_key): ?array {
global $wpdb;
$request_key = substr(sanitize_text_field($request_key), 0, 191);
if ($request_key === '') {
return null;
}
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE request_key = %s",
$request_key
), ARRAY_A);
return is_array($row) ? $this->normalize_row($row) : null;
}
/**
@@ -264,12 +288,92 @@ trait OrderRepositoryCore {
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
global $wpdb;
$preflight = $this->get($order_number);
if (!$preflight) {
return false;
}
if (
$new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID
&& sanitize_key((string) ($preflight['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
&& class_exists('\AKSRT\Credits\BalanceService')
) {
return \AKSRT\Credits\BalanceService::get_instance()->complete_recharge_payment(
$preflight,
sanitize_key((string) ($extra['payment_method'] ?? $preflight['payment_method'] ?? '')),
sanitize_text_field((string) ($extra['payment_transaction_id'] ?? $preflight['payment_transaction_id'] ?? '')),
(string) ($extra['note'] ?? '')
);
}
$ledger_active = class_exists('\AKSRT\Ledger\LedgerService')
&& \AKSRT\Ledger\LedgerService::get_instance()->transaction_active();
$root_call = $this->status_transaction_depth === 0;
if ($root_call) {
$this->status_after_commit_events = [];
}
$owns_transaction = $root_call && !$ledger_active;
if ($owns_transaction && $wpdb->query('START TRANSACTION') === false) {
return false;
}
$this->status_transaction_depth++;
try {
$result = $this->update_status_in_transaction($order_number, $new_status, $extra);
if (!$result) {
if ($owns_transaction) {
$wpdb->query('ROLLBACK');
}
if ($root_call) {
$this->status_after_commit_events = [];
}
return false;
}
if ($owns_transaction && $wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
$this->status_after_commit_events = [];
return false;
}
if ($root_call) {
$events = $this->status_after_commit_events;
$this->status_after_commit_events = [];
if ($owns_transaction) {
$this->dispatch_status_events($events);
} elseif ($ledger_active) {
\AKSRT\Ledger\LedgerService::get_instance()->after_commit(function () use ($events): void {
$this->dispatch_status_events($events);
});
}
}
return true;
} catch (\Throwable $e) {
if ($owns_transaction) {
$wpdb->query('ROLLBACK');
}
if ($root_call) {
$this->status_after_commit_events = [];
}
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[AKSRT Order] transactional status update failed: ' . $e->getMessage());
}
return false;
} finally {
$this->status_transaction_depth--;
}
}
private function update_status_in_transaction(string $order_number, string $new_status, array $extra = []): bool {
global $wpdb;
$valid = \AKSRT\Core\DomainModel::order_status_labels();
if (!isset($valid[$new_status])) return false;
$order = $this->get($order_number);
$order_row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE order_number = %s FOR UPDATE",
$order_number
), ARRAY_A);
$order = is_array($order_row) ? $this->normalize_row($order_row) : null;
if (!$order) return false;
$current_status = $order['status'] ?? 'created';
@@ -370,8 +474,11 @@ trait OrderRepositoryCore {
return is_array($latest) && ($latest['status'] ?? '') === $new_status;
}
if ($result > 0) {
do_action('aksrt_order_status_changed', $order_number, $new_status, array_merge($order, $update_data));
if ($result > 0) {
$this->status_after_commit_events[] = [
'hook' => 'aksrt_order_status_changed',
'args' => [$order_number, $new_status, array_merge($order, $update_data)],
];
// 到账事件:让充值/VIP/商品履约等挂接(单一履约入口)。
// 仅在「首次」转为 paid 时触发,避免回调多次到达重复履约。
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID && $current_status !== \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) {
@@ -398,19 +505,60 @@ trait OrderRepositoryCore {
}
}
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;
}
}
$product_type = sanitize_key((string) ($paid_order['product_type'] ?? ''));
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE && 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;
}
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP && class_exists('\AKSRT\Content\VipService')) {
$fulfilled = \AKSRT\Content\VipService::get_instance()->fulfill_paid_order($order_number, $paid_order);
if (is_wp_error($fulfilled)) {
return false;
}
}
/**
* 订单到账。
*
* @param string $order_number 订单号
* @param array $paid_order 订单数据(status=paid)
*/
do_action('aksrt_order_paid', $order_number, $paid_order);
$this->status_after_commit_events[] = [
'hook' => 'aksrt_order_paid',
'args' => [$order_number, $paid_order],
];
}
}
return $result !== false;
}
/** @param array<int,array{hook:string,args:array<int,mixed>}> $events */
private function dispatch_status_events(array $events): void {
foreach ($events as $event) {
try {
do_action((string) $event['hook'], ...$event['args']);
} catch (\Throwable $e) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[AKSRT Order Event] ' . $e->getMessage());
}
}
}
}
/**
@@ -494,7 +642,7 @@ trait OrderRepositoryCore {
'refunding' => (int) ($row['refunding'] ?? 0),
'cancelled' => (int) ($row['cancelled'] ?? 0),
'refunded' => (int) ($row['refunded'] ?? 0),
'revenue' => (float) ($row['revenue'] ?? 0),
'revenue' => $this->cash_decimal((string) ($row['revenue'] ?? '0')),
];
}
@@ -516,57 +664,6 @@ trait OrderRepositoryCore {
/* ------------------------------------------------------------------
* Migration bridge: option → table
* ----------------------------------------------------------------*/
/**
* 一次性迁移:把 option 中的订单搬到表中。
* 幂等 — 已存在的 order_number 不会重复插入。
*/
public function migrate_from_option(): int {
global $wpdb;
$orders = get_option('aksrt_orders', []);
if (!is_array($orders) || empty($orders)) return 0;
$migrated = 0;
foreach ($orders as $order_number => $data) {
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE order_number = %s",
$order_number
));
if ($existing) continue;
$wpdb->insert($this->table(), [
'order_number' => $order_number,
'user_id' => (int) ($data['user_id'] ?? 0),
'product_type' => sanitize_key((string) ($data['product_type'] ?? '')),
'product_id' => (int) ($data['product_id'] ?? 0),
'amount' => (float) ($data['amount'] ?? 0),
'currency' => sanitize_key((string) ($data['currency'] ?? 'CNY')),
'status' => \AKSRT\Core\DomainModel::normalize_order_status((string) ($data['status'] ?? \AKSRT\Core\DomainModel::ORDER_STATUS_PENDING)),
'payment_method' => sanitize_key((string) ($data['payment_method'] ?? '')),
'created_at' => $data['created_at'] ?? current_time('mysql'),
'paid_at' => $data['paid_at'] ?? null,
'delivered_at' => $data['delivered_at'] ?? null,
'completed_at' => $data['completed_at'] ?? null,
'cancelled_at' => $data['cancelled_at'] ?? null,
'refunded_at' => $data['refunded_at'] ?? null,
'expires_at' => $data['expires_at'] ?? null,
'note' => '',
], ['%s', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
if ($wpdb->insert_id) $migrated++;
}
update_option(\AKSRT\Core\DomainModel::ORDER_MIGRATED_OPTION, time());
return $migrated;
}
/* ------------------------------------------------------------------
* Internal helpers
* ----------------------------------------------------------------*/
@@ -620,7 +717,10 @@ trait OrderRepositoryCore {
$row['id'] = (int) $row['id'];
$row['user_id'] = (int) $row['user_id'];
$row['product_id'] = (int) $row['product_id'];
$row['amount'] = (float) $row['amount'];
$row['amount'] = $this->normalize_order_amount((string) ($row['amount'] ?? ''), (string) ($row['currency'] ?? ''), true);
if ($row['amount'] === null) {
$row['amount'] = (string) ($row['currency'] ?? '') === 'points' ? 0 : '0.00';
}
$row['status'] = \AKSRT\Core\DomainModel::normalize_order_status((string) ($row['status'] ?? ''));
$row['meta_payload'] = [];
@@ -639,6 +739,39 @@ trait OrderRepositoryCore {
return $row;
}
private function normalize_order_amount(mixed $value, string $currency, bool $from_database = false): string|int|null {
if ($currency === 'points') {
$raw = is_int($value) ? (string) $value : (is_string($value) ? trim($value) : '');
if ($from_database && preg_match('/^(0|[1-9]\d*)\.0+$/', $raw, $matches)) {
$raw = $matches[1];
}
if (!preg_match('/^(0|[1-9]\d*)$/', $raw)) {
return null;
}
$max = (string) PHP_INT_MAX;
if (strlen($raw) > strlen($max) || (strlen($raw) === strlen($max) && strcmp($raw, $max) > 0)) {
return null;
}
return (int) $raw;
}
if (!is_string($value)) {
return null;
}
try {
return \AKSRT\Ledger\AssetAmount::balance(trim($value))->decimal();
} catch (\InvalidArgumentException $e) {
return null;
}
}
private function cash_decimal(string $value): string {
try {
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
} catch (\InvalidArgumentException $e) {
return '0.00';
}
}
private function find_latest_success_order(int $user_id, string $product_type, int $product_id = 0, bool $ignore_product_id = false): ?array {
@@ -676,7 +809,11 @@ trait OrderRepositoryCore {
* Column + major gateways (WeChat out_trade_no) cap at 32 chars, so the
* prefix is trimmed to leave room for {YmdHis}{4 digits} (=18).
*/
private function generate_order_number(): string {
public function allocate_order_number(): string {
return $this->generate_order_number();
}
private function generate_order_number(): string {
$prefix = 'AKSRT';
if (class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_order_settings')) {
$settings = \AKSRT\Settings\Settings::get_order_settings();