新增全局流水号
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Finance;
|
||||
|
||||
use AKSRT\Ledger\LedgerCommand;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class FinancialFlowService {
|
||||
|
||||
private const MAX_NUMBER_ATTEMPTS = 5;
|
||||
private static ?self $instance = null;
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an asset-ledger event inside the caller-owned transaction.
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $entries
|
||||
* @return string|\WP_Error
|
||||
*/
|
||||
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'] ?? '');
|
||||
|
||||
$flow_entries = [];
|
||||
foreach ($entries as $entry) {
|
||||
$delta = (int) ($entry['delta_minor'] ?? 0);
|
||||
$flow_entries[] = [
|
||||
'user_id' => max(0, (int) ($entry['user_id'] ?? 0)),
|
||||
'asset' => sanitize_key((string) ($entry['asset'] ?? '')),
|
||||
'direction' => $delta > 0 ? 'in' : 'out',
|
||||
'entry_type' => sanitize_key((string) ($entry['entry_type'] ?? 'adjustment')),
|
||||
'delta_minor' => $delta,
|
||||
'balance_after_minor' => max(0, (int) ($entry['balance_after_minor'] ?? 0)),
|
||||
'counterparty_user_id' => max(0, (int) ($entry['counterparty_user_id'] ?? 0)),
|
||||
'remark' => (string) ($entry['remark'] ?? ''),
|
||||
];
|
||||
}
|
||||
$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,
|
||||
'event_type' => $event_type,
|
||||
'ref_type' => $command->ref_type,
|
||||
'ref_key' => $command->ref_key,
|
||||
'order_number' => $order_number,
|
||||
'withdrawal_number' => $withdrawal_number,
|
||||
'ledger_transaction_id' => $transaction_id,
|
||||
'provider_transaction_id' => $provider_transaction_id,
|
||||
'metadata' => $metadata,
|
||||
'created_at' => $created_at,
|
||||
], $flow_entries, true);
|
||||
}
|
||||
|
||||
/** @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);
|
||||
}
|
||||
|
||||
/** @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));
|
||||
$number = sanitize_text_field((string) ($withdrawal['withdrawal_number'] ?? ''));
|
||||
try {
|
||||
$amount_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($withdrawal['amount'] ?? ''))->minor();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new \WP_Error('aksrt_flow_amount', __('提现流水金额无效', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
return $this->record([
|
||||
'operation_key' => $operation_key,
|
||||
'event_type' => 'withdraw_payout',
|
||||
'ref_type' => 'withdraw',
|
||||
'ref_key' => $number,
|
||||
'withdrawal_number' => $number,
|
||||
'metadata' => ['method' => sanitize_key((string) ($withdrawal['method'] ?? ''))],
|
||||
'created_at' => $created_at,
|
||||
], [[
|
||||
'user_id' => $user_id,
|
||||
'asset' => 'cash',
|
||||
'direction' => 'in',
|
||||
'entry_type' => 'payout',
|
||||
'delta_minor' => $amount_minor,
|
||||
'remark' => __('提现打款完成', 'aksrt'),
|
||||
]], $caller_transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $flow
|
||||
* @param array<int,array<string,mixed>> $entries
|
||||
* @return string|\WP_Error
|
||||
*/
|
||||
private function record(array $flow, array $entries, bool $caller_transaction) {
|
||||
global $wpdb;
|
||||
$operation_key = substr(sanitize_text_field((string) ($flow['operation_key'] ?? '')), 0, 191);
|
||||
$event_type = sanitize_key((string) ($flow['event_type'] ?? ''));
|
||||
if ($operation_key === '' || $event_type === '' || $entries === []) {
|
||||
return new \WP_Error('aksrt_flow_invalid', __('资金流水参数无效', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
$prepared_entries = $this->prepare_entries($entries);
|
||||
if (is_wp_error($prepared_entries)) {
|
||||
return $prepared_entries;
|
||||
}
|
||||
|
||||
$existing = $this->number_for_operation($operation_key);
|
||||
if ($existing !== '') {
|
||||
return $existing;
|
||||
}
|
||||
$owns_transaction = !$caller_transaction;
|
||||
if ($owns_transaction && $wpdb->query('START TRANSACTION') === false) {
|
||||
return new \WP_Error('aksrt_flow_db', __('无法开启资金流水事务', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
$settings = \AKSRT\Settings\Settings::get_order_settings();
|
||||
$prefix = BusinessNumber::normalize_prefix($settings['flow_prefix'] ?? 'AK', 'AK');
|
||||
$flow_id = 0;
|
||||
$flow_number = '';
|
||||
for ($attempt = 1; $attempt <= self::MAX_NUMBER_ATTEMPTS; $attempt++) {
|
||||
$flow_number = BusinessNumber::candidate($prefix);
|
||||
$metadata = is_array($flow['metadata'] ?? null) ? $flow['metadata'] : [];
|
||||
$inserted = $wpdb->insert($this->flow_table(), [
|
||||
'flow_number' => $flow_number,
|
||||
'operation_key' => $operation_key,
|
||||
'event_type' => $event_type,
|
||||
'ref_type' => ($flow['ref_type'] ?? '') !== '' ? sanitize_key((string) $flow['ref_type']) : null,
|
||||
'ref_key' => ($flow['ref_key'] ?? '') !== '' ? substr(sanitize_text_field((string) $flow['ref_key']), 0, 191) : null,
|
||||
'order_number' => ($flow['order_number'] ?? '') !== '' ? substr(sanitize_text_field((string) $flow['order_number']), 0, 32) : null,
|
||||
'withdrawal_number' => ($flow['withdrawal_number'] ?? '') !== '' ? substr(sanitize_text_field((string) $flow['withdrawal_number']), 0, 32) : null,
|
||||
'ledger_transaction_id' => max(0, (int) ($flow['ledger_transaction_id'] ?? 0)) ?: null,
|
||||
'provider_transaction_id' => ($flow['provider_transaction_id'] ?? '') !== '' ? substr(sanitize_text_field((string) $flow['provider_transaction_id']), 0, 128) : null,
|
||||
'metadata' => $metadata !== [] ? wp_json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
|
||||
'created_at' => (string) ($flow['created_at'] ?? self::utc_now()),
|
||||
], ['%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s']);
|
||||
if ($inserted === 1) {
|
||||
$flow_id = (int) $wpdb->insert_id;
|
||||
break;
|
||||
}
|
||||
$existing = $this->number_for_operation($operation_key);
|
||||
if ($existing !== '') {
|
||||
if ($owns_transaction) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
}
|
||||
return $existing;
|
||||
}
|
||||
}
|
||||
if ($flow_id <= 0) {
|
||||
if ($owns_transaction) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
}
|
||||
return new \WP_Error('aksrt_flow_number', __('无法生成唯一流水号', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
foreach ($prepared_entries as $index => $entry) {
|
||||
$inserted = $wpdb->insert($this->entry_table(), [
|
||||
'flow_id' => $flow_id,
|
||||
'line_no' => $index + 1,
|
||||
'user_id' => max(0, (int) ($entry['user_id'] ?? 0)),
|
||||
'asset' => $entry['asset'],
|
||||
'direction' => $entry['direction'],
|
||||
'entry_type' => substr(sanitize_key((string) ($entry['entry_type'] ?? 'adjustment')), 0, 32),
|
||||
'delta_minor' => $entry['delta_minor'],
|
||||
'balance_after_minor' => $entry['balance_after_minor'],
|
||||
'counterparty_user_id' => max(0, (int) ($entry['counterparty_user_id'] ?? 0)) ?: null,
|
||||
'remark' => ($entry['remark'] ?? '') !== '' ? substr(sanitize_text_field((string) $entry['remark']), 0, 255) : null,
|
||||
], ['%d', '%d', '%d', '%s', '%s', '%s', '%d', '%d', '%d', '%s']);
|
||||
if ($inserted !== 1) {
|
||||
if ($owns_transaction) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
}
|
||||
return new \WP_Error('aksrt_flow_entry', __('资金流水分录写入失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
}
|
||||
if ($owns_transaction && $wpdb->query('COMMIT') === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return new \WP_Error('aksrt_flow_db', __('资金流水提交失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
return $flow_number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate every entry before the flow head is inserted.
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $entries
|
||||
* @return array<int,array<string,mixed>>|\WP_Error
|
||||
*/
|
||||
private function prepare_entries(array $entries) {
|
||||
$prepared = [];
|
||||
foreach (array_values($entries) as $entry) {
|
||||
$delta = (int) ($entry['delta_minor'] ?? 0);
|
||||
$asset = sanitize_key((string) ($entry['asset'] ?? ''));
|
||||
$direction = sanitize_key((string) ($entry['direction'] ?? ($delta > 0 ? 'in' : 'out')));
|
||||
if (!in_array($asset, ['cash', 'balance', 'points'], true) || $delta === 0) {
|
||||
return new \WP_Error('aksrt_flow_entry', __('资金流水分录无效', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
if (!in_array($direction, ['in', 'out'], true) || ($delta > 0) !== ($direction === 'in')) {
|
||||
return new \WP_Error('aksrt_flow_entry', __('资金流水方向无效', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$prepared[] = [
|
||||
'user_id' => max(0, (int) ($entry['user_id'] ?? 0)),
|
||||
'asset' => $asset,
|
||||
'direction' => $direction,
|
||||
'entry_type' => substr(sanitize_key((string) ($entry['entry_type'] ?? 'adjustment')), 0, 32),
|
||||
'delta_minor' => $delta,
|
||||
'balance_after_minor' => array_key_exists('balance_after_minor', $entry) ? max(0, (int) $entry['balance_after_minor']) : null,
|
||||
'counterparty_user_id' => max(0, (int) ($entry['counterparty_user_id'] ?? 0)) ?: null,
|
||||
'remark' => ($entry['remark'] ?? '') !== '' ? substr(sanitize_text_field((string) $entry['remark']), 0, 255) : null,
|
||||
];
|
||||
}
|
||||
return $prepared;
|
||||
}
|
||||
|
||||
public function number_for_operation(string $operation_key): string {
|
||||
global $wpdb;
|
||||
return (string) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT flow_number FROM {$this->flow_table()} WHERE operation_key = %s LIMIT 1",
|
||||
$operation_key
|
||||
));
|
||||
}
|
||||
|
||||
public function number_for_ledger_transaction(int $transaction_id): string {
|
||||
global $wpdb;
|
||||
if ($transaction_id <= 0) {
|
||||
return '';
|
||||
}
|
||||
return (string) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT flow_number FROM {$this->flow_table()} WHERE ledger_transaction_id = %d LIMIT 1",
|
||||
$transaction_id
|
||||
));
|
||||
}
|
||||
|
||||
private function record_order_event(array $order, string $event_type, int $sign, bool $caller_transaction) {
|
||||
$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'));
|
||||
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]);
|
||||
}
|
||||
$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' => [
|
||||
'payment_method' => $payment_method,
|
||||
'currency' => $currency,
|
||||
],
|
||||
'created_at' => self::utc_now(),
|
||||
], [[
|
||||
'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'),
|
||||
]], $caller_transaction);
|
||||
}
|
||||
|
||||
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');
|
||||
$withdrawal_id = max(0, (int) ($command->metadata['withdrawal_id'] ?? 0));
|
||||
if ($withdrawal_id <= 0 && ctype_digit($command->ref_key)) {
|
||||
$withdrawal_id = (int) $command->ref_key;
|
||||
}
|
||||
if ($withdrawal_id > 0) {
|
||||
return (string) $wpdb->get_var($wpdb->prepare("SELECT withdrawal_number FROM {$table} WHERE id = %d LIMIT 1", $withdrawal_id));
|
||||
}
|
||||
return (string) $wpdb->get_var($wpdb->prepare("SELECT withdrawal_number FROM {$table} WHERE ledger_transaction_id = %d LIMIT 1", $transaction_id));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function resolve_order(string $order_number): array {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('order');
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT order_number, user_id, product_type, amount, currency, status, payment_method, payment_transaction_id FROM {$table} WHERE order_number = %s LIMIT 1",
|
||||
$order_number
|
||||
), ARRAY_A);
|
||||
return is_array($row) ? $row : [];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $order @param array<int,array<string,mixed>> $entries */
|
||||
private function is_successful_order_payment(array $order, array $entries): bool {
|
||||
if (sanitize_key((string) ($order['status'] ?? '')) !== \AKSRT\Payment\Order::STATUS_PAID) {
|
||||
return false;
|
||||
}
|
||||
$buyer_id = max(0, (int) ($order['user_id'] ?? 0));
|
||||
foreach ($entries as $entry) {
|
||||
if ((int) ($entry['user_id'] ?? 0) === $buyer_id && (int) ($entry['delta_minor'] ?? 0) < 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return $this->is_external_recharge($order, $entries);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $order @param array<int,array<string,mixed>> $entries */
|
||||
private function is_external_recharge(array $order, array $entries): bool {
|
||||
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||
if (sanitize_key((string) ($order['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|
||||
|| in_array($method, ['', 'balance', 'points'], 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');
|
||||
}
|
||||
|
||||
private function entry_table(): string {
|
||||
return \AKSRT\Core\DBMigrator::table('financial_flow_entry');
|
||||
}
|
||||
|
||||
private static function utc_now(): string {
|
||||
return (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s.u');
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user