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

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
+215 -118
View File
@@ -43,14 +43,6 @@ class Revenue {
return 0;
}
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE order_number = %s ORDER BY id ASC LIMIT 1",
$order_number
));
if ((int) $existing > 0) {
return (int) $existing;
}
$meta = $this->decode_order_meta($order);
if (empty($meta['content_pay'])) {
return 0;
@@ -71,11 +63,8 @@ class Revenue {
$currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
$is_points = $currency === 'points' || ($meta['pay_modo'] ?? '') === 'points';
$currency = $is_points ? 'points' : 'CNY';
$original_amount = round((float) ($order['amount'] ?? $meta['amount'] ?? $meta['payable'] ?? 0), 2);
if ($is_points) {
$original_amount = (float) ceil($original_amount);
}
if ($original_amount <= 0) {
$original_minor = $this->order_amount_minor($order['amount'] ?? $meta['amount'] ?? $meta['payable'] ?? null, $is_points);
if ($original_minor === null || $original_minor <= 0) {
return 0;
}
@@ -86,12 +75,32 @@ class Revenue {
if ($ratio <= 0) {
return 0;
}
$amount = $is_points
? (float) max(1, (int) floor($original_amount * $ratio / 100))
: round($original_amount * $ratio / 100, 2);
if ($amount <= 0) {
$amount_minor = $this->percentage_minor($original_minor, $ratio, !$is_points);
if ($is_points) {
$amount_minor = max(1, $amount_minor);
}
if ($amount_minor <= 0) {
return 0;
}
$original_amount = $this->stored_amount($original_minor, $is_points);
$amount = $this->stored_amount($amount_minor, $is_points);
$expected = [
'author_id' => $author_id,
'order_number' => $order_number,
'post_id' => $post_id,
'buyer_id' => $buyer_id,
'currency' => $currency,
'original_minor' => $original_minor,
'ratio' => $ratio,
'amount_minor' => $amount_minor,
];
$existing = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id ASC LIMIT 1",
$order_number
), ARRAY_A);
if (is_array($existing)) {
return $this->revenue_matches($existing, $expected) ? (int) $existing['id'] : -1;
}
$inserted = $wpdb->insert($this->table(), [
'author_id' => $author_id,
@@ -106,26 +115,19 @@ class Revenue {
'type' => 'order',
'status' => 'pending',
'created_at' => current_time('mysql'),
], ['%d', '%s', '%d', '%d', '%s', '%f', '%f', '%d', '%f', '%s', '%s', '%s']);
], ['%d', '%s', '%d', '%d', '%s', $is_points ? '%d' : '%s', '%d', '%d', $is_points ? '%d' : '%s', '%s', '%s', '%s']);
if ($inserted === false) {
// Duplicate callbacks are a successful no-op by contract.
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE order_number = %s ORDER BY id ASC LIMIT 1",
$existing = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id ASC LIMIT 1",
$order_number
));
return (int) $existing;
), ARRAY_A);
return is_array($existing) && $this->revenue_matches($existing, $expected)
? (int) $existing['id']
: -1;
}
$id = (int) $wpdb->insert_id;
if ($id > 0) {
do_action('aksrt_creator_income_added', $author_id, $amount, [
'order_number' => $order_number,
'currency' => $currency,
'revenue_id' => $id,
]);
}
return $id;
return (int) $wpdb->insert_id;
}
/**
@@ -134,11 +136,22 @@ class Revenue {
* @param int|array<int,int> $record_ids
* @param int $actor_id
*/
public function settle($record_ids, int $actor_id = 0): bool {
$result = $this->settle_batch($record_ids, $actor_id);
public function settle($record_ids, int $actor_id = 0, string $operation_key = ''): bool {
if (!is_array($record_ids)) {
return $this->settle_result((int) $record_ids, $actor_id, $operation_key) instanceof \AKSRT\Ledger\LedgerResult;
}
$result = $this->settle_batch($record_ids, $actor_id, $operation_key);
return !empty($result['settled']) && empty($result['failed']);
}
/** @return \AKSRT\Ledger\LedgerResult|\WP_Error */
public function settle_result(int $revenue_id, int $actor_id = 0, string $operation_key = '') {
if ($revenue_id <= 0 || !$this->is_enabled()) {
return new \WP_Error('aksrt_revenue_invalid', __('收益记录无效或结算功能未启用', 'aksrt'), ['status' => 400]);
}
return $this->settle_one($revenue_id, $actor_id, $operation_key);
}
/**
* Settle a batch while preserving per-record outcomes for operational feedback.
*
@@ -146,7 +159,7 @@ class Revenue {
* @param int $actor_id
* @return array{settled:array<int,int>,failed:array<int,int>}
*/
public function settle_batch($record_ids, int $actor_id = 0): array {
public function settle_batch($record_ids, int $actor_id = 0, string $operation_key = ''): array {
$ids = is_array($record_ids) ? $record_ids : [$record_ids];
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0)));
if (!$ids || !$this->is_enabled()) {
@@ -155,8 +168,11 @@ class Revenue {
$settled = [];
$failed = [];
foreach ($ids as $revenue_id) {
if ($this->settle_one($revenue_id, $actor_id)) {
foreach ($ids as $revenue_id) {
$item_key = $operation_key !== '' && count($ids) === 1
? $operation_key
: ($operation_key !== '' ? \AKSRT\Ledger\OperationKey::deterministic('revenue-settle-item', [$operation_key, $revenue_id]) : '');
if ($this->settle_one($revenue_id, $actor_id, $item_key) instanceof \AKSRT\Ledger\LedgerResult) {
$settled[] = $revenue_id;
} else {
$failed[] = $revenue_id;
@@ -200,70 +216,69 @@ class Revenue {
return $settled;
}
private function settle_one(int $revenue_id, int $actor_id): bool {
/** @return \AKSRT\Ledger\LedgerResult|\WP_Error */
private function settle_one(int $revenue_id, int $actor_id, string $operation_key = '') {
global $wpdb;
$wpdb->query('START TRANSACTION');
try {
$revenue = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE id = %d AND status = %s FOR UPDATE",
$revenue_id,
'pending'
), ARRAY_A);
if (!is_array($revenue)) {
$wpdb->query('COMMIT');
return false;
}
$ref = ['type' => 'revenue', 'id' => $revenue_id, 'currency' => (string) ($revenue['currency'] ?? 'CNY')];
$remark = sprintf(__('创作收益结算(订单 %s)', 'aksrt'), (string) ($revenue['order_number'] ?? ''));
if (!$this->has_balance_entry((int) $revenue['author_id'], $ref)) {
$credited = (string) ($revenue['currency'] ?? '') === 'points'
? (class_exists('\\AKSRT\\Credits\\Credits') ? \AKSRT\Credits\Credits::add((int) $revenue['author_id'], (int) ceil((float) $revenue['amount']), 'creator_income', $ref, $remark) : new \WP_Error('credits_unavailable'))
: (class_exists('\\AKSRT\\Credits\\Balance') ? \AKSRT\Credits\Balance::add((int) $revenue['author_id'], (float) $revenue['amount'], 'creator_income', $ref, $remark) : new \WP_Error('balance_unavailable'));
if (is_wp_error($credited)) {
$wpdb->query('ROLLBACK');
return false;
}
}
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()} SET status = %s, settled_at = %s, audit_json = %s WHERE id = %d AND status = %s",
'settled',
current_time('mysql'),
$this->audit_json('settled', '', $actor_id),
$revenue_id,
'pending'
));
if ($updated !== 1) {
throw new \RuntimeException('revenue_settle_failed');
}
$wpdb->query('COMMIT');
return true;
} catch (\Throwable $e) {
$wpdb->query('ROLLBACK');
return false;
}
}
private function has_balance_entry(int $user_id, array $ref): bool {
global $wpdb;
if ($user_id <= 0 || (int) ($ref['id'] ?? 0) <= 0) {
return false;
}
$table = (string) ($ref['currency'] ?? '') === 'points'
? \AKSRT\Core\DBMigrator::table('credit_log')
: \AKSRT\Core\DBMigrator::table('balance_log');
if ($table === '') {
return false;
}
return (bool) $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$table} WHERE user_id = %d AND ref_type = %s AND ref_id = %d AND reason = %s LIMIT 1",
$user_id,
'revenue',
(int) $ref['id'],
'creator_income'
));
}
$revenue = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d", $revenue_id), ARRAY_A);
if (!is_array($revenue)) {
return new \WP_Error('aksrt_revenue_missing', __('收益记录不存在', 'aksrt'), ['status' => 404]);
}
$asset = (string) ($revenue['currency'] ?? '') === 'points'
? \AKSRT\Ledger\AssetType::POINTS
: \AKSRT\Ledger\AssetType::BALANCE;
try {
$amount_minor = $this->database_amount_minor((string) $revenue['amount'], $asset === \AKSRT\Ledger\AssetType::POINTS);
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_revenue_amount', __('收益金额无效', 'aksrt'), ['status' => 500]);
}
if ($amount_minor === null || $amount_minor <= 0 || (int) ($revenue['author_id'] ?? 0) <= 0) {
return new \WP_Error('aksrt_revenue_amount', __('收益金额或收款用户无效', 'aksrt'), ['status' => 500]);
}
$expected = [
'author_id' => (int) $revenue['author_id'],
'currency' => (string) $revenue['currency'],
'amount' => (string) $revenue['amount'],
];
$guard = function () use ($revenue_id, $expected) {
global $wpdb;
$current = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d FOR UPDATE", $revenue_id), ARRAY_A);
if (!is_array($current)
|| (string) ($current['status'] ?? '') !== 'pending'
|| (int) ($current['author_id'] ?? 0) !== $expected['author_id']
|| (string) ($current['currency'] ?? '') !== $expected['currency']
|| (string) ($current['amount'] ?? '') !== $expected['amount']
) {
return new \WP_Error('aksrt_revenue_state', __('收益记录状态已变化', 'aksrt'), ['status' => 409]);
}
return true;
};
$mutation = function () use ($revenue_id, $actor_id) {
global $wpdb;
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()} SET status = %s, settled_at = %s, audit_json = %s WHERE id = %d AND status = %s",
'settled',
current_time('mysql'),
$this->audit_json('settled', '', $actor_id),
$revenue_id,
'pending'
));
return $updated === 1 ? true : new \WP_Error('aksrt_revenue_state', __('收益记录状态已变化', 'aksrt'), ['status' => 409]);
};
$result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
$expected['author_id'],
$asset,
$amount_minor,
$operation_key !== '' ? $operation_key : \AKSRT\Ledger\OperationKey::deterministic('revenue-settle', [$revenue_id]),
'creator_income',
['type' => 'revenue', 'id' => $revenue_id],
sprintf(__('创作收益结算(订单 %s)', 'aksrt'), (string) ($revenue['order_number'] ?? '')),
$actor_id,
['currency' => $expected['currency']],
$mutation,
$guard
);
return $result;
}
private function is_enabled(): bool {
return class_exists('\\AKSRT\\Settings\\Settings')
@@ -302,10 +317,11 @@ class Revenue {
$offset = max(0, (int) ($args['offset'] ?? 0));
$params[] = $limit;
$params[] = $offset;
return (array) $wpdb->get_results($wpdb->prepare(
$rows = (array) $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . " ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d",
$params
), ARRAY_A);
return array_map([$this, 'normalize_revenue_row'], $rows);
}
/**
@@ -316,10 +332,10 @@ class Revenue {
public function get_user_income_view(int $user_id, array $args = []): array {
global $wpdb;
$empty_stats = [
'today' => ['CNY' => 0.0, 'points' => 0.0],
'total' => ['CNY' => 0.0, 'points' => 0.0],
'pending' => ['CNY' => 0.0, 'points' => 0.0],
'settled' => ['CNY' => 0.0, 'points' => 0.0],
'today' => ['CNY' => '0.00', 'points' => 0],
'total' => ['CNY' => '0.00', 'points' => 0],
'pending' => ['CNY' => '0.00', 'points' => 0],
'settled' => ['CNY' => '0.00', 'points' => 0],
];
if ($user_id <= 0) {
return ['stats' => $empty_stats, 'items' => [], 'total' => 0, 'max_pages' => 1, 'page' => 1, 'per_page' => 20];
@@ -350,10 +366,11 @@ class Revenue {
if (!isset($empty_stats['total'][$key])) {
continue;
}
$empty_stats['today'][$key] = (float) ($row['today_amount'] ?? 0);
$empty_stats['total'][$key] = (float) ($row['total_amount'] ?? 0);
$empty_stats['pending'][$key] = (float) ($row['pending_amount'] ?? 0);
$empty_stats['settled'][$key] = (float) ($row['settled_amount'] ?? 0);
$is_points = $key === 'points';
$empty_stats['today'][$key] = $this->display_amount((string) ($row['today_amount'] ?? '0'), $is_points);
$empty_stats['total'][$key] = $this->display_amount((string) ($row['total_amount'] ?? '0'), $is_points);
$empty_stats['pending'][$key] = $this->display_amount((string) ($row['pending_amount'] ?? '0'), $is_points);
$empty_stats['settled'][$key] = $this->display_amount((string) ($row['settled_amount'] ?? '0'), $is_points);
}
$status = sanitize_key((string) ($args['status'] ?? ''));
@@ -430,20 +447,20 @@ class Revenue {
$count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) : $wpdb->get_var($count_sql));
$items_params = array_merge($params, [$limit, $offset]);
$items = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d", $items_params), ARRAY_A);
$items = array_map([$this, 'normalize_revenue_row'], (array) $wpdb->get_results($wpdb->prepare("SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d", $items_params), ARRAY_A));
return ['items' => $items ?: [], 'total' => $total, 'page' => (int) floor($offset / $limit) + 1, 'max_pages' => (int) ceil(max(1, $total) / $limit)];
}
/**
* Return separate cash and points totals; amounts must never be added across units.
*
* @return array{cash:array{total:float,pending:float,settled:float,count:int},points:array{total:float,pending:float,settled:float,count:int}}
* @return array{cash:array{total:string,pending:string,settled:string,count:int},points:array{total:int,pending:int,settled:int,count:int}}
*/
public function get_stats(int $user_id = 0): array {
global $wpdb;
$empty = [
'cash' => ['total' => 0.0, 'pending' => 0.0, 'settled' => 0.0, 'count' => 0],
'points' => ['total' => 0.0, 'pending' => 0.0, 'settled' => 0.0, 'count' => 0],
'cash' => ['total' => '0.00', 'pending' => '0.00', 'settled' => '0.00', 'count' => 0],
'points' => ['total' => 0, 'pending' => 0, 'settled' => 0, 'count' => 0],
];
$where = ["status IN ('pending', 'settled')"];
$params = [];
@@ -460,24 +477,104 @@ class Revenue {
$rows = $wpdb->get_results($params ? $wpdb->prepare($stats_sql, $params) : $stats_sql, ARRAY_A);
foreach ((array) $rows as $row) {
$key = (string) ($row['currency'] ?? '') === 'points' ? 'points' : 'cash';
$is_points = $key === 'points';
$empty[$key] = [
'total' => (float) ($row['total'] ?? 0),
'pending' => (float) ($row['pending'] ?? 0),
'settled' => (float) ($row['settled'] ?? 0),
'total' => $this->display_amount((string) ($row['total'] ?? '0'), $is_points),
'pending' => $this->display_amount((string) ($row['pending'] ?? '0'), $is_points),
'settled' => $this->display_amount((string) ($row['settled'] ?? '0'), $is_points),
'count' => (int) ($row['count'] ?? 0),
];
}
return $empty;
}
private function resolve_share_ratio(string $currency, int $author_level): float {
private function resolve_share_ratio(string $currency, int $author_level): int {
$settings = \AKSRT\Settings\Settings::get_payment_revenue_settings();
$bucket = $currency === 'points' ? 'points' : 'cash';
$level_key = $author_level >= 2 ? 'vip2' : ($author_level === 1 ? 'vip1' : 'ordinary');
$ratio = $settings['ratios'][$bucket][$level_key] ?? 0;
return max(0, min(100, (float) $ratio));
return max(0, min(100, (int) $ratio));
}
private function order_amount_minor(mixed $value, bool $is_points): ?int {
if ($is_points) {
$raw = is_int($value) ? (string) $value : (is_string($value) ? trim($value) : '');
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))->minor();
} catch (\InvalidArgumentException $e) {
return null;
}
}
private function percentage_minor(int $base_minor, int $ratio, bool $round_half_up): int {
$whole = intdiv($base_minor, 100) * $ratio;
$remainder = ($base_minor % 100) * $ratio;
return $whole + intdiv($remainder + ($round_half_up ? 50 : 0), 100);
}
private function stored_amount(int $minor, bool $is_points): string|int {
return $is_points
? $minor
: \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $minor)->decimal();
}
private function database_amount_minor(string $value, bool $is_points): ?int {
if ($is_points) {
if (!preg_match('/^(0|[1-9]\d*)(?:\.0+)?$/', $value, $matches)) {
return null;
}
return $this->order_amount_minor($matches[1], true);
}
try {
return \AKSRT\Ledger\AssetAmount::balance($value)->minor();
} catch (\InvalidArgumentException $e) {
return null;
}
}
/** @param array<string,mixed> $row @param array<string,mixed> $expected */
private function revenue_matches(array $row, array $expected): bool {
$is_points = $expected['currency'] === 'points';
return (int) ($row['author_id'] ?? 0) === $expected['author_id']
&& (string) ($row['order_number'] ?? '') === $expected['order_number']
&& (int) ($row['post_id'] ?? 0) === $expected['post_id']
&& (int) ($row['buyer_id'] ?? 0) === $expected['buyer_id']
&& (string) ($row['currency'] ?? '') === $expected['currency']
&& $this->database_amount_minor((string) ($row['original_amount'] ?? ''), $is_points) === $expected['original_minor']
&& (int) ($row['share_ratio'] ?? -1) === $expected['ratio']
&& $this->database_amount_minor((string) ($row['amount'] ?? ''), $is_points) === $expected['amount_minor'];
}
private function display_amount(string $value, bool $is_points): string|int {
$minor = $this->database_amount_minor($value, $is_points);
if ($minor === null) {
return $is_points ? 0 : '0.00';
}
return $this->stored_amount($minor, $is_points);
}
/** @param array<string,mixed> $row @return array<string,mixed> */
private function normalize_revenue_row(array $row): array {
$is_points = (string) ($row['currency'] ?? '') === 'points';
foreach (['original_amount', 'amount', 'reversal_amount'] as $field) {
$row[$field] = $this->display_amount((string) ($row[$field] ?? '0'), $is_points);
}
$row['share_ratio'] = (int) ($row['share_ratio'] ?? 0);
return $row;
}
private function decode_order_meta(array $order): array {
if (is_array($order['meta_payload'] ?? null)) {
return $order['meta_payload'];